Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e1de662c7 | ||
|
|
f901b38fb7 | ||
|
|
665dc0856c | ||
|
|
2fa1bffc38 | ||
|
|
1262fcdcb5 | ||
|
|
3854bb5eaf | ||
|
|
07501c7c6e | ||
|
|
95522dcd0e | ||
|
|
47850dfcd6 | ||
|
|
59ef4da9dd | ||
|
|
57cf8df181 | ||
|
|
80af7ef041 | ||
|
|
e7fbbbcade | ||
|
|
cdc57bda2d | ||
|
|
1d9bc0246d | ||
|
|
77373cb563 | ||
|
|
8c84857670 | ||
|
|
ae8fa5571f | ||
|
|
4b525b463c | ||
|
|
44951a1f07 | ||
|
|
dc46be205a | ||
|
|
5dd5b4a863 | ||
|
|
c0735f17aa | ||
|
|
64e64df3bd | ||
|
|
2f0d1d4bcb | ||
|
|
c81fc38ecc | ||
|
|
8c33a18729 | ||
|
|
2e4ccc40ad | ||
|
|
59667f4648 | ||
|
|
c34d167f74 | ||
|
|
68b666c2e5 | ||
|
|
106dfb8327 | ||
|
|
779c24ba80 | ||
|
|
99f9a22fa4 | ||
|
|
092ff997b4 | ||
|
|
06282c47d8 | ||
|
|
6aba9a8801 |
68 changed files with 2774 additions and 7303 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -591,7 +591,6 @@ dependencies = [
|
|||
"cultmesh-rs",
|
||||
"cultnet-rs",
|
||||
"pretty_assertions",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
|
|
|
|||
101
README.md
101
README.md
|
|
@ -70,75 +70,47 @@ Local package surfaces:
|
|||
|
||||
## Idunn
|
||||
|
||||
Idunn is Odin's keepalive organ: the daemon package that should keep the known
|
||||
swarm alive after Odin has accepted where each daemon lives and what authority
|
||||
path may touch it. Individual daemons publish health and command boundaries;
|
||||
Idunn brings them up after reboots or crashes, watches health, and escalates
|
||||
operator-needed failures through Bifrost's CultMesh bridge. VoidBot owner-DM
|
||||
delivery is a demoted compatibility actuator, not the owner; the command belongs
|
||||
in Bifrost's Verse. Odin sees the daemons; Idunn keeps the apples from rotting.
|
||||
Agents do not deploy daemons directly. They configure Idunn's target catalog,
|
||||
release targets, migration commands, and command boundaries so Idunn can run
|
||||
the shared rollout primitive and leave typed witnesses behind.
|
||||
|
||||
Deployment consequence has one final owner: immediately before every migration,
|
||||
deploy, or restart process spawn, Idunn re-opens the root-owned deployment-brake
|
||||
CultCache and the dedicated operator public anchor. A released record must bind
|
||||
the canonical runtime (`yggdrasil` for `yggdrasil-local`), exact source revision
|
||||
(or `restart:<daemon>`), exact request id, operator, signature, and short expiry.
|
||||
Missing, corrupt, engaged, expired, foreign, wrong-scope, substituted, or
|
||||
badly-signed state fails closed. Planning and lifecycle-command publication do
|
||||
not bypass or cache this decision. The store and its sibling `.lock` live in
|
||||
`/var/lib/gamecult/idunn-authority`, a root-owned `root:idunn` `0750` directory;
|
||||
both files are `root:idunn` `0640`, and the daemon's systemd sandbox exposes the
|
||||
directory read-only. Idunn holds the lock shared from its final snapshot read
|
||||
through process spawn, so an engage cannot be defeated by previously captured
|
||||
released bytes. A release is not one-shot: its exact request/revision grant
|
||||
covers each migration and deploy spawn in that rollout until its short expiry;
|
||||
every spawn independently reopens and validates the same grant.
|
||||
Idunn (`GameCult/Idunn`) owns deployment and daemon survival for every GameCult
|
||||
target, Odin included: it builds from the target's recipe, activates and
|
||||
restarts the admitted incarnation, and holds the deployment and lifecycle
|
||||
brakes. Odin owns none of that. Odin does discovery, schema awareness,
|
||||
rendezvous, and interface aggregation, and it reads Idunn's topology projection
|
||||
to learn which incarnations exist. Agents do not deploy daemons directly: they
|
||||
configure the target's recipe and binding and let Idunn run the transaction.
|
||||
|
||||
Local package surfaces:
|
||||
|
||||
- Organ contract: `docs/idunn.md`
|
||||
- User README: `src/Idunn/README.md`
|
||||
- Rust daemon crate: `crates/idunn-daemon`
|
||||
- Rust lifecycle logic: `crates/odin-core/src/idunn.rs`
|
||||
- Runtime store: `scratch/idunn/idunn.keepalive.cc`
|
||||
- Hosted VoidBot release: upstream `main` -> Yggdrasil-local Idunn -> the
|
||||
root-owned `/srv/odin/deploy-manifests/voidbot` actuator. Local checkouts are
|
||||
development and diagnostics only; do not create a local deployment.
|
||||
- Hosted Odin release: branch `codex/ygg-idunn-independent-bootstrap`, built
|
||||
`cargo --locked` so `Cargo.lock` is the CultLib pin -> one immutable
|
||||
`/srv/odin/releases/<commit>` body -> `/srv/odin/current`. Odin signs its own
|
||||
liveness to Idunn, while Idunn starts independently and owns later restart or
|
||||
deployment actuation.
|
||||
- Operator escalation: Bifrost-owned CultMesh crossing; current compatibility
|
||||
delivery calls `scripts\notify-idunn-operator-alarm.ps1`, which asks Bifrost
|
||||
to publish a typed `gamecult.operator_dm_request.v1` CultMesh command document
|
||||
only after Idunn raises an alarm
|
||||
- Deployment recipe: `deployment/idunn/recipe.toml`
|
||||
- Operator binding: `/etc/gamecult/idunn/bindings/odin.toml` on the host, not in
|
||||
this repository
|
||||
- Live deployment and admission map: `state/map.yaml`
|
||||
- Idunn's contract, brakes, and runbooks: the `GameCult/Idunn` repository
|
||||
|
||||
## Authority Map
|
||||
|
||||
- Owner: Odin owns the network-wide Verse registry, schema catalog index, translation map, and accepted provider catalog/proxy surfaces.
|
||||
- Inputs: CultMesh/CultNet peer announcements, schema catalog responses, daemon
|
||||
health/provider publications over `cultnet.transport.rudp.v0`, local
|
||||
Docker/ADB debug facts, and provider-owned Eve/CultUI surfaces.
|
||||
health/provider publications over `cultnet.transport.rudp.v0`, Idunn's
|
||||
read-only topology projection, and provider-owned Eve/CultUI surfaces.
|
||||
- Outputs: CultCache-backed Odin state, CultMesh documents, and CultNet
|
||||
schema/catalog messages. Browser, GUI, TUI, and framebuffer renderers lower
|
||||
those documents outside Odin instead of asking Odin to host web surfaces.
|
||||
- Derived state: Gjallar's aggregate surface is derived from Odin/provider state; Eve clients derive pixels from that aggregate.
|
||||
- Forbidden writers: renderers do not probe the network or decide Verse truth; individual projects do not maintain private incompatible discovery ledgers once Odin can see them.
|
||||
- Shared paths: human dashboards, worker schedulers, Verse bootstrap code, and compact TUI views consume the same registry and schema catalog.
|
||||
- Deletion line: old per-host coordinator scripts should be deleted or reduced to deployment wrappers that start Odin.
|
||||
- Not Odin's: process lifecycle, restart, deployment, and daemon survival belong to Idunn.
|
||||
|
||||
## Run Locally On Starfire
|
||||
## Runtime Body
|
||||
|
||||
```powershell
|
||||
.\scripts\start-odin.ps1 -IdunnRudpHealth $env:IDUNN_RUDP_HEALTH
|
||||
```
|
||||
Odin runs as `odin-daemon` under Idunn on Yggdrasil, built from
|
||||
`deployment/idunn/recipe.toml`. There is no local start script. The daemon:
|
||||
|
||||
`-IdunnRudpHealth`, `ODIN_IDUNN_RUDP_HEALTH`, or `IDUNN_RUDP_HEALTH` must name
|
||||
the Idunn RUDP health endpoint. Odin does not assume a localhost health target.
|
||||
- reads Idunn's read-only projection to learn which incarnations of which
|
||||
targets exist;
|
||||
- admits provider presences over RUDP for those incarnations and persists them
|
||||
through CultMesh/CultCache;
|
||||
- publishes signed runtime-topology correlation records;
|
||||
- answers CultNet/RUDP snapshot and document requests on its route.
|
||||
|
||||
Odin's native document catalog is addressed by CultMesh URI. Concrete RUDP
|
||||
bootstrap is configured behind CultMesh URI resolution by the operator or by
|
||||
|
|
@ -152,28 +124,9 @@ That URI accepts typed document publication and schema/catalog requests through
|
|||
the shared CultMesh runtime. Consumers that need Odin's accepted surface can
|
||||
still request the current CultNet snapshot after CultMesh resolves the transport.
|
||||
|
||||
Legacy browser/deck lowerers must consume Odin's CultMesh state through their
|
||||
own lowering process. Odin no longer hosts browser-deck surfaces or publishes
|
||||
deck URLs as discovery seed material.
|
||||
|
||||
State and logs live under ignored `scratch/odin/`.
|
||||
|
||||
## Current First Body
|
||||
|
||||
The first executable is deliberately narrow:
|
||||
|
||||
- publishes provider catalog `odin.providers`;
|
||||
- persists the latest surface through local CultMesh/CultCache when `CultLib` packages are available at `E:\Projects\CultLib\packages`;
|
||||
- writes `scratch/odin/latest-surface.json` only when `--write-debug-surface-json` or `ODIN_WRITE_DEBUG_SURFACE_JSON=1` is explicitly supplied;
|
||||
- observes Starfire Docker and Periwinkle ADB as local debug/edge facts;
|
||||
- derives remote Verse presence from provider-owned CultMesh/CultNet
|
||||
advertisements and interface records, not TCP/SSH/systemd probes.
|
||||
- publishes explicit `verse` and `service` nodes for compact Eve/CultUI lowerers.
|
||||
- ingests provider-owned Eve/CultUI dashboards, including `mimir.live.stats` and `voidbot.swarm`, and embeds them as Odin `interface` nodes;
|
||||
- accepts live `gamecult.eve.provider_advertisement.v1` announcements through Odin's CultMesh/RUDP rendezvous path so daemons can announce schemas, surfaces, commands, nested Verses, and style capabilities without Odin scraping private dashboards;
|
||||
- accepts explicit local debug imports only when `--interfaceBindingStore` / `ODIN_INTERFACE_BINDING_STORES` entries are written as `cultmesh-store:file://...` URIs; raw filesystem paths are not discovery configuration;
|
||||
- preserves provider semantic addresses such as `asgard.starfire.bifrost/eve/tui` and `asgard.starfire.bifrost/eve/gui`, with CultNet routes carried as transport metadata rather than identity;
|
||||
- persists operator tiling intent as `odin.interface_layout.v1` in the Odin CultMesh store; ignored `scratch/odin/interface-layout.json` is migration input only.
|
||||
Browser and deck lowerers consume Odin's CultMesh state through their own
|
||||
lowering process. Odin does not host browser-deck surfaces or publish deck URLs
|
||||
as discovery seed material.
|
||||
|
||||
Provider advertisements and CultNet/RUDP transport profiles are the discovery
|
||||
path. External host probes, product health checks, port probes, and renderer
|
||||
|
|
|
|||
|
|
@ -13,11 +13,10 @@ path = "src/lib.rs"
|
|||
anyhow = "1"
|
||||
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "3bf1c0cea53e0092187fa2e59a75ba1571947ffe", package = "cultcache-rs" }
|
||||
cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "3bf1c0cea53e0092187fa2e59a75ba1571947ffe", package = "cultmesh-rs" }
|
||||
serde_json = "1"
|
||||
cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "3bf1c0cea53e0092187fa2e59a75ba1571947ffe", package = "cultnet-rs" }
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
rmp-serde = "1.3"
|
||||
pretty_assertions = "1.4"
|
||||
tempfile = "3"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,672 +0,0 @@
|
|||
use crate::documents::{
|
||||
IdunnDaemonHealthRecord, IdunnDeploymentRequestRecord, IdunnDesiredDaemonRecord,
|
||||
IdunnKeepaliveDecisionRecord, IdunnOperatorAlarmRecord, IdunnRestartRequestRecord,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct IdunnPlan {
|
||||
pub decision: IdunnKeepaliveDecisionRecord,
|
||||
pub deployment_request: Option<IdunnDeploymentRequestRecord>,
|
||||
pub restart_request: Option<IdunnRestartRequestRecord>,
|
||||
pub operator_alarm: Option<IdunnOperatorAlarmRecord>,
|
||||
}
|
||||
|
||||
pub fn plan_keepalive(
|
||||
desired: &IdunnDesiredDaemonRecord,
|
||||
health: &IdunnDaemonHealthRecord,
|
||||
now: impl Into<String>,
|
||||
) -> IdunnPlan {
|
||||
let now = now.into();
|
||||
let decision_id = format!("{}:{}", desired.daemon_id, now);
|
||||
|
||||
if !desired.enabled {
|
||||
return IdunnPlan {
|
||||
decision: decision(&decision_id, desired, "noop", "daemon is disabled", &now),
|
||||
deployment_request: None,
|
||||
restart_request: None,
|
||||
operator_alarm: None,
|
||||
};
|
||||
}
|
||||
|
||||
if is_healthy(&health.state) {
|
||||
return IdunnPlan {
|
||||
decision: decision(&decision_id, desired, "observe", "daemon is healthy", &now),
|
||||
deployment_request: None,
|
||||
restart_request: None,
|
||||
operator_alarm: None,
|
||||
};
|
||||
}
|
||||
|
||||
if health.state == "warming" {
|
||||
return IdunnPlan {
|
||||
decision: decision(
|
||||
&decision_id,
|
||||
desired,
|
||||
"observe",
|
||||
"daemon is warming under provider-owned progress authority",
|
||||
&now,
|
||||
),
|
||||
deployment_request: None,
|
||||
restart_request: None,
|
||||
operator_alarm: None,
|
||||
};
|
||||
}
|
||||
|
||||
if health.state == "degraded" || health.state == "dependency-unavailable" {
|
||||
let alarm_id = format!("alarm:{}:{}", desired.daemon_id, now);
|
||||
let reason = format!(
|
||||
"health is {}; local restart/deploy is not the owner of this failure: {}",
|
||||
health.state, health.detail
|
||||
);
|
||||
return IdunnPlan {
|
||||
decision: decision(&decision_id, desired, "alarm", &reason, &now),
|
||||
deployment_request: None,
|
||||
restart_request: None,
|
||||
operator_alarm: Some(IdunnOperatorAlarmRecord {
|
||||
alarm_id,
|
||||
daemon_id: desired.daemon_id.clone(),
|
||||
severity: "operator-action-required".to_string(),
|
||||
reason,
|
||||
escalation_target: "bifrost.operator-notification".to_string(),
|
||||
raised_at: now,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(command) = desired.deploy_command.as_deref() {
|
||||
if !command.trim().is_empty() && health.state == "stale-deployment" {
|
||||
let request_id = format!("deploy:{}:{}", desired.daemon_id, now);
|
||||
return IdunnPlan {
|
||||
decision: decision(
|
||||
&decision_id,
|
||||
desired,
|
||||
"deploy",
|
||||
&format!(
|
||||
"health is {}; deployment authority is available",
|
||||
health.state
|
||||
),
|
||||
&now,
|
||||
),
|
||||
deployment_request: Some(IdunnDeploymentRequestRecord {
|
||||
request_id,
|
||||
daemon_id: desired.daemon_id.clone(),
|
||||
command: command.to_string(),
|
||||
authority: desired.authority.clone(),
|
||||
requested_at: now,
|
||||
repository_full_name: String::new(),
|
||||
upstream_ref: String::new(),
|
||||
source_revision: String::new(),
|
||||
release_authority_id: String::new(),
|
||||
release_authority_envelope_sha256: String::new(),
|
||||
requires_bifrost_authority: false,
|
||||
}),
|
||||
restart_request: None,
|
||||
operator_alarm: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if health.state == "stale-deployment" {
|
||||
let alarm_id = format!("alarm:{}:{}", desired.daemon_id, now);
|
||||
let reason = format!(
|
||||
"health is stale-deployment under {}; no deploy command authority is available",
|
||||
desired.health_contract
|
||||
);
|
||||
return IdunnPlan {
|
||||
decision: decision(&decision_id, desired, "alarm", &reason, &now),
|
||||
deployment_request: None,
|
||||
restart_request: None,
|
||||
operator_alarm: Some(IdunnOperatorAlarmRecord {
|
||||
alarm_id,
|
||||
daemon_id: desired.daemon_id.clone(),
|
||||
severity: "operator-action-required".to_string(),
|
||||
reason,
|
||||
escalation_target: "bifrost.operator-notification".to_string(),
|
||||
raised_at: now,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
match desired.restart_command.as_deref() {
|
||||
Some(command) if !command.trim().is_empty() => {
|
||||
let request_id = format!("restart:{}:{}", desired.daemon_id, now);
|
||||
IdunnPlan {
|
||||
decision: decision(
|
||||
&decision_id,
|
||||
desired,
|
||||
"restart",
|
||||
&format!("health is {}; restart authority is available", health.state),
|
||||
&now,
|
||||
),
|
||||
restart_request: Some(IdunnRestartRequestRecord {
|
||||
request_id,
|
||||
daemon_id: desired.daemon_id.clone(),
|
||||
command: command.to_string(),
|
||||
authority: desired.authority.clone(),
|
||||
requested_at: now,
|
||||
}),
|
||||
deployment_request: None,
|
||||
operator_alarm: None,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let alarm_id = format!("alarm:{}:{}", desired.daemon_id, now);
|
||||
let reason = format!(
|
||||
"health is {}; no restart command authority is available",
|
||||
health.state
|
||||
);
|
||||
IdunnPlan {
|
||||
decision: decision(&decision_id, desired, "alarm", &reason, &now),
|
||||
deployment_request: None,
|
||||
restart_request: None,
|
||||
operator_alarm: Some(IdunnOperatorAlarmRecord {
|
||||
alarm_id,
|
||||
daemon_id: desired.daemon_id.clone(),
|
||||
severity: "operator-action-required".to_string(),
|
||||
reason,
|
||||
escalation_target: "bifrost.operator-notification".to_string(),
|
||||
raised_at: now,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_healthy(state: &str) -> bool {
|
||||
matches!(state, "active" | "healthy" | "ok" | "running")
|
||||
}
|
||||
|
||||
fn decision(
|
||||
decision_id: &str,
|
||||
desired: &IdunnDesiredDaemonRecord,
|
||||
action: &str,
|
||||
reason: &str,
|
||||
decided_at: &str,
|
||||
) -> IdunnKeepaliveDecisionRecord {
|
||||
IdunnKeepaliveDecisionRecord {
|
||||
decision_id: decision_id.to_string(),
|
||||
daemon_id: desired.daemon_id.clone(),
|
||||
action: action.to_string(),
|
||||
reason: reason.to_string(),
|
||||
authority: desired.authority.clone(),
|
||||
decided_at: decided_at.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::documents::{
|
||||
IDUNN_COMMAND_BOUNDARY_SCHEMA, IDUNN_DAEMON_SURGERY_PLAN_SCHEMA,
|
||||
IDUNN_DAEMON_TRANSPORT_PROFILE_SCHEMA, IDUNN_DEPLOYMENT_ARTIFACT_SCHEMA,
|
||||
IDUNN_DESIRED_DAEMON_SCHEMA, IDUNN_RELEASE_TARGET_SCHEMA, IDUNN_ROLLOUT_PLAN_SCHEMA,
|
||||
IDUNN_STATE_MIGRATION_PLAN_SCHEMA, IDUNN_SWARM_SURGERY_PLAN_SCHEMA,
|
||||
IdunnCommandBoundaryRecord, IdunnDaemonSurgeryPlanRecord,
|
||||
IdunnDaemonTransportProfileRecord, IdunnDeploymentArtifactRecord, IdunnReleaseTargetRecord,
|
||||
IdunnRolloutPlanRecord, IdunnRuntimeTransportCheckRecord, IdunnStateMigrationPlanRecord,
|
||||
IdunnSwarmSurgeryPlanRecord, OdinDocuments,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use cultmesh_rs::{CultMesh, CultMeshNodeOptions};
|
||||
|
||||
fn desired(restart_command: Option<&str>) -> IdunnDesiredDaemonRecord {
|
||||
IdunnDesiredDaemonRecord {
|
||||
daemon_id: "voidbot".to_string(),
|
||||
verse_id: "local".to_string(),
|
||||
name: "VoidBot".to_string(),
|
||||
enabled: true,
|
||||
health_command: None,
|
||||
restart_command: restart_command.map(ToString::to_string),
|
||||
authority: "idunn-supervisor-command".to_string(),
|
||||
max_silence_seconds: 60,
|
||||
observed_at: "2026-06-04T00:00:00Z".to_string(),
|
||||
deploy_command: None,
|
||||
health_contract: "test.cultnet-rudp-health".to_string(),
|
||||
transport_profile_id: "transport:voidbot".to_string(),
|
||||
command_boundary_id: "command-boundary:voidbot".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn health(state: &str) -> IdunnDaemonHealthRecord {
|
||||
IdunnDaemonHealthRecord {
|
||||
daemon_id: "voidbot".to_string(),
|
||||
state: state.to_string(),
|
||||
detail: "unit daemon publication".to_string(),
|
||||
observed_at: "2026-06-04T00:00:01Z".to_string(),
|
||||
health_contract: "test.cultnet-rudp-health".to_string(),
|
||||
publication_source: "debug-command".to_string(),
|
||||
transport: "debug.local-command".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn healthy_daemon_is_observed_without_restart() {
|
||||
let plan = plan_keepalive(
|
||||
&desired(Some("npm start")),
|
||||
&health("active"),
|
||||
"2026-06-04T00:00:02Z",
|
||||
);
|
||||
|
||||
assert_eq!(plan.decision.action, "observe");
|
||||
assert!(plan.deployment_request.is_none());
|
||||
assert!(plan.restart_request.is_none());
|
||||
assert!(plan.operator_alarm.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warming_daemon_is_observed_without_actuation_or_promotion() {
|
||||
let mut desired = desired(Some("systemctl restart epiphany"));
|
||||
desired.deploy_command = Some("deploy epiphany".to_string());
|
||||
let plan = plan_keepalive(&desired, &health("warming"), "2026-07-17T00:00:02Z");
|
||||
|
||||
assert_eq!(plan.decision.action, "observe");
|
||||
assert!(plan.decision.reason.contains("provider-owned progress"));
|
||||
assert!(plan.deployment_request.is_none());
|
||||
assert!(plan.restart_request.is_none());
|
||||
assert!(plan.operator_alarm.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unhealthy_daemon_with_restart_authority_requests_restart() {
|
||||
let plan = plan_keepalive(
|
||||
&desired(Some("npm start")),
|
||||
&health("failed"),
|
||||
"2026-06-04T00:00:02Z",
|
||||
);
|
||||
|
||||
assert_eq!(plan.decision.action, "restart");
|
||||
assert!(plan.deployment_request.is_none());
|
||||
assert_eq!(plan.restart_request.unwrap().command, "npm start");
|
||||
assert!(plan.operator_alarm.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unhealthy_daemon_with_deploy_authority_requests_deployment_before_restart() {
|
||||
let mut desired = desired(Some("systemctl restart gjallar"));
|
||||
desired.deploy_command = Some("deploy gjallar".to_string());
|
||||
let plan = plan_keepalive(
|
||||
&desired,
|
||||
&health("stale-deployment"),
|
||||
"2026-06-04T00:00:02Z",
|
||||
);
|
||||
|
||||
assert_eq!(plan.decision.action, "deploy");
|
||||
assert_eq!(plan.deployment_request.unwrap().command, "deploy gjallar");
|
||||
assert!(plan.restart_request.is_none());
|
||||
assert!(plan.operator_alarm.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_daemon_with_deploy_and_restart_authority_restarts_instead_of_redeploying() {
|
||||
let mut desired = desired(Some("systemctl restart gjallar"));
|
||||
desired.deploy_command = Some("deploy gjallar".to_string());
|
||||
let plan = plan_keepalive(&desired, &health("failed"), "2026-06-04T00:00:02Z");
|
||||
|
||||
assert_eq!(plan.decision.action, "restart");
|
||||
assert!(plan.deployment_request.is_none());
|
||||
assert_eq!(
|
||||
plan.restart_request.unwrap().command,
|
||||
"systemctl restart gjallar"
|
||||
);
|
||||
assert!(plan.operator_alarm.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_unavailable_raises_alarm_instead_of_redeploying_or_restarting() {
|
||||
let mut desired = desired(Some("systemctl restart gjallar"));
|
||||
desired.deploy_command = Some("deploy gjallar".to_string());
|
||||
let plan = plan_keepalive(
|
||||
&desired,
|
||||
&health("dependency-unavailable"),
|
||||
"2026-06-04T00:00:02Z",
|
||||
);
|
||||
|
||||
assert_eq!(plan.decision.action, "alarm");
|
||||
assert!(plan.deployment_request.is_none());
|
||||
assert!(plan.restart_request.is_none());
|
||||
assert!(plan.operator_alarm.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_deployment_without_deploy_authority_raises_alarm_instead_of_restarting() {
|
||||
let plan = plan_keepalive(
|
||||
&desired(Some("systemctl restart old-app")),
|
||||
&health("stale-deployment"),
|
||||
"2026-06-04T00:00:02Z",
|
||||
);
|
||||
|
||||
assert_eq!(plan.decision.action, "alarm");
|
||||
assert!(plan.deployment_request.is_none());
|
||||
assert!(plan.restart_request.is_none());
|
||||
assert!(plan.operator_alarm.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unhealthy_daemon_without_authority_raises_operator_alarm() {
|
||||
let plan = plan_keepalive(&desired(None), &health("failed"), "2026-06-04T00:00:02Z");
|
||||
|
||||
assert_eq!(plan.decision.action, "alarm");
|
||||
assert!(plan.deployment_request.is_none());
|
||||
assert!(plan.restart_request.is_none());
|
||||
assert_eq!(
|
||||
plan.operator_alarm.unwrap().escalation_target,
|
||||
"bifrost.operator-notification"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idunn_records_round_trip_through_cultmesh() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store_path = temp.path().join("idunn.cc");
|
||||
let desired = desired(Some("npm start"));
|
||||
let health = health("failed");
|
||||
let plan = plan_keepalive(&desired, &health, "2026-06-04T00:00:02Z");
|
||||
let request = plan.restart_request.unwrap();
|
||||
|
||||
let mut node = CultMesh::create_node(
|
||||
&store_path,
|
||||
OdinDocuments,
|
||||
CultMeshNodeOptions {
|
||||
runtime_id: "idunn-test".to_string(),
|
||||
pull_on_start: true,
|
||||
},
|
||||
)?;
|
||||
node.put(&desired.daemon_id, &desired)?;
|
||||
node.put(&health.daemon_id, &health)?;
|
||||
node.put(&plan.decision.decision_id, &plan.decision)?;
|
||||
node.put(&request.request_id, &request)?;
|
||||
let surgery_plan = IdunnDaemonSurgeryPlanRecord {
|
||||
plan_id: "surgery:voidbot".to_string(),
|
||||
daemon_id: "voidbot".to_string(),
|
||||
severity: "high".to_string(),
|
||||
status: "transport-surgery-required".to_string(),
|
||||
owner: "VoidBot internal provider stack".to_string(),
|
||||
objective: "Publish VoidBot internal daemon health over CultNet/RUDP".to_string(),
|
||||
current_mechanism: "daemon-published RUDP health".to_string(),
|
||||
intended_authority: "daemon-owned CultNet/RUDP health record".to_string(),
|
||||
cut_line: "daemon health must be published over CultNet/RUDP".to_string(),
|
||||
steps: vec![
|
||||
"update CultLib".to_string(),
|
||||
"publish RUDP health".to_string(),
|
||||
],
|
||||
blockers: vec!["runtime CultLib update".to_string()],
|
||||
updated_at: "2026-06-04T00:00:02Z".to_string(),
|
||||
};
|
||||
let swarm_surgery_plan = IdunnSwarmSurgeryPlanRecord {
|
||||
plan_id: "swarm-surgery:starfire-local".to_string(),
|
||||
profile: "starfire-local".to_string(),
|
||||
status: "active-transport-migration".to_string(),
|
||||
owner: "Idunn swarm supervisor".to_string(),
|
||||
objective: "Move daemon awareness to CultNet/RUDP".to_string(),
|
||||
current_mechanism: "daemon-published RUDP health required".to_string(),
|
||||
invariants: vec!["daemon truth is typed RUDP state".to_string()],
|
||||
phases: vec!["install Muninn RUDP health".to_string()],
|
||||
current_phase: "Phase 2".to_string(),
|
||||
next_target: "starfire-muninn".to_string(),
|
||||
cut_line: "daemon health must be published over CultNet/RUDP".to_string(),
|
||||
verification_layer: "CultMesh keepalive store".to_string(),
|
||||
updated_at: "2026-06-04T00:00:02Z".to_string(),
|
||||
};
|
||||
node.put(&swarm_surgery_plan.plan_id, &swarm_surgery_plan)?;
|
||||
node.put(&surgery_plan.plan_id, &surgery_plan)?;
|
||||
let transport_profile = IdunnDaemonTransportProfileRecord {
|
||||
profile_id: "transport:voidbot".to_string(),
|
||||
daemon_id: "voidbot".to_string(),
|
||||
target_transport: "cultnet.transport.rudp.v0".to_string(),
|
||||
current_transport: "missing-daemon-published-rudp-health".to_string(),
|
||||
state: "migration-required".to_string(),
|
||||
health_contract: "test.cultnet-rudp-health".to_string(),
|
||||
publication_schema: "idunn.daemon_health.v1".to_string(),
|
||||
debug_mechanism: "none".to_string(),
|
||||
cut_line: "daemon health must be published over CultNet/RUDP".to_string(),
|
||||
observed_at: "2026-06-04T00:00:02Z".to_string(),
|
||||
};
|
||||
node.put(&transport_profile.profile_id, &transport_profile)?;
|
||||
let command_boundary = IdunnCommandBoundaryRecord {
|
||||
boundary_id: "command-boundary:voidbot".to_string(),
|
||||
daemon_id: "voidbot".to_string(),
|
||||
owner: "idunn-supervisor-command-boundary".to_string(),
|
||||
restart_authority: "idunn-supervisor-command.restart".to_string(),
|
||||
deploy_authority: "none".to_string(),
|
||||
health_authority: "daemon-published-rudp-health".to_string(),
|
||||
alarm_authority: "bifrost.operator-notification".to_string(),
|
||||
command_lowerings: vec!["npm start".to_string()],
|
||||
forbidden_authority: "debug probes do not own truth".to_string(),
|
||||
observed_at: "2026-06-04T00:00:02Z".to_string(),
|
||||
};
|
||||
node.put(&command_boundary.boundary_id, &command_boundary)?;
|
||||
let release_target = IdunnReleaseTargetRecord {
|
||||
target_id: "release-target:voidbot".to_string(),
|
||||
daemon_id: "voidbot".to_string(),
|
||||
repo: "VoidBot".to_string(),
|
||||
repo_path: "E:\\Projects\\VoidBot".to_string(),
|
||||
upstream_remote: "origin".to_string(),
|
||||
upstream_branch: "main".to_string(),
|
||||
desired_revision: "abc123".to_string(),
|
||||
deployed_revision: "old123".to_string(),
|
||||
artifact_strategy: "source-archive-from-upstream-main".to_string(),
|
||||
rollout_strategy: "restart-after-verified-build".to_string(),
|
||||
state_migration_authority: "daemon-owned-command".to_string(),
|
||||
zero_downtime_capability: "restart-required".to_string(),
|
||||
status: "tracked".to_string(),
|
||||
observed_at: "2026-06-04T00:00:02Z".to_string(),
|
||||
repository_full_name: "GameCult/VoidBot".to_string(),
|
||||
upstream_ref: "refs/heads/main".to_string(),
|
||||
release_authority_id: "release:GameCult/VoidBot:refs/heads/main:abc123".to_string(),
|
||||
release_authority_envelope_sha256: "authority-sha".to_string(),
|
||||
release_authority_status: "authorized".to_string(),
|
||||
requires_bifrost_authority: true,
|
||||
observed_upstream_revision: "abc123".to_string(),
|
||||
minimum_source_revision: None,
|
||||
};
|
||||
let artifact = IdunnDeploymentArtifactRecord {
|
||||
artifact_id: "artifact:voidbot:main".to_string(),
|
||||
daemon_id: "voidbot".to_string(),
|
||||
source_revision: "abc123".to_string(),
|
||||
source_branch: "main".to_string(),
|
||||
source_remote: "origin".to_string(),
|
||||
artifact_kind: "source-archive-from-upstream-main".to_string(),
|
||||
artifact_uri: "built-by-deploy-command".to_string(),
|
||||
sha256: "pending-deploy-command".to_string(),
|
||||
built_at: "2026-06-04T00:00:02Z".to_string(),
|
||||
release_authority_id: release_target.release_authority_id.clone(),
|
||||
release_authority_envelope_sha256: release_target
|
||||
.release_authority_envelope_sha256
|
||||
.clone(),
|
||||
};
|
||||
let migration_plan = IdunnStateMigrationPlanRecord {
|
||||
plan_id: "migration:voidbot:main".to_string(),
|
||||
daemon_id: "voidbot".to_string(),
|
||||
from_schema_version: "deployed-state".to_string(),
|
||||
to_schema_version: "target-revision-state".to_string(),
|
||||
authority: "daemon-owned-migrator".to_string(),
|
||||
command: "voidbot migrate-state".to_string(),
|
||||
strategy: "backup-then-daemon-migrator".to_string(),
|
||||
backup_required: true,
|
||||
zero_downtime_required: false,
|
||||
status: "planned".to_string(),
|
||||
planned_at: "2026-06-04T00:00:02Z".to_string(),
|
||||
};
|
||||
let rollout_plan = IdunnRolloutPlanRecord {
|
||||
plan_id: "rollout:voidbot:main".to_string(),
|
||||
daemon_id: "voidbot".to_string(),
|
||||
desired_revision: "abc123".to_string(),
|
||||
deployed_revision: "old123".to_string(),
|
||||
strategy: "restart-after-verified-build".to_string(),
|
||||
phases: vec![
|
||||
"fetch upstream main".to_string(),
|
||||
"verify health".to_string(),
|
||||
],
|
||||
migration_plan_id: migration_plan.plan_id.clone(),
|
||||
artifact_id: artifact.artifact_id.clone(),
|
||||
status: "planned".to_string(),
|
||||
planned_at: "2026-06-04T00:00:02Z".to_string(),
|
||||
};
|
||||
let release_payload = rmp_serde::to_vec(&release_target)?;
|
||||
assert_eq!(&release_payload[..3], &[0xdc, 0, 22]);
|
||||
let mut pre_floor_payload = release_payload.clone();
|
||||
assert_eq!(pre_floor_payload.pop(), Some(0xc0));
|
||||
pre_floor_payload[2] = 21;
|
||||
assert!(
|
||||
rmp_serde::from_slice::<IdunnReleaseTargetRecord>(&pre_floor_payload).is_err(),
|
||||
"release-target v3 must not decode a pre-floor positional body"
|
||||
);
|
||||
node.put(&release_target.target_id, &release_target)?;
|
||||
node.put(&artifact.artifact_id, &artifact)?;
|
||||
node.put(&migration_plan.plan_id, &migration_plan)?;
|
||||
node.put(&rollout_plan.plan_id, &rollout_plan)?;
|
||||
let runtime_check = IdunnRuntimeTransportCheckRecord {
|
||||
check_id: "idunn-runtime-rudp-loopback".to_string(),
|
||||
runtime_id: "idunn-daemon".to_string(),
|
||||
transport: "cultnet.transport.rudp.v0".to_string(),
|
||||
state: "available".to_string(),
|
||||
detail: "loopback acknowledged".to_string(),
|
||||
observed_at: "2026-06-04T00:00:02Z".to_string(),
|
||||
};
|
||||
node.put(&runtime_check.check_id, &runtime_check)?;
|
||||
|
||||
let reloaded = CultMesh::create_node(
|
||||
&store_path,
|
||||
OdinDocuments,
|
||||
CultMeshNodeOptions {
|
||||
runtime_id: "idunn-test-reloaded".to_string(),
|
||||
pull_on_start: true,
|
||||
},
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.documents()
|
||||
.binding("idunn.desired_daemon")
|
||||
.and_then(|binding| binding.payload_schema_version.clone())
|
||||
.as_deref(),
|
||||
Some(IDUNN_DESIRED_DAEMON_SCHEMA)
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.documents()
|
||||
.binding("idunn.swarm_surgery_plan")
|
||||
.and_then(|binding| binding.payload_schema_version.clone())
|
||||
.as_deref(),
|
||||
Some(IDUNN_SWARM_SURGERY_PLAN_SCHEMA)
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.documents()
|
||||
.binding("idunn.daemon_surgery_plan")
|
||||
.and_then(|binding| binding.payload_schema_version.clone())
|
||||
.as_deref(),
|
||||
Some(IDUNN_DAEMON_SURGERY_PLAN_SCHEMA)
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.documents()
|
||||
.binding("idunn.daemon_transport_profile")
|
||||
.and_then(|binding| binding.payload_schema_version.clone())
|
||||
.as_deref(),
|
||||
Some(IDUNN_DAEMON_TRANSPORT_PROFILE_SCHEMA)
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.documents()
|
||||
.binding("idunn.command_boundary")
|
||||
.and_then(|binding| binding.payload_schema_version.clone())
|
||||
.as_deref(),
|
||||
Some(IDUNN_COMMAND_BOUNDARY_SCHEMA)
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.documents()
|
||||
.binding("idunn.release_target")
|
||||
.and_then(|binding| binding.payload_schema_version.clone())
|
||||
.as_deref(),
|
||||
Some(IDUNN_RELEASE_TARGET_SCHEMA)
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.documents()
|
||||
.binding("idunn.deployment_artifact")
|
||||
.and_then(|binding| binding.payload_schema_version.clone())
|
||||
.as_deref(),
|
||||
Some(IDUNN_DEPLOYMENT_ARTIFACT_SCHEMA)
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.documents()
|
||||
.binding("idunn.state_migration_plan")
|
||||
.and_then(|binding| binding.payload_schema_version.clone())
|
||||
.as_deref(),
|
||||
Some(IDUNN_STATE_MIGRATION_PLAN_SCHEMA)
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.documents()
|
||||
.binding("idunn.rollout_plan")
|
||||
.and_then(|binding| binding.payload_schema_version.clone())
|
||||
.as_deref(),
|
||||
Some(IDUNN_ROLLOUT_PLAN_SCHEMA)
|
||||
);
|
||||
let reloaded_desired =
|
||||
reloaded.get_required::<IdunnDesiredDaemonRecord>(&desired.daemon_id)?;
|
||||
assert_eq!(reloaded_desired.transport_profile_id, "transport:voidbot");
|
||||
assert_eq!(
|
||||
reloaded_desired.command_boundary_id,
|
||||
"command-boundary:voidbot"
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.get_required::<IdunnKeepaliveDecisionRecord>(&plan.decision.decision_id)?
|
||||
.action,
|
||||
"restart"
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.get_required::<IdunnSwarmSurgeryPlanRecord>(&swarm_surgery_plan.plan_id)?
|
||||
.next_target,
|
||||
"starfire-muninn"
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.get_required::<IdunnDaemonSurgeryPlanRecord>(&surgery_plan.plan_id)?
|
||||
.cut_line,
|
||||
"daemon health must be published over CultNet/RUDP"
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.get_required::<IdunnDaemonTransportProfileRecord>(&transport_profile.profile_id)?
|
||||
.target_transport,
|
||||
"cultnet.transport.rudp.v0"
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.get_required::<IdunnCommandBoundaryRecord>(&command_boundary.boundary_id)?
|
||||
.health_authority,
|
||||
"daemon-published-rudp-health"
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.get_required::<IdunnRuntimeTransportCheckRecord>(&runtime_check.check_id)?
|
||||
.state,
|
||||
"available"
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.get_required::<IdunnReleaseTargetRecord>(&release_target.target_id)?
|
||||
.upstream_branch,
|
||||
"main"
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.get_required::<IdunnRolloutPlanRecord>(&rollout_plan.plan_id)?
|
||||
.migration_plan_id,
|
||||
"migration:voidbot:main"
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.get_required::<IdunnRestartRequestRecord>(&request.request_id)?
|
||||
.command,
|
||||
"npm start"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
pub mod discovery;
|
||||
pub mod documents;
|
||||
pub mod idunn;
|
||||
pub mod pipeline;
|
||||
pub mod ports;
|
||||
pub mod repository;
|
||||
|
||||
pub use discovery::*;
|
||||
pub use documents::*;
|
||||
pub use idunn::*;
|
||||
pub use pipeline::*;
|
||||
pub use ports::*;
|
||||
pub use repository::*;
|
||||
|
|
|
|||
|
|
@ -250,12 +250,36 @@ impl CultCacheIdunnProjectionSource {
|
|||
if !self.path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(
|
||||
SingleFileMessagePackBackingStore::new(&self.path).pull_all_read_only_snapshot()?,
|
||||
))
|
||||
SingleFileMessagePackBackingStore::new(&self.path)
|
||||
.pull_all_read_only_snapshot()
|
||||
.map(Some)
|
||||
.map_err(|error| {
|
||||
// The store's decode step reports its failures as text, so an
|
||||
// I/O error in the chain here is from opening or reading the
|
||||
// file and from nothing the file said.
|
||||
if error.chain().any(|cause| cause.is::<std::io::Error>()) {
|
||||
error.context(ProjectionUnreadable)
|
||||
} else {
|
||||
error
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The projection file could not be opened or read. Marks the read step only:
|
||||
/// a failure while decoding or validating what was read never carries it,
|
||||
/// whatever it wraps.
|
||||
#[derive(Debug)]
|
||||
pub struct ProjectionUnreadable;
|
||||
|
||||
impl std::fmt::Display for ProjectionUnreadable {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("Idunn's projection file could not be read")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ProjectionUnreadable {}
|
||||
|
||||
fn provider_anchor(
|
||||
entries: &[CultCacheEnvelope],
|
||||
target: &str,
|
||||
|
|
@ -756,6 +780,15 @@ where
|
|||
.context(PresenceAuthorityRefused(
|
||||
"the projected runtime authority cannot be verified",
|
||||
))?;
|
||||
// A host whose clock has not reached the anchor's binding (booting
|
||||
// before time sync) cannot yet tell whether the anchor is good. That is
|
||||
// not a refusal of the authority, so it must not end Odin: it retries.
|
||||
if authority.is_none()
|
||||
&& incoming.is_some()
|
||||
&& only_clock_precedes_anchor(&projection, &projection_disagreements, now)
|
||||
{
|
||||
bail!("the clock ({now}) precedes the provider trust anchor's binding; retrying");
|
||||
}
|
||||
let (lease_sha256, lease_disagreement) = classify_current_lease(&projection)?;
|
||||
let query = OdinStoreQuery {
|
||||
incarnation: IncarnationRef::of(&projection.expected)?,
|
||||
|
|
@ -1274,6 +1307,8 @@ fn dependency_permits_ready(kind: &str, ready: bool) -> bool {
|
|||
matches!(kind, "optional" | "external-operator-binding") || ready
|
||||
}
|
||||
|
||||
const PROVIDER_ANCHOR_TIME_CODE: &str = "provider-trust-anchor-time";
|
||||
|
||||
fn classify_runtime_authority(
|
||||
projection: &IdunnRuntimeProjection,
|
||||
idunn_anchor: &ServiceIdentityTrustAnchor,
|
||||
|
|
@ -1334,7 +1369,7 @@ fn classify_runtime_authority(
|
|||
.is_some_and(|expires_at| now >= expires_at)
|
||||
{
|
||||
disagreements.push(OdinTopologyDisagreement {
|
||||
code: "provider-trust-anchor-time".into(),
|
||||
code: PROVIDER_ANCHOR_TIME_CODE.into(),
|
||||
expected: Some(format!("current-at:{now}")),
|
||||
observed: Some(format!(
|
||||
"bound:{};expires:{}",
|
||||
|
|
@ -1404,6 +1439,23 @@ fn classify_runtime_authority(
|
|||
Ok((authority, disagreements))
|
||||
}
|
||||
|
||||
/// The projected provider anchor is not yet bound at `now` and nothing else
|
||||
/// disagrees, so a later reading of the same projection would verify.
|
||||
fn only_clock_precedes_anchor(
|
||||
projection: &IdunnRuntimeProjection,
|
||||
disagreements: &[OdinTopologyDisagreement],
|
||||
now: u64,
|
||||
) -> bool {
|
||||
projection.provider_anchor.as_ref().is_some_and(|anchor| {
|
||||
anchor.bound_at_unix_millis > now
|
||||
&& !anchor
|
||||
.expires_at_unix_millis
|
||||
.is_some_and(|expires_at| now >= expires_at)
|
||||
}) && disagreements
|
||||
.iter()
|
||||
.all(|disagreement| disagreement.code == PROVIDER_ANCHOR_TIME_CODE)
|
||||
}
|
||||
|
||||
fn classify_current_lease(
|
||||
projection: &IdunnRuntimeProjection,
|
||||
) -> Result<(Option<String>, Option<OdinTopologyDisagreement>)> {
|
||||
|
|
@ -2558,6 +2610,82 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn is_refused(error: &anyhow::Error) -> bool {
|
||||
error.downcast_ref::<PresenceAuthorityRefused>().is_some()
|
||||
}
|
||||
|
||||
/// A host whose clock has not reached the anchor's binding (booting before
|
||||
/// time sync) is retried, not refused: the same claim lands once the clock
|
||||
/// catches up. The anchor is bound at NOW - 100.
|
||||
#[test]
|
||||
fn a_clock_before_the_anchors_binding_is_retried_not_refused() -> Result<()> {
|
||||
let world = TestWorld::new()?;
|
||||
let service = world.service("ghostlight", Vec::new(), false)?;
|
||||
let claim = service.signed_presence(1, |_| {})?;
|
||||
let error = world
|
||||
.engine(NOW - 200)
|
||||
.admit_presence("ghostlight", &claim, NOW - 200)
|
||||
.unwrap_err();
|
||||
assert!(!is_refused(&error), "{error:#}");
|
||||
world
|
||||
.engine(NOW)
|
||||
.admit_presence("ghostlight", &claim, NOW)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The clock exemption is only for the binding time. An anchor that has
|
||||
/// expired, or one that also disagrees with Expected, is a refusal.
|
||||
#[test]
|
||||
fn only_a_clock_before_the_binding_is_exempt_from_refusal() -> Result<()> {
|
||||
let world = TestWorld::new()?;
|
||||
let mut service = world.service("ghostlight", Vec::new(), false)?;
|
||||
let claim = service.signed_presence(1, |_| {})?;
|
||||
|
||||
service.projection.provider_anchor.as_mut().unwrap().expires_at_unix_millis =
|
||||
Some(NOW - 50);
|
||||
world.publish_projection(&service.projection)?;
|
||||
let error = world
|
||||
.engine(NOW)
|
||||
.admit_presence("ghostlight", &claim, NOW)
|
||||
.unwrap_err();
|
||||
assert!(is_refused(&error), "expired: {error:#}");
|
||||
|
||||
service.projection.provider_anchor.as_mut().unwrap().expires_at_unix_millis = None;
|
||||
service.projection.provider_anchor.as_mut().unwrap().runtime_id = "other-runtime".into();
|
||||
world.publish_projection(&service.projection)?;
|
||||
let error = world
|
||||
.engine(NOW - 200)
|
||||
.admit_presence("ghostlight", &claim, NOW - 200)
|
||||
.unwrap_err();
|
||||
assert!(is_refused(&error), "early and mismatched: {error:#}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The clock exemption belongs to a claim being admitted. A refresh has no
|
||||
/// claim to refuse: with the clock before the binding it still writes the
|
||||
/// incarnation's correlation, carrying the time disagreement, and neither
|
||||
/// fails nor is refused.
|
||||
#[test]
|
||||
fn a_refresh_before_the_anchors_binding_records_the_time_disagreement() -> Result<()> {
|
||||
let world = TestWorld::new()?;
|
||||
let service = world.service("ghostlight", Vec::new(), false)?;
|
||||
let signed = world
|
||||
.engine(NOW - 200)
|
||||
.refresh(&service.incarnation()?)?
|
||||
.expect("Idunn projects the incarnation");
|
||||
let correlation = decode_signed(&signed)?;
|
||||
assert!(
|
||||
correlation
|
||||
.disagreements
|
||||
.iter()
|
||||
.any(|disagreement| disagreement.code == PROVIDER_ANCHOR_TIME_CODE),
|
||||
"{:?}",
|
||||
correlation.disagreements
|
||||
);
|
||||
assert!(!correlation.ready);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn snapshot_with_stored_presence(
|
||||
service: &TestService,
|
||||
stored: AdmittedPresence,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -46,35 +46,24 @@ The first Rust core lives in `crates/odin-core`:
|
|||
- `repository.rs`: `OdinRepository` abstraction, in-memory mock repository, and
|
||||
CultMesh-backed repository.
|
||||
|
||||
The Rust spine owns the future architecture. The CommonJS daemon remains the
|
||||
legacy operational body until each organ crosses this typed boundary.
|
||||
The deployed body is `crates/odin-daemon`, built and run by Idunn from
|
||||
`deployment/idunn/recipe.toml`. `crates/odin-core` holds the typed record spine
|
||||
that the rest of the machine grows into.
|
||||
|
||||
## Runtime Body
|
||||
|
||||
Odin's executable body is split by ownership:
|
||||
|
||||
- `crates/odin-core`: Rust target core. Owns typed Odin documents, ingest
|
||||
ports, normalization, and CultMesh/CultCache repository boundaries. This is
|
||||
the replacement spine; JavaScript remains legacy runtime scaffolding until
|
||||
each organ has crossed the typed boundary.
|
||||
- `src/odin-coordinator.cjs`: process lifecycle, serialized refresh loop,
|
||||
persistence, health, and transport wiring. Refreshes must not overlap because
|
||||
a refresh publishes Odin's daemon health.
|
||||
- `src/odin/config.cjs`: runtime paths, refresh intervals, and CultLib module path setup.
|
||||
- `src/odin/documents.cjs`: CultCache/CultMesh document definitions accepted by Odin.
|
||||
- `src/odin/idunn-rudp.cjs`: daemon-owned Odin provider health publication to
|
||||
Idunn over the canonical CultNet RUDP `schema` channel.
|
||||
- `src/odin/probes.cjs`: local Docker/ADB debug lowerings for Starfire and
|
||||
Android edge visibility. Remote Verse health is not probed here; it comes
|
||||
from provider-owned CultMesh/CultNet records.
|
||||
- `src/odin/interfaces.cjs`: provider advertisements, CultMesh interface
|
||||
bindings, accepted interface projection, and renderer-lowering metadata.
|
||||
Renderer routes are lowerings outside Odin, not Odin-hosted transport.
|
||||
- `src/odin/layout.cjs`: `odin.interface_layout.v1` read/write and merge policy.
|
||||
- `src/odin/marquee.cjs`: canonical marquee tape assembly from Stonks securities and ordered VoidBot poem lines.
|
||||
- `src/odin/surface.cjs`: `gamecult.eve.surface.v1` tree projection.
|
||||
- `src/odin/state.cjs`: one refresh's input records into Odin's provider catalog/proxy state.
|
||||
The entrypoint is not allowed to grow new probe, surface, provider, layout, or renderer policy. If a new owner is needed, name the owner and its invariant before adding code.
|
||||
- `crates/odin-daemon`: the deployed process. Reads Idunn's read-only topology
|
||||
projection, admits provider presences over RUDP for the incarnations Idunn
|
||||
projects, persists them through CultMesh/CultCache, publishes signed
|
||||
runtime-topology correlation records, and answers CultNet/RUDP snapshot and
|
||||
document requests. Deployment, restart, and survival of this process are
|
||||
Idunn's.
|
||||
- `crates/odin-core`: typed Odin documents, ingest ports, normalization, and
|
||||
CultMesh/CultCache repository boundaries.
|
||||
|
||||
If a new owner is needed, name the owner and its invariant before adding code.
|
||||
|
||||
The accepted document registry is also the persistence admission boundary.
|
||||
Unknown schemas may be observed for diagnostics but are not silently persisted
|
||||
|
|
@ -94,19 +83,15 @@ the typed `gjallar.overview` Eve surface. Eve clients own graphical, terminal,
|
|||
and framebuffer lowering. Gjallar must not own the underlying registry, probe,
|
||||
provider truth, client pixels, or translation decisions.
|
||||
|
||||
Idunn is the named keepalive organ for daemon continuity. Its current Rust
|
||||
body lives in `crates/idunn-daemon` and `crates/odin-core/src/idunn.rs`. Idunn may read
|
||||
Odin-owned service records and provider advertisements, then bring daemons up
|
||||
after reboots or crashes, watch health, emit keepalive observations, restart
|
||||
requests, denied-action records, and operator alarms. When human action is
|
||||
needed, Idunn uses CultMesh to request a Bifrost-owned operator
|
||||
notification crossing. VoidBot's `voidbot.operator-dm` command `owner.dm.send`
|
||||
is a demoted compatibility delivery actuator, not the owner. The target command
|
||||
lives in Bifrost's Verse; any still-VoidBot delivery path must be invoked by
|
||||
Bifrost or documented as migration debt. Idunn must not own Verse discovery,
|
||||
schema truth, provider dashboards, identity grants, Discord delivery, owner-DM
|
||||
delivery, or renderer layout. Keepalive loops belong in Idunn, not Odin's
|
||||
coordinator or individual daemons.
|
||||
Idunn (`GameCult/Idunn`) owns deployment and daemon survival, including Odin's.
|
||||
It builds from each target's recipe, activates and restarts the admitted
|
||||
incarnation, and holds the deployment and lifecycle brakes. Odin publishes what
|
||||
it can see and reads Idunn's topology projection; it does not start, restart,
|
||||
deploy, or health-probe any daemon, and no Odin code path keeps a process alive.
|
||||
When human action is needed, Idunn uses CultMesh to request a Bifrost-owned
|
||||
operator notification crossing. Idunn must not own Verse discovery, schema
|
||||
truth, provider dashboards, identity grants, Discord delivery, owner-DM
|
||||
delivery, or renderer layout.
|
||||
|
||||
Muninn is the portable local telemetry Verse assembler. Its Rust body lives in
|
||||
the `GameCult/Muninn` repository and publishes `muninn.telemetry_surface.v1`
|
||||
|
|
@ -213,19 +198,10 @@ This is not a reporting nicety. It is how Odin prevents services from becoming
|
|||
private little islands with separate websites, dashboards, state formats, and
|
||||
separate command languages.
|
||||
|
||||
## Current Service Surface
|
||||
## Service Surface
|
||||
|
||||
Odin currently publishes service squares for:
|
||||
|
||||
- Starfire: Odin, Docker, ADB, Odin CultCache, and running Docker containers.
|
||||
- Nightwing: SSH, Eve broker, Eve browser reference, visible TUI, Docker unit state, and NVIDIA GPU state.
|
||||
- EVE: SSH/native Eve reachability.
|
||||
- Periwinkle: ADB reachability.
|
||||
- Periwinkle: local ADB edge status plus any provider-owned surface or advertisement Odin receives through CultMesh/Odin discovery.
|
||||
- Raven: SSH reachability.
|
||||
- Yggdrasil: compatibility host reachability plus nginx,
|
||||
StreamPixels, Heimdall, Repixelizer, and Bifrost systemd state until those
|
||||
daemon surfaces publish health and command boundaries over CultNet RUDP.
|
||||
Odin's service records come from provider-owned presences and advertisements
|
||||
received through CultMesh/RUDP, not from host probes.
|
||||
|
||||
Gjallar consumes Odin's accepted CultMesh/Eve state and composes every visible
|
||||
provider surface into one typed aggregate on Yggdrasil. EveCanvas, browser, and
|
||||
|
|
@ -250,8 +226,8 @@ interactive language; they are not separate dashboard products. Huginn's `.cc`
|
|||
inspection surface is the current clean example: Huginn inspects CultCache bytes
|
||||
and emits Eve DSL, while Eve or any other runtime owns presentation.
|
||||
|
||||
Provider advertisements are the promotion path out of probing. Odin's CJS
|
||||
document set already accepts `gamecult.eve.provider_advertisement.v1` alongside
|
||||
Provider advertisements are the promotion path out of probing. Odin's document
|
||||
registry already accepts `gamecult.eve.provider_advertisement.v1` alongside
|
||||
`gamecult.eve.interface_binding.v1` and `gamecult.eve.surface_state.v1`.
|
||||
Daemons should publish advertisements that name service id, Verse id, schema
|
||||
catalog, `.cc` witnesses, Eve surface keys, command boundaries, nested Verses,
|
||||
|
|
@ -290,18 +266,6 @@ identity.
|
|||
The canonical contract lives in
|
||||
`E:\Projects\Eve\docs\provider-advertisement-contract.md`.
|
||||
|
||||
Odin persists operator layout intent as `odin.interface_layout.v1` in the Odin CultMesh store under record key `odin.providers`. The ignored `scratch/odin/interface-layout.json` file is migration input only; Odin no longer writes layout truth back to that sidecar. Layout intents name the provider id and request focus, move, resize, or visibility changes; renderers are input devices for those intents, not local layout owners.
|
||||
|
||||
Odin now derives dense top-level layout intent from each provider's retained
|
||||
`surface.root` tree: element count, leaf count, branch count, depth, text-cell
|
||||
pressure, and list-like branches. Provider explicit preferred sizes are capped
|
||||
at the Odin wrapper boundary unless the current intent is fullscreen, so stale
|
||||
layout files cannot keep empty panels huge. The renderer should use
|
||||
`props.tree`, `props.layout.signalWeight`, and `props.packing` to allocate space
|
||||
to nested signal, then recursively lower provider children. Flattening a
|
||||
provider surface into one log/list is a compatibility failure when the retained
|
||||
tree has children.
|
||||
|
||||
## Observation Surface
|
||||
|
||||
Odin no longer tails Mimir JSONL artifacts for observation truth. Device and
|
||||
|
|
|
|||
570
docs/store-recovery-cut.md
Normal file
570
docs/store-recovery-cut.md
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
# Odin store repair and recovery: cut map
|
||||
|
||||
**Operator rulings, 2026-09-30:**
|
||||
- **Q-R1:** "You got it". A foreign file under a running Odin is set aside and Odin continues.
|
||||
- **Q-R2:** "Yep". Keep the previous generation on disk.
|
||||
- **Q-R3:** "Start empty". Nothing decodable at activation means Odin starts empty.
|
||||
- **Q-R4:** "Yep, and Idunn needs better reporting capabilities so it can serve as watchdog. I ought to get a message
|
||||
about this on Discord if it ever happens."
|
||||
- Stop writing after the third set-aside in 10 minutes, and keep serving.
|
||||
- **New requirement:** Idunn watchdog reporting that reaches the operator on Discord. It is mapped as its own
|
||||
campaign and is not an Odin cut.
|
||||
- **Q-R5:** "That's fine, Odin still runs after all, right?" Yes. Odin keeps serving, and the store condition is
|
||||
published as its own typed record, not as a degraded presence.
|
||||
- **Q-R6:** "Approved". Both drills.
|
||||
|
||||
**R1 LANDED: CultLib main `daedfdd6` (2026-09-30).** The merged tree `0e6891b3` was verified on Yggdrasil in every
|
||||
runtime, including the four-runtime store interop test. That test runs close to its 30 s per-command limit under
|
||||
load (`cult-cache.test.ts:1407`), so it is fragile: follow-up. Consumer pins still gate their own bumps: Bifrost only
|
||||
with R3b's TS fsync; Muninn and Idunn with their error-text matches.
|
||||
|
||||
**R1 status (Self, 2026-09-30).** Built on CultLib `hands/cultcache-read-failures` (`c0b56e06`..`b7908668`), then
|
||||
fix batch 2 (`..2d7a3c78`). Soul's verdict on batch 2 was to hold the merge. Batch 3 is in Hands, fixing:
|
||||
- **Blocker, F1 (zero-byte stores on Yggdrasil).**
|
||||
- Two zero-byte stores are live: `/srv/bifrost/persona-feedback/{inbox/observations,outbox/deliveries}.cc`.
|
||||
- They are pre-created by `touch` in gamecult-ops `install-bifrost-persona-feedback-native-yggdrasil.sh:42,46`
|
||||
and `bootstrap-bifrost-persona-feedback-yggdrasil.sh:22,29`.
|
||||
- Bifrost `persona-feedback.mjs serve` and Epiphany `epiphany-swarm` crash-loop on them once their CultLib pins
|
||||
move past R1.
|
||||
- **Neither pin may move until the scripts stop pre-creating stores and Self has moved the live files aside.**
|
||||
- **F4.** Rust errors echo record values through serde's text (`lib.rs:753-758`). The header is echoed without bound.
|
||||
- **F6.** A dangling symlink reads as absent, and the writer then replaces it.
|
||||
- **F7.** The same NotFound-only rule in Python's `JsonLinesBackingStore` and in TS `pullAll`'s repair write.
|
||||
- **M7.** A header-prefix fixture.
|
||||
Recorded for later cuts:
|
||||
- **F3 → R2/R3b.** TS, Python and C# decode the whole buffer before reading the header. A truncated newer-format
|
||||
store (20..n-1 bytes) is "unsupported format" in Rust and a raw decode error elsewhere.
|
||||
- **F5 → R3.** C# `ReadSnapshot` reads ENOTDIR/EISDIR as an empty store (`FileInfo.Exists`, `CultCache.cs:3439`).
|
||||
C# `DirectoryMessagePackBackingStore` `File.Exists` (`:409`, `:497`) is the same. This is a live disagreement between
|
||||
runtimes until R3.
|
||||
- **F8 → R3b.** Python leaves its staging file behind when a write fails (`stores.py:124-128`).
|
||||
- **Tooling.**
|
||||
- `packages/cultcache-rs` has no committed `Cargo.lock`, so the map's `cargo test --locked` cannot run.
|
||||
- The cultcache-ts interop test needs `CARGO_TARGET_DIR` unset under ygg-verify.
|
||||
- **Soul on batch 3 (`2d7a3c78..3c9e195b`, gamecult-ops `bca7978`), 2026-09-30: merge both.** Pre-merge batch 4 is in
|
||||
Hands: fix the README symlink claim (S1), close value echoes in the redb and typed-decode paths (S2) and in the
|
||||
Python/TS non-string key echo (S3), make the unlock policy coherent (S9), classify encode refusals separately from
|
||||
`NotReplaced` (S10), and fix a test timeout (S11). The ops branch deletes the guard test (S6) and the dead compose
|
||||
layout (S5).
|
||||
- **Open for R3:** writes through a symlinked store path replace the link, so a store behind a link to a mounted
|
||||
volume moves into the container layer. R3 decides resolve or refuse.
|
||||
- **Open for R3b:** Python and TS never fsync. After F1 their first create-by-rename is not covered by ext4
|
||||
`auto_da_alloc`, so a crash can leave zero bytes, which every post-R1 reader refuses forever. **Bifrost's CultLib
|
||||
pin moves past R1 only together with R3b's TS fsync.**
|
||||
- **For W0 (Odin):** classify a write failure through `CultCacheStoreWriteFailed.kind` together with its `io::Error`,
|
||||
never retry on `NotReplaced` alone (S10).
|
||||
- Deployed Epiphany is `d2ca6630`, using a vendored cultcache-rs (`ba6a4874`) that reads a missing store as empty.
|
||||
All Epiphany units have been inactive since 2026-08-22.
|
||||
- **Done 2026-09-30 20:48 CEST (Self).** gamecult-ops merged at `3fa22aa`. The inbox is setgid 2750. All three
|
||||
zero-byte stores were renamed in place and moved to `/root/zero-byte-stores-20260930/` on Yggdrasil, with
|
||||
`manifest.txt` recording owner, mode and mtime. None had gained bytes. All three Bifrost units stayed active, and
|
||||
persona-feedback logged nothing and did not restart.
|
||||
- The compose/container lane (the compose file, two deploy scripts, the `.ps1` uploader, two runbooks) is dead:
|
||||
the live Bifrost units run native `dotnet` and `node`. **Deleted 2026-09-30, operator: "delete the Bifrost lane".**
|
||||
gamecult-ops `fdb4356`. Its live trigger, the Idunn legacy actuator route `deploy bifrost-persona-feedback`, was
|
||||
retired in Idunn `7793b5b` and installed on Yggdrasil at 21:50 CEST (sha256 `d95fa08c...`; the previous binary is
|
||||
at `/root/idunn-yggdrasil.pre-bifrost-retire-20260930`). The manifest and `/srv/bifrost/compose` were moved to
|
||||
`/srv/retired/retired-2026-09-30/`. All three Bifrost units stayed active. **Follow-up:** persona-feedback has no
|
||||
Idunn deploy route now. Give it a real Idunn binding.
|
||||
- Live zero-byte stores to move aside after the ops merge, in Soul's order (setgid on inbox, then rename in place,
|
||||
then relocate): `observations.cc`, `deliveries.cc`, and a third, `/var/lib/gamecult/epiphany/idunn-provider-health-admission.cc`.
|
||||
- **Text matches.** gamecult-ops `scripts/idunn/start-idunn-local.ps1:143` matches the same removed text as Muninn
|
||||
(below). It must change with Idunn's next CultLib bump.
|
||||
- **Consumer follow-up that must travel with the next Muninn CultLib bump:**
|
||||
`Muninn scripts/restart-starfire-muninn.ps1:144` matches `*failed to decode MessagePack*` to trigger
|
||||
`Reset-CorruptMuninnStore`. R1 removes that text, so the reset would stop firing silently. The script must match
|
||||
the typed `CultCacheStoreUnreadable` kind, or the new wording, in the same change.
|
||||
- C#'s truncated-store exception (`EndOfStreamException`) is an `IOException`, the same I/O-versus-undecodable
|
||||
overlap. It goes with R3's C# work.
|
||||
|
||||
Status: cut map, Imagination pass 1 (Opus), 2026-09-30. Nothing has landed. This map owns the means for repairing
|
||||
and recovering `/var/lib/gamecult/odin/topology.cc`. It follows `docs/write-pattern-cut.md` (Cuts 1-3) and starts
|
||||
from that campaign's landed head.
|
||||
|
||||
Anchors:
|
||||
- Odin `hands/odin-write` at `cda2087` (Cuts 1-2 plus two test commits). Q3 B (peer puts fsynced before ACK) is
|
||||
being finished on that branch now. **Re-anchor every Odin `file:line` below on the head that lands; the line
|
||||
numbers here are `cda2087`.**
|
||||
- CultLib `6d0cc963`. `packages/cultcache-rs` is byte-identical to Odin's pin `3bf1c0ce`, so the Rust line numbers
|
||||
hold at both.
|
||||
- Idunn `a96ad9d`, Heimdall `e8e2832`, gamecult-ops `runbooks/gamecult-state-backup.md` as of 2026-09-30.
|
||||
|
||||
Operator, 2026-09-30, on the three options Self offered for an undecodable foreign write (degraded but loud, end
|
||||
Odin, unchanged): **"None of these options involve repair and recovery, which is what ultimately needs to happen."**
|
||||
|
||||
Open: Q-R1 to Q-R6 (section 8). D1 (section 6, O1) is a Self default the operator may overrule.
|
||||
|
||||
## Short answer
|
||||
|
||||
Odin already holds the one thing a repair needs: its in-memory working set, which is authoritative for everything
|
||||
it owns while it holds the write lease. Today it never uses it for repair. It ends on a decodable foreign write, and
|
||||
it silently stops persisting on an undecodable one. At activation it has no working set and no second copy, so it
|
||||
crash-loops.
|
||||
|
||||
The repair has one rule: **a store file that is not what Odin last wrote is set aside, never deleted and never
|
||||
merged, and Odin writes its working set over it under the lease.** At activation Odin has no working set, so it
|
||||
falls back to a previous generation kept on disk, and then to an empty store that providers refill. The
|
||||
correlation sequence gets a time floor, so no rollback can recreate the watermark deadlock. Recovery is published as
|
||||
a typed record in Odin's own catalog. The mechanism (typed read failures, a set-aside exchange, a retained previous
|
||||
generation, no staging file left behind by a failed write) belongs to CultLib, because every consumer of the
|
||||
single-file store has the same gap.
|
||||
|
||||
## 1. Body facts (probed)
|
||||
|
||||
### 1.1 Probe: cultcache-rs single-file store under fault
|
||||
|
||||
The probe was a scratch crate at CultLib `3bf1c0ce`, `scratchpad/odin-recovery-probe` (Imagination session), commit
|
||||
`b19eba9`, run through the stopgap on Yggdrasil in slot 2. It ran with `--tmpfs /small:size=64k --tmpfs /ro:ro`,
|
||||
using `trap '' XFSZ; ulimit -f 40` for the file-size case. The log is `scratchpad/odin-recovery-probe.log`.
|
||||
|
||||
| Fault | Result |
|
||||
|---|---|
|
||||
| P1: file truncated to half | `compare_exchange_snapshot` and `pull_all` return `Err`, **not** `Ok(false)`. There is no `io::Error` in the chain; the decode error is flattened to text (`lib.rs:720-725`). The message is wrong: "invalid type: string \"cultcache.store.v1\", expected struct CultCacheEnvelope". `store_header` (`lib.rs:317-341`) deserializes the *whole* array to find the header, so a truncated v1 file has no header and falls through to the legacy decoder. The file is unchanged by the failed exchange |
|
||||
| P2: 12 bytes of garbage appended | `pull_all` returns `Ok`, all 3 records. `compare_exchange_snapshot(expected)` returns **`Ok(true)` and silently drops the tail**. `rmp_serde::from_slice` ignores trailing bytes. C#'s `DeserializeSnapshot` (`CultDocumentMessagePackSerialization.cs:179-215`) never checks for the end either |
|
||||
| P2b: one byte flipped inside a payload | `pull_all` returns `Ok` (payloads are opaque bytes). `compare_exchange_snapshot` returns **`Ok(false)`**. To Odin, bit rot is a *decodable foreign write* |
|
||||
| P3: header `cultcache.store.v4...` | `Err` with a distinct message, "not readable; this runtime reads v1 only". It is distinguishable only by its text |
|
||||
| P4: another handle wrote a valid snapshot | `Ok(false)` |
|
||||
| P5: `hard_link(topology.cc, topology.cc.displaced.<ts>.x)`, then an exchange through the store | The link keeps the old bytes exactly, and the store writes the new file. The staging sweep (`remove_abandoned_staging_files`, `lib.rs:2730-2766`) removes a `<name>.<uuid>.tmp` beside it and leaves the displaced name alone |
|
||||
| read-only filesystem, no store | `pull_all` returns `Ok(0)`. The exchange fails opening the lock: `io::Error` `ReadOnlyFilesystem` (os 30) |
|
||||
| `RLIMIT_FSIZE` 40 KiB, 200 KB write | `Err` `io::Error` `FileTooLarge` (os 27). The old file is intact and still decodes. **A 40,960 B partial staging file is left behind.** The next attempt sweeps it and leaves a new one |
|
||||
| 64 KiB tmpfs, 200 KB write (ENOSPC) | `Err` `StorageFull` (os 28). The old file is intact. **A 61,440 B partial staging file is left behind: all of the filesystem's remaining space.** The same holds on every retry |
|
||||
|
||||
What follows from the probe:
|
||||
- **Decodable is not the same as legitimate.** A flipped payload byte is "decodable and foreign", and appended
|
||||
garbage is "decodable and ours". The decodable/undecodable split cannot be what decides recovery. One rule has to
|
||||
cover both: *not what Odin last wrote*.
|
||||
- **A write that fails for lack of space holds the space it failed on.** `/var/lib/gamecult` is shared by every
|
||||
service on Yggdrasil. Odin retries once per second (and, under Q3 B, on every put), so it re-takes whatever
|
||||
another service frees. C# deletes its temp file in a `finally` (`CultCache.cs:3496-3531`); Rust does not
|
||||
(`write_all_unlocked`, `lib.rs:741-768`). This is a Rust parity defect.
|
||||
- **The atomic write keeps no previous generation.** `write_all_unlocked` stages, fsyncs, renames over the file and
|
||||
fsyncs the directory. After the rename, the old inode is gone. The brief's "last good checkpoint kept by the
|
||||
atomic write" does not exist on disk. The only last-good copy is Odin's in-memory `flushed` snapshot
|
||||
(`lib.rs:506-510`), and it dies with the process.
|
||||
|
||||
### 1.2 Odin today (`cda2087`)
|
||||
|
||||
| Case | Path | Outcome |
|
||||
|---|---|---|
|
||||
| Decodable foreign write while serving | `MemoryOdinTopologyStore::flush` `lib.rs:613-626`: `compare_exchange_snapshot(flushed, current)` returns `false`, so `ForeignStoreWrite`. `survive` `main.rs:879-895` returns it, and Odin ends | Idunn restarts Odin (`control_plane.rs:68-71`: 6 restarts per hour, the wait doubling from 5 s). The new Odin **loads the foreign file as truth**. Odin's own unflushed changes, and every record of Odin's that the foreign file lacks, are lost without a trace. A flipped payload byte (P2b) takes this path |
|
||||
| Undecodable foreign write while serving | the same exchange returns `Err` (decode). `survive` logs it and retries every second, forever. Nothing is persisted again | **Idunn's crossing breaks too.** `receive` (`Idunn drivers.rs:5005-5030`) reads the same file and propagates the error to `admit_latest_topology` (`control_plane.rs:7903-7907`) and `refresh_admitted_topology` (`:6040`). Every Odin-correlated deployment and refresh fails until the file is readable, which is never |
|
||||
| Corrupt file at activation | `try_activate` `main.rs:246-297` calls `MemoryOdinTopologyStore::load` `lib.rs:519-538`, which calls `pull_all()?`. `main.rs:762` `while !try_activate()?` exits the process | Crash loop until Idunn's budget is spent. Then Idunn reports once, to its log only (`control_plane.rs:5709-5723`, `report_once` `:4599-4610` is `eprintln`), and stops. The Verse rendezvous is down |
|
||||
| Disk full or read-only | the exchange returns `Err` (io). `survive` logs and retries every second | Odin serves from memory. Each attempt leaves a partial staging file (1.1). A stop or deploy in this state loses everything unflushed. Nothing typed says so |
|
||||
|
||||
Detection latency for a foreign write: `flush` writes only when dirty (`main.rs:307-314`). Odin's own heartbeat
|
||||
(`HEARTBEAT_INTERVAL` 5 s, `main.rs:69`) dirties the set, so a foreign write is met at the latest one heartbeat plus
|
||||
one flush interval (about 6 s) after it happens. No polling read is needed, and Cut 1's rule (no reads while
|
||||
serving) stands.
|
||||
|
||||
### 1.3 Other sources of truth, and what they can recover
|
||||
|
||||
| Source | Holds | Freshness | Recovers |
|
||||
|---|---|---|---|
|
||||
| Odin's working set (W) | everything, including unflushed changes | live | everything, but only while this process lives and holds the lease |
|
||||
| `flushed` (in memory) | the last snapshot Odin wrote | at most one interval old | nothing more than W; it is the comparison base, not a store |
|
||||
| previous generation on disk (new, O3 and R3) | the snapshot before the last one this store's owner verified and replaced | one write old | everything except the last write's delta, including publish-once documents, presence history and the watermark |
|
||||
| nightly backup (`gamecult-state-backup.md`) | `/var/lib/gamecult/odin` whole, shared-lock copy | up to 24 h old, 3 days on Yggdrasil, 14 days plus 8 weekly on Raven | publish-once documents. **Never automatically**: "It never becomes live authority, and nothing restores it automatically"; "restore a store only after stopping its owner" |
|
||||
| providers republishing | presences (2.7-12 s), Muninn documents (2 s), ads (about 30 s); Heimdall HEAD republishes `heimdall.command_boundary` and its three siblings every 60 s (`Heimdall src/index.ts:19,35-39`, gated on its write lease) | seconds to a minute | every document whose publisher republishes |
|
||||
| Idunn's projection plus presences | correlations | one refresh pass (250 ms) after the presences return | correlations. They are a projection and never need restoring |
|
||||
| Idunn's `odin_publisher_sequence_cursor` (`control_plane.rs:1456,1537,6081`) | the highest correlation sequence Idunn admitted, per target | live | nothing directly (Idunn owns it), but it is why the watermark must never roll back |
|
||||
|
||||
Premise correction: `heimdall.command_boundary` is not publish-once at Heimdall HEAD. A `stored_at` of 09-23
|
||||
proves only that the publisher's own `stored_at` has not changed, because Odin stores the publisher's
|
||||
`stored_at` (`persist_generic_document`, `main.rs:1330-1347`). Whether the deployed Heimdall re-puts is
|
||||
unverified; O3's outstanding-republish list measures it.
|
||||
|
||||
**The watermark is the one record whose loss is not self-healing.** Idunn refuses any correlation sequence at or
|
||||
below its cursor (`control_plane.rs:1763,1785`). An Odin that starts from an empty store, from the previous
|
||||
generation, or from a backup restarts a target's count below that cursor. That is the deadlock documented at
|
||||
`lib.rs:33-49`: Idunn refuses every correlation until the count climbs past the old mark, and the refusal
|
||||
withdraws the correlation before it can climb. Every recovery source except W has this defect. O1 removes it.
|
||||
|
||||
Other facts the design leans on:
|
||||
- **Idunn fences before it grants.** `CultCacheWriteLeaseDriver` (`Idunn drivers.rs:4050-4205`): "revocation
|
||||
follows an exact incumbent stop", and grant is a nonblocking exchange from empty. A second *Odin* cannot hold
|
||||
the lease while the first is still writing, so a foreign writer seen by a lease-holding Odin is not an Odin.
|
||||
- **Idunn has a lifecycle brake** (`idunn-provision lifecycle-brake-engage|release|status`,
|
||||
`provisioning.rs:95-97`; `odin-lifecycle-brake.cc` per `runbooks/odin-yggdrasil.md:29-30`). An engaged brake
|
||||
denies continuity restarts (`control_plane.rs:5771-5774`, `:6219`). This is the operator's hold for a manual
|
||||
restore.
|
||||
- **A non-`active` Odin presence fans out.** `reconcile` sets `ready` only when the presence state is `active`
|
||||
(`lib.rs:946`), and `dependency_evidence` (`lib.rs:1184ff`) makes every managed dependent of Odin not Ready.
|
||||
The presence schema allows `degraded` (`cultnet-rs runtime_authority_contracts.rs:193`), but using it would
|
||||
be a readiness statement about every dependent, not a store report.
|
||||
- **The catalog serves only peer types and presences** (`public_document`, `main.rs:590-640`). No `odin.*` record
|
||||
is served today, and neither Odin nor Idunn publishes an Eve surface. F3 in the write map (the unowned
|
||||
`surface:gamecult.network.status`) is where an Eve lowering belongs.
|
||||
|
||||
## 2. Ends
|
||||
|
||||
1. No single fault, whether a foreign write of any kind, a corrupt file, or a failed write, ends Odin or leaves it
|
||||
silently unpersisted. Only losing the write lease ends it.
|
||||
2. A store file that is not what Odin last wrote is preserved byte for byte beside the store and never deleted,
|
||||
merged or adopted by any automatic path. The owner (Odin under the lease) writes its own state over it.
|
||||
3. At activation a corrupt store is recovered from the best source on the host (previous generation, then empty),
|
||||
and never from the backup automatically.
|
||||
4. No recovery can make a correlation sequence repeat or fall below one Idunn has admitted.
|
||||
5. Every repair, and every stretch in which Odin is serving without persisting, is typed state that can be
|
||||
queried through Odin's catalog while it is happening, including when the disk refuses writes.
|
||||
|
||||
Invariants carried over: one writer (the lease, checked immediately before every write); Idunn's lockless
|
||||
crossing reads a whole `.cc` snapshot at `topology.cc`; the path never becomes absent (Idunn reads an absent file
|
||||
as an empty snapshot, which withdraws every correlation); no reads of the store while serving; one bad record
|
||||
never stops Odin.
|
||||
|
||||
## 3. Identity, lifecycle, authority
|
||||
|
||||
| Persistent kind | Named by | Over time | Decided by |
|
||||
|---|---|---|---|
|
||||
| store file `topology.cc` | the state root plus `TOPOLOGY_SLOT` | replaced whole by the owner's exchange; never absent once created | Odin under the lease |
|
||||
| displaced file (new) | `topology.cc.displaced.<UTC yyyymmddThhmmssZ>.<uuid>`, same directory (it must not end in `.tmp`) | created by a set-aside exchange as a hard link to the replaced inode; never modified; never deleted by any program. Travels to Raven with the nightly backup | created by Odin through CultLib; **disposed of only by the operator** |
|
||||
| previous generation (new, Q-R2) | `topology.cc.previous` | replaced on every *verified* exchange (current == expected) by a hard link to the outgoing inode; **not** rotated by a set-aside exchange, so it always holds a generation that some writer verified before replacing | CultLib mechanism; Odin opts in |
|
||||
| store condition record (new) | type `odin.store_condition`, schema `odin.store_condition.v1`, key `topology` | one record held in W; rewritten when the condition changes; flushed with the store; served by the catalog | Odin |
|
||||
| correlation sequence floor (new rule, not a record) | per target, in the existing watermark | next = max(mark + 1, prior + 1, now in unix ms) | Odin |
|
||||
| backup snapshot | `/var/backups/gamecult-state/<date>/` and Raven | nightly; never live authority | the operator restores it, with the lifecycle brake engaged (runbook) |
|
||||
|
||||
## 4. Authority map (the whole campaign)
|
||||
|
||||
- **Owner:** Odin, while it holds the current process-write lease, decides what `topology.cc` holds, when it is
|
||||
set aside, and what activation recovers from. CultLib owns the mechanism (read classification, set-aside
|
||||
exchange, previous generation, staging cleanup) and no policy. Idunn owns whether Odin runs (continuity budget,
|
||||
lifecycle brake, deploys). The operator owns restores from backup and the disposal of displaced files.
|
||||
- **Inputs:** W; `flushed`; the lease (read immediately before every write); at activation, `topology.cc` and then
|
||||
`topology.cc.previous`; the clock (the sequence floor).
|
||||
- **Outputs:** one atomic `topology.cc`; displaced files; `topology.cc.previous`; the `odin.store_condition` record
|
||||
in the catalog.
|
||||
- **Derived state:** `topology.cc` is a checkpoint and Idunn's crossing, never an input while serving. The
|
||||
displaced files are evidence only: nothing reads them automatically. The previous generation is read only by
|
||||
activation recovery. The condition record is a report and decides nothing.
|
||||
- `ForeignStoreWrite` **is no longer an owner of Odin's life**. A foreign write becomes a set-aside event.
|
||||
- **Forbidden writers and deciders:**
|
||||
- Any path that ends Odin on a store condition. `survive`'s `ForeignStoreWrite` arm (`main.rs:884`) is
|
||||
deleted; `WriteLeaseLost` stays, and so does the temporary `PresenceAuthorityRefused` rule.
|
||||
- Any automatic delete of a displaced or previous file.
|
||||
- Any merge of records from a foreign or displaced file into W.
|
||||
- Any automatic restore from the backup.
|
||||
- Idunn writing or repairing `topology.cc`.
|
||||
- Any second writer of the file inside Odin: activation never writes. It prepares W and `flushed`, and the one
|
||||
`flush` makes the first write through the same set-aside exchange.
|
||||
- **Shared paths:**
|
||||
- The interval flush, the Q3 B per-put flush, the stop flush and the activation-recovery first flush all go
|
||||
through one primitive, `replace_snapshot_setting_aside`.
|
||||
- Every path that reserves a correlation sequence goes through the one floored reservation.
|
||||
- **Deletion line:** `ForeignStoreWrite` (`lib.rs:630-641`) and its `survive` arm are deleted before the set-aside
|
||||
exchange is wired in. The `Err` escape in `load` (`lib.rs:521`, `pull_all()?`) is replaced by the recovery
|
||||
ladder, not wrapped.
|
||||
|
||||
## 5. Recovery by case
|
||||
|
||||
| Case | Detected by | Action | Recovered from | Loss bound | Visible as |
|
||||
|---|---|---|---|---|---|
|
||||
| **A. Decodable foreign write while serving** (a hand edit, a tool, a restore made against the runbook, or bit rot in a payload per P2b) | the next flush: current != `flushed` | the lease is checked; the file is set aside (hard link); W is written; Odin keeps serving | W | none of Odin's own records. The foreign file's content is preserved, not adopted | condition event `displaced`, reason `mismatch`, with path, sha256, bytes and time |
|
||||
| **B. Undecodable foreign write while serving** (truncation, garbage, a foreign format, and after R2 trailing bytes too) | the next flush: current does not decode | the same | W | none. Idunn's crossing is unreadable for at most one heartbeat plus one flush (about 6 s), instead of forever | the same, reason `undecodable` |
|
||||
| **A/B repeated** (a live foreign writer) | a third set-aside inside 10 minutes in one activation | Odin stops writing and keeps serving; puts are not ACKed (Q3 B: ACK means durable); no new displaced files (Q-R4) | W stays in memory | nothing while the process lives | condition `contested`, since when, with the displaced files |
|
||||
| **C1. Undecodable store at activation** | `load` gets `Undecodable` | W is built from `topology.cc.previous` if it decodes; `flushed` is set to "unreadable", so the first flush sets the corrupt file aside and writes W | the previous generation | the last write's delta: one interval of Odin bookkeeping, or one fsynced put. **A put ACKed as durable can be lost here; say so in Q3 B's record** | event `recovered-previous`, plus an outstanding-republish list: peer records in the previous generation not yet re-put by their publisher, cleared as they arrive |
|
||||
| **C2. Nothing decodable at activation** | `.previous` absent or undecodable too | start empty (Q-R3); the first flush sets the corrupt file aside | providers republish; correlations re-derive in one pass; the sequence floor holds | every publish-once document; presence history (F4 makes this harmless) | event `started-empty`; outstanding list is `unknown`. The operator's path is the backup, with the lifecycle brake engaged |
|
||||
| **C3. Unsupported format at activation** (a newer runtime wrote v4, or a downgrade) | `load` gets `UnsupportedFormat` | refuse to activate. The file is untouched and nothing is set aside | the deploy or rollback that caused it; Idunn owns that | none | Idunn's continuity budget and the Odin journal. This is a deployment fault, not a store fault |
|
||||
| **C4. I/O error reading the store at activation** (EIO, EACCES after a restore with the wrong owner, EISDIR) | `load` gets an `io::Error` | refuse to activate. Nothing is set aside: **only bytes that were read are ever judged** | the operator fixes the host or the mode | none | as C3 |
|
||||
| **D. Write fails while serving** (ENOSPC, EDQUOT, EFBIG, EROFS, EIO) | the exchange returns an `io::Error` while staging or renaming | nothing is set aside; the staging file is removed (R1); W is kept; retry at the flush interval; puts are not ACKed; Odin keeps serving | W, when the fault clears | nothing, unless Odin is stopped or redeployed before the fault clears (follow-up I-F1) | condition `unpersisted` since T, with the last error kind (`no-space`, `read-only`, `io`) and the dirty-record count, served from memory |
|
||||
| **D'. Stop while unpersisted** | the stop flush fails | log it and stop, as today (`main.rs:794-799`) | the next activation loads the last good file | the unflushed delta | the final journal line only. I-F1 is the owner fix |
|
||||
|
||||
Rollback safety, for C1, C2 and an operator restore: every source except W carries a watermark below Idunn's
|
||||
cursor. The floor (O1) makes the next sequence exceed every sequence published before, whatever the store holds.
|
||||
|
||||
## 6. Cuts
|
||||
|
||||
Order: the Rust substrate gaps first (R1), then the watermark floor (O1, independent and small), the set-aside
|
||||
primitive (R3), Odin's serving repair (O2), activation recovery (O3), parity hardening (R2, R3b), and finally the
|
||||
runbook and drill (X1). Each cut is its own branch and its own Soul pass. Odin verification:
|
||||
`cargo test --locked -p odin-daemon` from the Odin root. CultLib Rust: `cd packages/cultcache-rs && cargo test
|
||||
--locked`. All of it runs on Yggdrasil through the stopgap. **A permission-based test is unproven there (root).**
|
||||
The injections below avoid permissions: truncation, a directory at the store path (EISDIR), tmpfs size and
|
||||
read-only options, and `RLIMIT_FSIZE`.
|
||||
|
||||
### R1. cultcache-rs: typed read failures, no staging file left behind, a header that survives truncation (CultLib)
|
||||
|
||||
- **Repo/branch:** CultLib `hands/cultcache-rs-store-faults` from `main`. Depends on nothing.
|
||||
- **Deletes first:** the flattening `map_err(|error| anyhow!(...))` in `read_all_unlocked` (`lib.rs:720-725`);
|
||||
`store_header`'s whole-array visitor (`lib.rs:317-341`).
|
||||
- **Adds:**
|
||||
- `pub struct CultCacheStoreUnreadable { path, kind: Undecodable | UnsupportedFormat }`, carried in the error
|
||||
chain of every read of a single-file store whose bytes were read and not accepted, so consumers can
|
||||
`downcast_ref`. An `io::Error` stays an `io::Error`, and the two never overlap.
|
||||
- `store_header` reads only the array header and the first string, reusing `read_array_header` and
|
||||
`read_string` (`lib.rs:2661-2700`). A truncated v1 file then reports as an undecodable v1 file, not as legacy.
|
||||
- `write_all_unlocked` removes its own staging file on every failure after creating it. This is C# parity with
|
||||
`CultCache.cs:3525-3530`.
|
||||
- **Rules that must die under their own mutation:**
|
||||
- a truncated store is `Undecodable`, and a v4 header is `UnsupportedFormat`;
|
||||
- an `io::Error` read failure is never classified `Unreadable`;
|
||||
- after a failed write, the directory holds no `<name>.*.tmp`.
|
||||
- **Verification:**
|
||||
- Tests describe behaviour. The staging-cleanup test forces the failure without permissions. One option is a
|
||||
child process under `RLIMIT_FSIZE` with SIGXFSZ ignored (the probe's method); another is a path whose parent
|
||||
fills a size-capped tmpfs, if the test runner can mount one. Hands picks one and names it.
|
||||
- `cargo mutants --in-diff`.
|
||||
- **Estimate:** about -30 / +70.
|
||||
|
||||
### O1. The correlation sequence has a time floor (Odin) — default D1
|
||||
|
||||
- **Repo/branch:** Odin `hands/odin-sequence-floor` from the landed `hands/odin-write` head. Depends on nothing
|
||||
else.
|
||||
- **Change:** `reserve_publisher_sequence` (`lib.rs:702-723`) returns max(mark + 1, prior + 1, now in unix ms),
|
||||
and records it as the mark. The doc comment at `lib.rs:33-49` gains the rule: "A mark read from any older copy
|
||||
of the store is a floor, never the next value; the clock makes every rollback safe."
|
||||
- **Why a floor and not a read of Idunn's cursor:** it needs no new crossing. It holds for every recovery source,
|
||||
including the backup, and the host clock is already trusted for presence freshness.
|
||||
- **Assumption:** the clock does not step back across a rollback by more than the rollback's age. Soul checks that
|
||||
Idunn accepts a jump: its rule is "strictly above the cursor" (`control_plane.rs:1763,1785`). No test may pin a
|
||||
gap limit.
|
||||
- **Verification:**
|
||||
- Existing tests pinning counts (`withdrawing_a_correlation_does_not_reset_its_publisher_sequence`
|
||||
`lib.rs:1996-2014`) are restated as order properties.
|
||||
- New test: a store rolled back to an earlier snapshot, then one refresh, yields a correlation sequence greater
|
||||
than every sequence published before the rollback.
|
||||
- New test: two reservations inside one millisecond are strictly increasing.
|
||||
- The clock must be injectable at the reservation. At least one mutant must be a function of the time (an offset
|
||||
or a scale), with probe values where it would show.
|
||||
- **Estimate:** about -5 / +25.
|
||||
|
||||
### R3. The set-aside exchange and the previous generation (CultLib, Rust and C#; TS and Python in R3b)
|
||||
|
||||
- **Repo/branch:** CultLib `hands/cultcache-set-aside` from R1. Q-R2 decides the previous-generation half.
|
||||
- **Adds, on `SingleFileMessagePackBackingStore`:**
|
||||
- `replace_snapshot_setting_aside(expected, replacements) -> Result<SnapshotReplacement>`, with
|
||||
`SnapshotReplacement::{Exchanged, SetAside { displaced: PathBuf, sha256, bytes, reason: Mismatch |
|
||||
Undecodable }}`. It makes one exclusive lock hold, in this order:
|
||||
1. stage the replacement (write and fsync);
|
||||
2. read current;
|
||||
3. if current equals expected, rename the staged file over (rotating `.previous` first when enabled);
|
||||
4. if the read was an `io::Error`, remove the staging file, return the error and change nothing;
|
||||
5. otherwise hard-link current to the displaced name, fsync the directory, rename the staged file over, and
|
||||
fsync the directory again.
|
||||
|
||||
Staging comes first, so a full disk sets nothing aside. The path is never absent: a link, never a rename. A
|
||||
set-aside never rotates `.previous`.
|
||||
- Opt-in previous generation: a constructor option (`retaining_previous_generation`, or Hands' spelling of it)
|
||||
plus `pull_previous_generation() -> Result<Vec<CultCacheEnvelope>>`, read under the shared lock and classified
|
||||
like any read. Off by default, because existing consumers must not grow files they did not ask for.
|
||||
- Filesystems without hard links: return an explicit error naming the filesystem. Do not fall back to a
|
||||
copy-then-rename, which would make the path briefly hold new content while the evidence is still being
|
||||
written. Windows NTFS links work; the Windows path is **not yet reached** by the Yggdrasil suite and is
|
||||
recorded as such.
|
||||
- C# reference: the same operation on `SingleFileBackingStore` (`CultCache.cs:3268ff`), with the same names
|
||||
across runtimes.
|
||||
- **Verification (Rust and C#):**
|
||||
- a mismatch preserves the foreign bytes exactly and writes the replacement;
|
||||
- an undecodable current is preserved and replaced;
|
||||
- an I/O read failure (a directory at the path) changes nothing and leaves no staging file;
|
||||
- a staging failure (size cap) changes nothing and sets nothing aside;
|
||||
- the path is readable at every instant: a reader thread loops `pull_all_read_only_snapshot` through 1,000
|
||||
exchanges and never sees an absent file or an empty snapshot;
|
||||
- `.previous` rotates only on a verified exchange;
|
||||
- two set-asides in the same second get distinct names;
|
||||
- mutation tools: `cargo mutants --in-diff`, and Stryker.NET on the C# diff.
|
||||
- **R3b (parity, not blocking Odin):** cultcache-ts `single-file-messagepack-backing-store.ts:34` and cultcache-py
|
||||
`stores.py:83` gain the same operation and option, with a shared fixture test.
|
||||
- **Estimate:** Rust about +140, C# about +120, R3b about +200.
|
||||
|
||||
### O2. A foreign store file while serving is set aside, not fatal (Odin)
|
||||
|
||||
- **Repo/branch:** Odin `hands/odin-store-repair` from O1, with a CultLib pin bump to R3. Needs Q-R1 and Q-R4.
|
||||
- **Deletes first:** `ForeignStoreWrite` (`lib.rs:630-641`) and its arm in `survive` (`main.rs:884`). The test
|
||||
`a_flush_writes_only_changes_and_never_over_a_foreign_file` (`lib.rs:2055-2090`) and the `survive` tests at
|
||||
`main.rs:2842-2844` and `:3110` pin the old rule: restate them, do not delete them.
|
||||
- **Changes:**
|
||||
- `MemoryOdinTopologyStore::flush` (`lib.rs:613-626`) calls `replace_snapshot_setting_aside(flushed, current)`.
|
||||
On `SetAside` it records a `displaced` event and counts it toward the contested bound. On an I/O failure it
|
||||
records `unpersisted` and returns the error, which `survive` logs and retries as today.
|
||||
- `RuntimeState::flush` (`main.rs:307-314`) keeps its lease check immediately before the write.
|
||||
- The Q3 B per-put path uses this same flush. Hands re-anchors on the Q3 B head.
|
||||
- **Adds:**
|
||||
- The `odin.store_condition` record (schema in `odin-core` `documents.rs`), held in W. It carries:
|
||||
- `generation` and `written_by`, updated inside the flush before the snapshot is taken;
|
||||
- `unpersisted_since` and `last_write_error`;
|
||||
- `contested_since`;
|
||||
- a bounded list of the last 16 recovery events: kind, time, displaced path, sha256 and bytes, source, records
|
||||
loaded, and the outstanding list (bounded).
|
||||
- One arm in `public_document` (`main.rs:590-608`) that serves exactly this type. Providers still cannot write
|
||||
it: `is_peer_document_type` is unchanged.
|
||||
- **Rules that must die under their own mutation:**
|
||||
- a flush never leaves the store dirty (the condition record is written inside the flush, not after it);
|
||||
- a set-aside is preceded by a lease check;
|
||||
- the third set-aside inside the window stops writing, and the second does not;
|
||||
- after `contested`, no file is written or created;
|
||||
- on an I/O failure, nothing is set aside;
|
||||
- the condition is served while writes fail.
|
||||
- **Authority map:** as in section 4. The deletion line is the `ForeignStoreWrite` type and arm.
|
||||
- **Verification (fault injection, each at the file layer, not a log line):**
|
||||
- T-A: activate, serve, write a different valid snapshot behind Odin. After the next pass the file decodes to
|
||||
W, the displaced file's bytes equal the foreign bytes, `serving_pass` returned `Ok`, the catalog is unchanged,
|
||||
and the served condition names the displaced file and its sha256.
|
||||
- T-A2 (P2b): flip one payload byte behind Odin; the same outcome, reason `mismatch`.
|
||||
- T-B: truncate the file behind Odin; the same outcome, reason `undecodable`, and
|
||||
`pull_all_read_only_snapshot` (Idunn's crossing) decodes the file after one pass.
|
||||
- T-B-latency: with no puts, the set-aside happens within one heartbeat plus one flush interval.
|
||||
- T-contested: three foreign writes inside the window make exactly two set-asides and a third `contested`
|
||||
report. After that the file and directory are untouched by further passes, and Odin still serves.
|
||||
- T-D: replace the store path with a directory (EISDIR, which works as root). Nothing is set aside, no staging
|
||||
file remains, the condition is `unpersisted` and is served, and a put is not ACKed. Remove the directory, and
|
||||
the next pass writes W, clears `unpersisted` and ACKs.
|
||||
- T-lease: the lease withdrawn before a flush that would set aside means no link, no write, and Odin ends
|
||||
(`WriteLeaseLost`).
|
||||
- Negative: `rg -n "ForeignStoreWrite" crates` matches nothing; `rg -n "remove_file|remove_dir" crates/odin-daemon/src`
|
||||
matches nothing new outside tests.
|
||||
- `cargo mutants --in-diff`.
|
||||
- **Estimate:** about -40 / +160.
|
||||
|
||||
### O3. Activation recovers a corrupt store (Odin)
|
||||
|
||||
- **Repo/branch:** Odin `hands/odin-store-activation` from O2. Needs Q-R2 and Q-R3.
|
||||
- **Change:** `MemoryOdinTopologyStore::load` (`lib.rs:519-538`) becomes the recovery ladder:
|
||||
|
||||
| `load` result | Outcome |
|
||||
|---|---|
|
||||
| `Ok` | as today |
|
||||
| absent | empty, as today |
|
||||
| `Unreadable::Undecodable` | try `pull_previous_generation()`, then empty (Q-R3); `flushed` becomes a sentinel meaning "the file is not a snapshot Odin can compare", so the first flush's exchange sets it aside |
|
||||
| `Unreadable::UnsupportedFormat` | return the error: refuse |
|
||||
| `io::Error` | return the error: refuse |
|
||||
|
||||
Activation itself never writes. The first `serving_pass` flush (`main.rs:833-842`, due at once) writes W. That
|
||||
set-aside is recorded under the activation event and is not counted toward the contested bound.
|
||||
- **Adds:** the outstanding-republish list. It holds the peer-type records present in the recovered previous
|
||||
generation, each cleared when its publisher re-puts it. After 10 minutes the remainder is marked "not
|
||||
republished", which is the live answer to "who is publish-once". For `started-empty` the list is `unknown`.
|
||||
- **Rules that must die under their own mutation:**
|
||||
- Unsupported-format and I/O failures never set aside, never fall back, and never start.
|
||||
- A recovered activation writes before it serves its first catalog snapshot, or it serves the recovered set and
|
||||
marks the condition before the first write. Hands picks one and states it.
|
||||
- **Verification:**
|
||||
- T-C1: a truncated `topology.cc` and a valid `.previous` activate with the previous content. The first pass
|
||||
writes it and sets the corrupt file aside byte-exact, and the condition is `recovered-previous` with its
|
||||
outstanding list. Re-putting one listed document clears it.
|
||||
- T-C2: both undecodable (Q-R3 A) start empty. Both files are preserved, the condition is `started-empty`, and
|
||||
the next correlation sequence is above the floor.
|
||||
- T-C3: a v4 header refuses to activate. The file is byte-identical afterwards, and there is no displaced file
|
||||
and no `.previous` change.
|
||||
- T-C4: a directory at the store path refuses, as C3.
|
||||
- T-restart: a recovered Odin stopped and restarted loads its own first write normally, with no second
|
||||
set-aside.
|
||||
- `cargo mutants --in-diff`.
|
||||
- **Estimate:** about -10 / +110.
|
||||
|
||||
### R2. Single-file decoders reject trailing bytes, in every runtime (CultLib)
|
||||
|
||||
- **Repo/branch:** CultLib `hands/cultcache-trailing-bytes`. Independent of Odin; land it before O3 ships so that
|
||||
an appended file is caught as undecodable, not overwritten silently (P2).
|
||||
- **Change:** C# `DeserializeSnapshot` (`CultDocumentMessagePackSerialization.cs:179-215`) checks `reader.End`.
|
||||
Rust `decode_store_snapshot` (`lib.rs:2583ff`) and the legacy path check that the slice is consumed. TS and
|
||||
Python do the same. **One release**: a runtime that accepts what another rejects is a parity split.
|
||||
- **Verification:** one shared fixture (a valid store plus one byte) is rejected as undecodable by all four
|
||||
runtimes, and the valid store is accepted by all four.
|
||||
- **Estimate:** about +40 plus tests across four runtimes.
|
||||
|
||||
### X1. Runbook and recovery drill (gamecult-ops, then live)
|
||||
|
||||
- **Repo:** gamecult-ops `runbooks/odin-yggdrasil.md` gains a "Store recovery" section:
|
||||
- how to read `odin.store_condition` from the catalog;
|
||||
- what displaced and previous files mean, and that disposing of them is the operator's decision;
|
||||
- the manual restore: engage Odin's lifecycle brake, stop the `idunn-odin-*` unit, set the live file aside with
|
||||
`ln` (never `rm`), extract the snapshot's `topology.cc` with its owner, mode and `.lock`, release the brake,
|
||||
and read the condition. The sequence floor makes an old watermark safe.
|
||||
- **Drill D-1 (off-live, after O3; needs the Q2-style approval, Q-R6):** extract the newest Yggdrasil snapshot's
|
||||
`topology.cc` into a container on Yggdrasil. Activate a test-harness Odin on it through the existing fixture
|
||||
authority. Then truncate the copy and activate again with the extracted file as `.previous`. Report the records
|
||||
per type, the outstanding list and the sequence floor. Delete the copy afterwards.
|
||||
- **Drill D-2 (live, operator-scheduled, Q-R6), after O3 deploys:**
|
||||
1. Record the baseline: catalog types, Idunn correlated targets, route timeouts.
|
||||
2. Engage the lifecycle brake and stop Odin.
|
||||
3. `ln topology.cc /var/tmp/odin-drill-keep.cc` (the evidence of the evidence), then truncate `topology.cc` in
|
||||
place to half.
|
||||
4. Release the brake and let Idunn restart Odin.
|
||||
5. Pass when all of these hold:
|
||||
- Odin is serving within one restart;
|
||||
- the condition is `recovered-previous`, with a displaced file whose sha256 equals the truncated file's;
|
||||
- every Odin-correlated target is Ready again within 60 s;
|
||||
- there are 0 route-challenge timeouts in the next hour;
|
||||
- the catalog's type set equals the baseline after 10 minutes, or the outstanding list names every
|
||||
difference.
|
||||
6. Then run D-2b, a foreign write while serving: `ln` the live file aside, then truncate it. It passes when the
|
||||
condition shows `displaced` within about 6 s and Idunn's correlation reads never fail for longer than that
|
||||
(the journal).
|
||||
|
||||
## 7. Subtraction ledger (estimate)
|
||||
|
||||
| Cut | Removed | Added | Formats and targets |
|
||||
|---|---|---|---|
|
||||
| R1 | about 30 | about 70 | none |
|
||||
| O1 | about 5 | about 25 | none |
|
||||
| R3 | 0 | about 260 (Rust 140, C# 120); R3b about 200 | two sibling-file conventions (`.displaced.*`, `.previous`); no format change |
|
||||
| O2 | about 40 | about 160 | one Odin schema, `odin.store_condition.v1` |
|
||||
| O3 | about 10 | about 110 | none |
|
||||
| R2 | 0 | about 40 plus tests | none (it tightens the decoders) |
|
||||
|
||||
The campaign is net additive, at about +570 without R3b. It buys ends 1-5, which the operator asked for, and
|
||||
removes one fatal path and one silent path. Every addition in CultLib is a capability a general consumer of the
|
||||
single-file store would expect, and two of them (staging cleanup, and the header that survives truncation) are
|
||||
defects.
|
||||
|
||||
## 8. Operator questions
|
||||
|
||||
- **Q-R1. A store file changed underneath a running Odin (decodable or not).**
|
||||
- Context: Idunn fences the old Odin before granting the lease, so the other writer is never an Odin. It is a
|
||||
tool, a hand edit, a restore made against the runbook, or disk corruption; P2b shows a flipped byte looks
|
||||
exactly like a foreign write. Today a decodable change ends Odin, and the restart adopts the foreign file
|
||||
silently. An undecodable change stops persistence forever and breaks Idunn's crossing.
|
||||
- A: set the file aside (kept byte for byte, never deleted), write Odin's working set over it, keep serving,
|
||||
and report it as typed state.
|
||||
- B: set it aside, write the working set, then end Odin so a fresh process starts from the rewritten file.
|
||||
- **Recommended: A.** B's restart protects nothing that the lease check before the write does not already
|
||||
protect, and costs a route outage.
|
||||
- **Q-R2. Keep one previous generation on disk (`topology.cc.previous`)?**
|
||||
- Context: without it, a store that is corrupt at activation has no on-host source except "empty", and the
|
||||
backup is up to 24 h old and needs the operator. It costs one hard link per write (a directory update, no data
|
||||
written) and one more file of the store's size. It is a CultLib option, off by default for other consumers.
|
||||
- A: yes. B: no; activation recovery goes straight to empty.
|
||||
- **Recommended: A.** It is the only source that recovers publish-once documents, presence history and
|
||||
correlations without the operator. Its loss bound is one write.
|
||||
- **Q-R3. Activation finds nothing decodable: no usable `topology.cc`, no usable previous generation.**
|
||||
- Context: presences and most catalog documents return within seconds to a minute, and correlations re-derive
|
||||
in one refresh pass. Publish-once documents (if any exist, see 1.3) stay missing until their publisher
|
||||
re-puts them, or until the operator restores from the backup.
|
||||
- A: start empty automatically, set the corrupt file aside, and report `started-empty`.
|
||||
- B: refuse to start, so Odin crash-loops until the operator restores. Every service's rendezvous is down
|
||||
meanwhile.
|
||||
- **Recommended: A.** A down Odin loses everything the Verse needs; an empty one loses only what nobody
|
||||
republishes, and the restore path stays open.
|
||||
- **Q-R4. A foreign writer keeps writing (a third set-aside within 10 minutes).**
|
||||
- Context: repeated repair against a live writer makes a displaced file every second.
|
||||
- A: stop writing, keep serving from memory, withhold put ACKs, and report `contested` until the operator
|
||||
finds the writer. The working set survives.
|
||||
- B: end Odin. Idunn restarts it, and the restart loads whatever the foreign writer last wrote.
|
||||
- **Recommended: A.** B hands the store to the unknown writer. A is loud and loses nothing that is in memory.
|
||||
- **Q-R5. Should a store condition change Odin's presence to `degraded`?**
|
||||
- Context: a non-`active` Odin presence makes every managed dependent of Odin not Ready (`lib.rs:946`,
|
||||
`dependency_evidence`). The store condition does not affect serving.
|
||||
- A: no. The presence stays `active`, and the condition record is the signal.
|
||||
- B: `degraded` while `unpersisted` or `contested`.
|
||||
- **Recommended: A.** Otherwise a full disk becomes an outage of every dependent, and a restart that Idunn might
|
||||
take in response would destroy the only complete copy.
|
||||
- **Q-R6. The drills.**
|
||||
- D-1: decode a copy of last night's backup `topology.cc` in a container on Yggdrasil, the same terms as Q2 for
|
||||
the write map, with the copy deleted afterwards.
|
||||
- D-2: after O3 deploys, a scheduled live drill that truncates the real `topology.cc` behind the lifecycle brake,
|
||||
keeping a hard link of the original.
|
||||
- **Recommended: yes to both**, D-1 before O3 ships and D-2 in a window the operator names.
|
||||
|
||||
## 9. Follow-ups outside this map
|
||||
|
||||
- **I-F1 (Idunn):** a deploy or a stop of Odin while `odin.store_condition` says `unpersisted` or `contested`
|
||||
destroys the only complete copy. Idunn could read the condition from the catalog and hold the deployment
|
||||
(deployment brake, never continuity). This is Idunn's decision, raised with the Idunn map.
|
||||
- **I-F2 (Idunn):** "Idunn stopped restarting admitted odin" is a log line (`report_once`, `control_plane.rs:4599`),
|
||||
not typed state. It belongs to Idunn's own operator surface.
|
||||
- **F3 (from the write map):** the Eve lowering of `odin.store_condition` belongs to Odin's operator surface.
|
||||
This map publishes the typed record only.
|
||||
- **H-F1 (Heimdall):** confirm whether the deployed Heimdall re-puts its Odin documents every 60 s. O3's
|
||||
outstanding list measures it after any recovery.
|
||||
- **Q3 B's record in the write map:** "an ACK means durable" holds against crashes, not against a corruption at
|
||||
activation, which loses at most the last write (C1).
|
||||
1008
docs/write-pattern-cut.md
Normal file
1008
docs/write-pattern-cut.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -7,10 +7,7 @@
|
|||
"scripts": {
|
||||
"gjallar:persona": "node scripts/write-gjallar-persona-cc.mjs inspect",
|
||||
"gjallar:persona:write": "node scripts/write-gjallar-persona-cc.mjs write",
|
||||
"rust:test": "cargo test --workspace",
|
||||
"check": "node scripts/check-syntax.cjs",
|
||||
"test": "node --test test/*.test.cjs",
|
||||
"start": "node src/odin-coordinator.cjs"
|
||||
"rust:test": "cargo test --workspace"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const files = [];
|
||||
|
||||
collect(path.join(repoRoot, "src"));
|
||||
|
||||
for (const file of files) {
|
||||
const result = spawnSync(process.execPath, ["--check", file], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
if (result.status !== 0) process.exit(result.status);
|
||||
}
|
||||
|
||||
console.log(`Checked ${files.length} CommonJS files.`);
|
||||
|
||||
function collect(directory) {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collect(fullPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".cjs")) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
@echo off
|
||||
if not "%IDUNN_ACTUATOR%"=="1" (
|
||||
echo This deployment wrapper is an Idunn actuator. Configure Idunn; do not deploy manually.
|
||||
exit /b 1
|
||||
)
|
||||
if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" (
|
||||
echo This deployment wrapper is an Idunn actuator. Configure Idunn; do not deploy manually.
|
||||
exit /b 1
|
||||
)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0deploy-yggdrasil-heimdall.ps1
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
param(
|
||||
[string] $SshTarget = "yggdrasil",
|
||||
[string] $RepoRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "Heimdall"),
|
||||
[string] $CultLibRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "CultLib"),
|
||||
[string] $AppUser = "heimdall",
|
||||
[string] $RemoteAppHome = "/srv/heimdall",
|
||||
[string] $RemoteTarballName = "heimdall-source.tar",
|
||||
[string] $RemoteCultLibTarballName = "heimdall-cultlib.tar",
|
||||
[string] $DeployScript = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "gamecult-ops\scripts\deploy-heimdall.sh"),
|
||||
[string] $CheckScript = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "gamecult-ops\scripts\check-heimdall.sh"),
|
||||
[string] $UpstreamRemote = "origin",
|
||||
[string] $UpstreamBranch = "main",
|
||||
[string] $CultLibUpstreamRemote = "origin",
|
||||
[string] $CultLibUpstreamBranch = "main"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") {
|
||||
throw "This deployment script is an Idunn actuator. Agents must configure Idunn release targets and let Idunn run deployment; do not invoke deploy scripts manually."
|
||||
}
|
||||
|
||||
foreach ($path in @($RepoRoot, $CultLibRoot, $DeployScript, $CheckScript)) {
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
throw "Required path not found: $path"
|
||||
}
|
||||
}
|
||||
|
||||
$sourceRef = "$UpstreamRemote/$UpstreamBranch"
|
||||
$cultLibSourceRef = "$CultLibUpstreamRemote/$CultLibUpstreamBranch"
|
||||
git -C $RepoRoot fetch --prune $UpstreamRemote $UpstreamBranch
|
||||
if ($LASTEXITCODE -ne 0) { throw "git fetch failed for $RepoRoot $sourceRef" }
|
||||
git -C $CultLibRoot fetch --prune $CultLibUpstreamRemote $CultLibUpstreamBranch
|
||||
if ($LASTEXITCODE -ne 0) { throw "git fetch failed for $CultLibRoot $cultLibSourceRef" }
|
||||
|
||||
$heimdallCommit = (git -C $RepoRoot rev-parse $sourceRef).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($heimdallCommit)) {
|
||||
throw "Could not determine Heimdall git revision."
|
||||
}
|
||||
$cultLibCommit = (git -C $CultLibRoot rev-parse $cultLibSourceRef).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($cultLibCommit)) {
|
||||
throw "Could not determine CultLib git revision."
|
||||
}
|
||||
|
||||
$scratch = Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\idunn-deploy") "yggdrasil-heimdall"
|
||||
$sourceTarPath = Join-Path $scratch $RemoteTarballName
|
||||
$cultLibTarPath = Join-Path $scratch $RemoteCultLibTarballName
|
||||
$manifestPath = Join-Path $scratch "deployment-manifest.txt"
|
||||
New-Item -ItemType Directory -Force -Path $scratch | Out-Null
|
||||
|
||||
git -C $RepoRoot archive --format=tar --output=$sourceTarPath $sourceRef
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git archive failed for $RepoRoot $sourceRef"
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $cultLibTarPath) {
|
||||
Remove-Item -LiteralPath $cultLibTarPath -Force
|
||||
}
|
||||
git -C $CultLibRoot archive --format=tar --output=$cultLibTarPath $cultLibSourceRef packages/cultnet-ts packages/cultcache-ts
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "CultLib tar build failed for $CultLibRoot $cultLibSourceRef"
|
||||
}
|
||||
|
||||
$sourceHash = (Get-FileHash $sourceTarPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$cultLibHash = (Get-FileHash $cultLibTarPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$deployedAt = [DateTimeOffset]::UtcNow.ToString("O")
|
||||
@(
|
||||
"schema=gamecult.idunn.deployment_manifest.v1"
|
||||
"appId=yggdrasil-heimdall"
|
||||
"repoRoot=$RepoRoot"
|
||||
"upstreamRemote=$UpstreamRemote"
|
||||
"upstreamBranch=$UpstreamBranch"
|
||||
"sourceRef=$sourceRef"
|
||||
"gitCommit=$heimdallCommit"
|
||||
"artifact=$RemoteTarballName"
|
||||
"sha256=$sourceHash"
|
||||
"cultLibRepoRoot=$CultLibRoot"
|
||||
"cultLibUpstreamRemote=$CultLibUpstreamRemote"
|
||||
"cultLibUpstreamBranch=$CultLibUpstreamBranch"
|
||||
"cultLibSourceRef=$cultLibSourceRef"
|
||||
"cultLibGitCommit=$cultLibCommit"
|
||||
"cultLibArtifact=$RemoteCultLibTarballName"
|
||||
"cultLibSha256=$cultLibHash"
|
||||
"deployedAtUtc=$deployedAt"
|
||||
) | Set-Content -Encoding ASCII -LiteralPath $manifestPath
|
||||
|
||||
$remoteDeploy = "/home/gamecultadmin/$([IO.Path]::GetFileName($DeployScript))"
|
||||
$remoteCheck = "/home/gamecultadmin/$([IO.Path]::GetFileName($CheckScript))"
|
||||
$remoteSourceTarball = "/home/gamecultadmin/$RemoteTarballName"
|
||||
$remoteCultLibTarball = "/home/gamecultadmin/$RemoteCultLibTarballName"
|
||||
$remoteManifest = "/home/gamecultadmin/yggdrasil-heimdall-deployment-manifest.txt"
|
||||
$remoteRunner = "/home/gamecultadmin/run-yggdrasil-heimdall-deploy.sh"
|
||||
$targetSourceTarball = "$RemoteAppHome/$RemoteTarballName"
|
||||
$targetCultLibTarball = "$RemoteAppHome/$RemoteCultLibTarballName"
|
||||
$targetManifest = "$RemoteAppHome/deployment-manifest.txt"
|
||||
|
||||
$sftpBatchPath = Join-Path $scratch "yggdrasil-heimdall-upload.sftp"
|
||||
$runnerPath = Join-Path $scratch "run-yggdrasil-heimdall-deploy.sh"
|
||||
$runnerScript = @"
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
sudo -n install -d -o '$AppUser' -g '$AppUser' '$RemoteAppHome'
|
||||
sudo -n install -o '$AppUser' -g '$AppUser' -m 600 '$remoteSourceTarball' '$targetSourceTarball'
|
||||
sudo -n install -o '$AppUser' -g '$AppUser' -m 600 '$remoteCultLibTarball' '$targetCultLibTarball'
|
||||
sudo -n install -m 644 '$remoteManifest' '$targetManifest'
|
||||
chmod +x '$remoteDeploy' '$remoteCheck'
|
||||
sudo -n env GAMECULT_IDUNN_REMOTE_ACTUATOR=1 IDUNN_COMMAND_AUTHORITY=idunn-daemon bash '$remoteDeploy'
|
||||
|
||||
check_ok=0
|
||||
for i in `$(seq 1 30); do
|
||||
if sudo -n bash '$remoteCheck'; then
|
||||
check_ok=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "`$check_ok" -ne 1 ]; then
|
||||
sudo -n bash '$remoteCheck'
|
||||
exit 1
|
||||
fi
|
||||
"@.Replace("`r`n", "`n")
|
||||
[System.IO.File]::WriteAllText($runnerPath, $runnerScript, [System.Text.Encoding]::ASCII)
|
||||
@(
|
||||
"put ""$sourceTarPath"" ""$remoteSourceTarball"""
|
||||
"put ""$cultLibTarPath"" ""$remoteCultLibTarball"""
|
||||
"put ""$DeployScript"" ""$remoteDeploy"""
|
||||
"put ""$CheckScript"" ""$remoteCheck"""
|
||||
"put ""$manifestPath"" ""$remoteManifest"""
|
||||
"put ""$runnerPath"" ""$remoteRunner"""
|
||||
) | Set-Content -Encoding ASCII -LiteralPath $sftpBatchPath
|
||||
|
||||
try {
|
||||
& sftp.exe -b $sftpBatchPath $SshTarget
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "sftp upload failed for yggdrasil-heimdall"
|
||||
}
|
||||
|
||||
& ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $SshTarget "chmod +x '$remoteRunner' && bash '$remoteRunner'"
|
||||
$remoteExit = $LASTEXITCODE
|
||||
& ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $SshTarget "rm -f '$remoteRunner'"
|
||||
if ($remoteExit -ne 0) {
|
||||
throw "remote deploy/check failed for yggdrasil-heimdall"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $runnerPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $sftpBatchPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "yggdrasil-heimdall deployed with CultLib snapshot."
|
||||
Write-Host " heimdallCommit=$heimdallCommit"
|
||||
Write-Host " cultLibCommit=$cultLibCommit"
|
||||
Write-Host " artifactSha256=$sourceHash"
|
||||
Write-Host " cultLibSha256=$cultLibHash"
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
@echo off
|
||||
if not "%IDUNN_ACTUATOR%"=="1" (
|
||||
echo This deployment wrapper is an Idunn actuator. Configure Idunn; do not deploy manually.
|
||||
exit /b 1
|
||||
)
|
||||
if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" (
|
||||
echo This deployment wrapper is an Idunn actuator. Configure Idunn; do not deploy manually.
|
||||
exit /b 1
|
||||
)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0deploy-yggdrasil-repixelizer.ps1
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
param(
|
||||
[string] $SshTarget = "yggdrasil",
|
||||
[string] $RepoRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "repixelizer"),
|
||||
[string] $CultLibRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "CultLib"),
|
||||
[string] $AppUser = "repixelizer",
|
||||
[string] $RemoteAppHome = "/srv/repixelizer",
|
||||
[string] $RemoteTarballName = "repixelizer-source.tar",
|
||||
[string] $RemoteCultLibTarballName = "repixelizer-cultlib.tar",
|
||||
[string] $DeployScript = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "gamecult-ops\scripts\deploy-repixelizer-gui.sh"),
|
||||
[string] $CheckScript = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "gamecult-ops\scripts\check-repixelizer-gui.sh"),
|
||||
[string] $UpstreamRemote = "origin",
|
||||
[string] $UpstreamBranch = "main",
|
||||
[string] $CultLibUpstreamRemote = "origin",
|
||||
[string] $CultLibUpstreamBranch = "main"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") {
|
||||
throw "This deployment script is an Idunn actuator. Agents must configure Idunn release targets and let Idunn run deployment; do not invoke deploy scripts manually."
|
||||
}
|
||||
|
||||
foreach ($path in @($RepoRoot, $CultLibRoot, $DeployScript, $CheckScript)) {
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
throw "Required path not found: $path"
|
||||
}
|
||||
}
|
||||
|
||||
$sourceRef = "$UpstreamRemote/$UpstreamBranch"
|
||||
$cultLibSourceRef = "$CultLibUpstreamRemote/$CultLibUpstreamBranch"
|
||||
git -C $RepoRoot fetch --prune $UpstreamRemote $UpstreamBranch
|
||||
if ($LASTEXITCODE -ne 0) { throw "git fetch failed for $RepoRoot $sourceRef" }
|
||||
git -C $CultLibRoot fetch --prune $CultLibUpstreamRemote $CultLibUpstreamBranch
|
||||
if ($LASTEXITCODE -ne 0) { throw "git fetch failed for $CultLibRoot $cultLibSourceRef" }
|
||||
|
||||
$repixelizerCommit = (git -C $RepoRoot rev-parse $sourceRef).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($repixelizerCommit)) {
|
||||
throw "Could not determine Repixelizer git revision."
|
||||
}
|
||||
|
||||
$cultLibCommit = (git -C $CultLibRoot rev-parse $cultLibSourceRef).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($cultLibCommit)) {
|
||||
throw "Could not determine CultLib git revision."
|
||||
}
|
||||
|
||||
$scratch = Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\idunn-deploy") "yggdrasil-repixelizer"
|
||||
$sourceTarPath = Join-Path $scratch $RemoteTarballName
|
||||
$cultLibTarPath = Join-Path $scratch $RemoteCultLibTarballName
|
||||
$manifestPath = Join-Path $scratch "deployment-manifest.txt"
|
||||
New-Item -ItemType Directory -Force -Path $scratch | Out-Null
|
||||
|
||||
git -C $RepoRoot archive --format=tar --output=$sourceTarPath $sourceRef
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git archive failed for $RepoRoot $sourceRef"
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $cultLibTarPath) {
|
||||
Remove-Item -LiteralPath $cultLibTarPath -Force
|
||||
}
|
||||
# All three Python packages. CultLib split packages/cultcache-py into
|
||||
# cultcache-py / cultnet-py / cultmesh-py; module names did not change, so
|
||||
# archiving only the first still succeeds and ships an incomplete library
|
||||
# that fails later as an ImportError, far from this line.
|
||||
git -C $CultLibRoot archive --format=tar --output=$cultLibTarPath $cultLibSourceRef packages/cultcache-py packages/cultnet-py packages/cultmesh-py
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "CultLib tar build failed for $CultLibRoot $cultLibSourceRef"
|
||||
}
|
||||
|
||||
$sourceHash = (Get-FileHash $sourceTarPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$cultLibHash = (Get-FileHash $cultLibTarPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$deployedAt = [DateTimeOffset]::UtcNow.ToString("O")
|
||||
@(
|
||||
"schema=gamecult.idunn.deployment_manifest.v1"
|
||||
"appId=yggdrasil-repixelizer"
|
||||
"repoRoot=$RepoRoot"
|
||||
"upstreamRemote=$UpstreamRemote"
|
||||
"upstreamBranch=$UpstreamBranch"
|
||||
"sourceRef=$sourceRef"
|
||||
"gitCommit=$repixelizerCommit"
|
||||
"artifact=$RemoteTarballName"
|
||||
"sha256=$sourceHash"
|
||||
"cultLibRepoRoot=$CultLibRoot"
|
||||
"cultLibUpstreamRemote=$CultLibUpstreamRemote"
|
||||
"cultLibUpstreamBranch=$CultLibUpstreamBranch"
|
||||
"cultLibSourceRef=$cultLibSourceRef"
|
||||
"cultLibGitCommit=$cultLibCommit"
|
||||
"cultLibArtifact=$RemoteCultLibTarballName"
|
||||
"cultLibSha256=$cultLibHash"
|
||||
"deployedAtUtc=$deployedAt"
|
||||
) | Set-Content -Encoding ASCII -LiteralPath $manifestPath
|
||||
|
||||
$remoteDeploy = "/home/gamecultadmin/$([IO.Path]::GetFileName($DeployScript))"
|
||||
$remoteCheck = "/home/gamecultadmin/$([IO.Path]::GetFileName($CheckScript))"
|
||||
$remoteSourceTarball = "/home/gamecultadmin/$RemoteTarballName"
|
||||
$remoteCultLibTarball = "/home/gamecultadmin/$RemoteCultLibTarballName"
|
||||
$remoteManifest = "/home/gamecultadmin/yggdrasil-repixelizer-deployment-manifest.txt"
|
||||
$remoteRunner = "/home/gamecultadmin/run-yggdrasil-repixelizer-deploy.sh"
|
||||
$targetSourceTarball = "$RemoteAppHome/$RemoteTarballName"
|
||||
$targetCultLibTarball = "$RemoteAppHome/$RemoteCultLibTarballName"
|
||||
$targetManifest = "$RemoteAppHome/deployment-manifest.txt"
|
||||
|
||||
$sftpBatchPath = Join-Path $scratch "yggdrasil-repixelizer-upload.sftp"
|
||||
$runnerPath = Join-Path $scratch "run-yggdrasil-repixelizer-deploy.sh"
|
||||
$runnerScript = @"
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
sudo -n install -d -o '$AppUser' -g '$AppUser' '$RemoteAppHome'
|
||||
sudo -n install -o '$AppUser' -g '$AppUser' -m 600 '$remoteSourceTarball' '$targetSourceTarball'
|
||||
sudo -n install -o '$AppUser' -g '$AppUser' -m 600 '$remoteCultLibTarball' '$targetCultLibTarball'
|
||||
sudo -n install -m 644 '$remoteManifest' '$targetManifest'
|
||||
chmod +x '$remoteDeploy' '$remoteCheck'
|
||||
sudo -n env GAMECULT_IDUNN_REMOTE_ACTUATOR=1 IDUNN_COMMAND_AUTHORITY=idunn-daemon bash '$remoteDeploy'
|
||||
|
||||
check_ok=0
|
||||
for i in `$(seq 1 30); do
|
||||
if sudo -n bash '$remoteCheck'; then
|
||||
check_ok=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "`$check_ok" -ne 1 ]; then
|
||||
sudo -n bash '$remoteCheck'
|
||||
exit 1
|
||||
fi
|
||||
"@.Replace("`r`n", "`n")
|
||||
[System.IO.File]::WriteAllText($runnerPath, $runnerScript, [System.Text.Encoding]::ASCII)
|
||||
@(
|
||||
"put ""$sourceTarPath"" ""$remoteSourceTarball"""
|
||||
"put ""$cultLibTarPath"" ""$remoteCultLibTarball"""
|
||||
"put ""$DeployScript"" ""$remoteDeploy"""
|
||||
"put ""$CheckScript"" ""$remoteCheck"""
|
||||
"put ""$manifestPath"" ""$remoteManifest"""
|
||||
"put ""$runnerPath"" ""$remoteRunner"""
|
||||
) | Set-Content -Encoding ASCII -LiteralPath $sftpBatchPath
|
||||
|
||||
try {
|
||||
& sftp.exe -b $sftpBatchPath $SshTarget
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "sftp upload failed for yggdrasil-repixelizer"
|
||||
}
|
||||
|
||||
& ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $SshTarget "chmod +x '$remoteRunner' && bash '$remoteRunner'"
|
||||
$remoteExit = $LASTEXITCODE
|
||||
& ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $SshTarget "rm -f '$remoteRunner'"
|
||||
if ($remoteExit -ne 0) {
|
||||
throw "remote deploy/check failed for yggdrasil-repixelizer"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $runnerPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $sftpBatchPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "yggdrasil-repixelizer deployed with CultLib snapshot."
|
||||
Write-Host " repixelizerCommit=$repixelizerCommit"
|
||||
Write-Host " cultLibCommit=$cultLibCommit"
|
||||
Write-Host " artifactSha256=$sourceHash"
|
||||
Write-Host " cultLibSha256=$cultLibHash"
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
param(
|
||||
[Parameter(Mandatory = $true)] [string] $AppId,
|
||||
[Parameter(Mandatory = $true)] [string] $RepoRoot,
|
||||
[Parameter(Mandatory = $true)] [string] $AppUser,
|
||||
[Parameter(Mandatory = $true)] [string] $RemoteAppHome,
|
||||
[Parameter(Mandatory = $true)] [string] $RemoteTarballName,
|
||||
[Parameter(Mandatory = $true)] [string] $DeployScript,
|
||||
[Parameter(Mandatory = $true)] [string] $CheckScript,
|
||||
[string] $SshTarget = "yggdrasil",
|
||||
[string] $UpstreamRemote = "origin",
|
||||
[string] $UpstreamBranch = "main",
|
||||
[string] $StateMigrationScript = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ($env:IDUNN_ACTUATOR -ne "1") {
|
||||
throw "This deployment script is an Idunn actuator. Agents must configure Idunn release targets and let Idunn run deployment; do not invoke deploy scripts manually."
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $RepoRoot)) {
|
||||
throw "Repo root not found: $RepoRoot"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $DeployScript)) {
|
||||
throw "Deploy script not found: $DeployScript"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $CheckScript)) {
|
||||
throw "Check script not found: $CheckScript"
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($StateMigrationScript) -and -not (Test-Path -LiteralPath $StateMigrationScript)) {
|
||||
throw "State migration script not found: $StateMigrationScript"
|
||||
}
|
||||
|
||||
git -C $RepoRoot fetch --prune $UpstreamRemote $UpstreamBranch
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git fetch failed for $RepoRoot $UpstreamRemote/$UpstreamBranch"
|
||||
}
|
||||
|
||||
$sourceRef = "$UpstreamRemote/$UpstreamBranch"
|
||||
$commit = (git -C $RepoRoot rev-parse $sourceRef).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($commit)) {
|
||||
throw "Could not determine git revision for $RepoRoot $sourceRef"
|
||||
}
|
||||
|
||||
$scratch = Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\idunn-deploy") $AppId
|
||||
$tarPath = Join-Path $scratch $RemoteTarballName
|
||||
$manifestPath = Join-Path $scratch "deployment-manifest.txt"
|
||||
New-Item -ItemType Directory -Force -Path $scratch | Out-Null
|
||||
|
||||
git -C $RepoRoot archive --format=tar --output=$tarPath $sourceRef
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git archive failed for $RepoRoot $sourceRef"
|
||||
}
|
||||
|
||||
$hash = (Get-FileHash $tarPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$deployedAt = [DateTimeOffset]::UtcNow.ToString("O")
|
||||
@(
|
||||
"schema=gamecult.idunn.deployment_manifest.v1"
|
||||
"appId=$AppId"
|
||||
"repoRoot=$RepoRoot"
|
||||
"upstreamRemote=$UpstreamRemote"
|
||||
"upstreamBranch=$UpstreamBranch"
|
||||
"sourceRef=$sourceRef"
|
||||
"gitCommit=$commit"
|
||||
"artifact=$RemoteTarballName"
|
||||
"sha256=$hash"
|
||||
"deployedAtUtc=$deployedAt"
|
||||
) | Set-Content -Encoding ASCII -LiteralPath $manifestPath
|
||||
|
||||
$remoteDeploy = "/home/gamecultadmin/$([IO.Path]::GetFileName($DeployScript))"
|
||||
$remoteCheck = "/home/gamecultadmin/$([IO.Path]::GetFileName($CheckScript))"
|
||||
$remoteMigration = if ([string]::IsNullOrWhiteSpace($StateMigrationScript)) { "" } else { "/home/gamecultadmin/$([IO.Path]::GetFileName($StateMigrationScript))" }
|
||||
$remoteTarball = "/home/gamecultadmin/$RemoteTarballName"
|
||||
$remoteManifest = "/home/gamecultadmin/$AppId-deployment-manifest.txt"
|
||||
$remoteRunner = "/home/gamecultadmin/run-$AppId-deploy.sh"
|
||||
$targetTarball = "$RemoteAppHome/$RemoteTarballName"
|
||||
$targetManifest = "$RemoteAppHome/deployment-manifest.txt"
|
||||
$sshArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=10", $SshTarget)
|
||||
$scpArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=10")
|
||||
$runnerPath = Join-Path $scratch "run-$AppId-deploy.sh"
|
||||
|
||||
$runnerScript = @"
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
sudo -n install -d -o '$AppUser' -g '$AppUser' '$RemoteAppHome'
|
||||
sudo -n install -o '$AppUser' -g '$AppUser' -m 600 '$remoteTarball' '$targetTarball'
|
||||
sudo -n install -m 644 '$remoteManifest' '$targetManifest'
|
||||
chmod +x '$remoteDeploy' '$remoteCheck'
|
||||
if [ -n '$remoteMigration' ]; then
|
||||
chmod +x '$remoteMigration'
|
||||
sudo -n bash '$remoteMigration'
|
||||
fi
|
||||
sudo -n bash '$remoteDeploy'
|
||||
|
||||
check_ok=0
|
||||
for i in `$(seq 1 30); do
|
||||
if sudo -n bash '$remoteCheck'; then
|
||||
check_ok=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "`$check_ok" -ne 1 ]; then
|
||||
sudo -n bash '$remoteCheck'
|
||||
exit 1
|
||||
fi
|
||||
"@.Replace("`r`n", "`n")
|
||||
[System.IO.File]::WriteAllText($runnerPath, $runnerScript, [System.Text.Encoding]::ASCII)
|
||||
|
||||
scp.exe @scpArgs $tarPath "${SshTarget}:$remoteTarball"
|
||||
if ($LASTEXITCODE -ne 0) { throw "scp source tarball failed for $AppId" }
|
||||
scp.exe @scpArgs $DeployScript "${SshTarget}:$remoteDeploy"
|
||||
if ($LASTEXITCODE -ne 0) { throw "scp deploy script failed for $AppId" }
|
||||
scp.exe @scpArgs $CheckScript "${SshTarget}:$remoteCheck"
|
||||
if ($LASTEXITCODE -ne 0) { throw "scp check script failed for $AppId" }
|
||||
if (-not [string]::IsNullOrWhiteSpace($StateMigrationScript)) {
|
||||
scp.exe @scpArgs $StateMigrationScript "${SshTarget}:$remoteMigration"
|
||||
if ($LASTEXITCODE -ne 0) { throw "scp state migration script failed for $AppId" }
|
||||
}
|
||||
scp.exe @scpArgs $manifestPath "${SshTarget}:$remoteManifest"
|
||||
if ($LASTEXITCODE -ne 0) { throw "scp manifest failed for $AppId" }
|
||||
scp.exe @scpArgs $runnerPath "${SshTarget}:$remoteRunner"
|
||||
if ($LASTEXITCODE -ne 0) { throw "scp runner script failed for $AppId" }
|
||||
|
||||
ssh.exe @sshArgs "chmod +x '$remoteRunner' && bash '$remoteRunner'"
|
||||
$remoteExit = $LASTEXITCODE
|
||||
ssh.exe @sshArgs "rm -f '$remoteRunner'"
|
||||
if ($remoteExit -ne 0) {
|
||||
throw "remote deploy/check failed for $AppId"
|
||||
}
|
||||
|
||||
Remove-Item -LiteralPath $runnerPath -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host "$AppId deployed to Yggdrasil from $sourceRef at $commit."
|
||||
Write-Host " artifactSha256=$hash"
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
@echo off
|
||||
if not "%IDUNN_ACTUATOR%"=="1" (
|
||||
echo This deployment wrapper is an Idunn actuator. Configure Idunn; do not deploy manually.
|
||||
exit /b 1
|
||||
)
|
||||
if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" (
|
||||
echo This deployment wrapper is an Idunn actuator. Configure Idunn; do not deploy manually.
|
||||
exit /b 1
|
||||
)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0deploy-yggdrasil-streampixels.ps1
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
param(
|
||||
[string] $SshTarget = "yggdrasil",
|
||||
[string] $RepoRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "StreamPixels"),
|
||||
[string] $CultLibRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "CultLib"),
|
||||
[string] $AppUser = "streampixels",
|
||||
[string] $RemoteAppHome = "/srv/streampixels",
|
||||
[string] $RemoteTarballName = "streampixels-source.tar",
|
||||
[string] $RemoteCultLibTarballName = "streampixels-cultlib.tar",
|
||||
[string] $DeployScript = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "gamecult-ops\scripts\deploy-streampixels-preview.sh"),
|
||||
[string] $CheckScript = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "gamecult-ops\scripts\check-streampixels-preview.sh"),
|
||||
[string] $UpstreamRemote = "origin",
|
||||
[string] $UpstreamBranch = "main",
|
||||
[string] $CultLibUpstreamRemote = "origin",
|
||||
[string] $CultLibUpstreamBranch = "main"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") {
|
||||
throw "This deployment script is an Idunn actuator. Agents must configure Idunn release targets and let Idunn run deployment; do not invoke deploy scripts manually."
|
||||
}
|
||||
|
||||
foreach ($path in @($RepoRoot, $CultLibRoot, $DeployScript, $CheckScript)) {
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
throw "Required path not found: $path"
|
||||
}
|
||||
}
|
||||
|
||||
$sourceRef = "$UpstreamRemote/$UpstreamBranch"
|
||||
$cultLibSourceRef = "$CultLibUpstreamRemote/$CultLibUpstreamBranch"
|
||||
git -C $RepoRoot fetch --prune $UpstreamRemote $UpstreamBranch
|
||||
if ($LASTEXITCODE -ne 0) { throw "git fetch failed for $RepoRoot $sourceRef" }
|
||||
git -C $CultLibRoot fetch --prune $CultLibUpstreamRemote $CultLibUpstreamBranch
|
||||
if ($LASTEXITCODE -ne 0) { throw "git fetch failed for $CultLibRoot $cultLibSourceRef" }
|
||||
|
||||
$streamPixelsCommit = (git -C $RepoRoot rev-parse $sourceRef).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($streamPixelsCommit)) {
|
||||
throw "Could not determine StreamPixels git revision."
|
||||
}
|
||||
$cultLibCommit = (git -C $CultLibRoot rev-parse $cultLibSourceRef).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($cultLibCommit)) {
|
||||
throw "Could not determine CultLib git revision."
|
||||
}
|
||||
|
||||
$scratch = Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\idunn-deploy") "yggdrasil-streampixels"
|
||||
$sourceTarPath = Join-Path $scratch $RemoteTarballName
|
||||
$cultLibTarPath = Join-Path $scratch $RemoteCultLibTarballName
|
||||
$manifestPath = Join-Path $scratch "deployment-manifest.txt"
|
||||
New-Item -ItemType Directory -Force -Path $scratch | Out-Null
|
||||
|
||||
git -C $RepoRoot archive --format=tar --output=$sourceTarPath $sourceRef
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git archive failed for $RepoRoot $sourceRef"
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $cultLibTarPath) {
|
||||
Remove-Item -LiteralPath $cultLibTarPath -Force
|
||||
}
|
||||
git -C $CultLibRoot archive --format=tar --output=$cultLibTarPath $cultLibSourceRef package.json package-lock.json scripts/run-typescript-workspaces.mjs contracts/cultnet packages/cultcache-ts packages/cultnet-ts packages/cultmesh-ts packages/cultmesh-browser
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "CultLib tar build failed for $CultLibRoot $cultLibSourceRef"
|
||||
}
|
||||
|
||||
$sourceHash = (Get-FileHash $sourceTarPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$cultLibHash = (Get-FileHash $cultLibTarPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$deployedAt = [DateTimeOffset]::UtcNow.ToString("O")
|
||||
@(
|
||||
"schema=gamecult.idunn.deployment_manifest.v1"
|
||||
"appId=yggdrasil-streampixels"
|
||||
"repoRoot=$RepoRoot"
|
||||
"upstreamRemote=$UpstreamRemote"
|
||||
"upstreamBranch=$UpstreamBranch"
|
||||
"sourceRef=$sourceRef"
|
||||
"gitCommit=$streamPixelsCommit"
|
||||
"artifact=$RemoteTarballName"
|
||||
"sha256=$sourceHash"
|
||||
"cultLibRepoRoot=$CultLibRoot"
|
||||
"cultLibUpstreamRemote=$CultLibUpstreamRemote"
|
||||
"cultLibUpstreamBranch=$CultLibUpstreamBranch"
|
||||
"cultLibSourceRef=$cultLibSourceRef"
|
||||
"cultLibGitCommit=$cultLibCommit"
|
||||
"cultLibArtifact=$RemoteCultLibTarballName"
|
||||
"cultLibSha256=$cultLibHash"
|
||||
"deployedAtUtc=$deployedAt"
|
||||
) | Set-Content -Encoding ASCII -LiteralPath $manifestPath
|
||||
|
||||
$remoteDeploy = "/home/gamecultadmin/$([IO.Path]::GetFileName($DeployScript))"
|
||||
$remoteCheck = "/home/gamecultadmin/$([IO.Path]::GetFileName($CheckScript))"
|
||||
$remoteSourceTarball = "/home/gamecultadmin/$RemoteTarballName"
|
||||
$remoteCultLibTarball = "/home/gamecultadmin/$RemoteCultLibTarballName"
|
||||
$remoteManifest = "/home/gamecultadmin/yggdrasil-streampixels-deployment-manifest.txt"
|
||||
$remoteRunner = "/home/gamecultadmin/run-yggdrasil-streampixels-deploy.sh"
|
||||
$targetSourceTarball = "$RemoteAppHome/$RemoteTarballName"
|
||||
$targetCultLibTarball = "$RemoteAppHome/$RemoteCultLibTarballName"
|
||||
$targetManifest = "$RemoteAppHome/deployment-manifest.txt"
|
||||
|
||||
$sftpBatchPath = Join-Path $scratch "yggdrasil-streampixels-upload.sftp"
|
||||
$runnerPath = Join-Path $scratch "run-yggdrasil-streampixels-deploy.sh"
|
||||
$runnerScript = @"
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
sudo -n install -d -o '$AppUser' -g '$AppUser' '$RemoteAppHome'
|
||||
sudo -n install -o '$AppUser' -g '$AppUser' -m 600 '$remoteSourceTarball' '$targetSourceTarball'
|
||||
sudo -n install -o '$AppUser' -g '$AppUser' -m 600 '$remoteCultLibTarball' '$targetCultLibTarball'
|
||||
sudo -n install -m 644 '$remoteManifest' '$targetManifest'
|
||||
chmod +x '$remoteDeploy' '$remoteCheck'
|
||||
sudo -n env GAMECULT_IDUNN_REMOTE_ACTUATOR=1 IDUNN_COMMAND_AUTHORITY=idunn-daemon bash '$remoteDeploy'
|
||||
|
||||
check_ok=0
|
||||
for i in `$(seq 1 30); do
|
||||
if sudo -n bash '$remoteCheck'; then
|
||||
check_ok=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "`$check_ok" -ne 1 ]; then
|
||||
sudo -n bash '$remoteCheck'
|
||||
exit 1
|
||||
fi
|
||||
"@.Replace("`r`n", "`n")
|
||||
[System.IO.File]::WriteAllText($runnerPath, $runnerScript, [System.Text.Encoding]::ASCII)
|
||||
@(
|
||||
"put ""$sourceTarPath"" ""$remoteSourceTarball"""
|
||||
"put ""$cultLibTarPath"" ""$remoteCultLibTarball"""
|
||||
"put ""$DeployScript"" ""$remoteDeploy"""
|
||||
"put ""$CheckScript"" ""$remoteCheck"""
|
||||
"put ""$manifestPath"" ""$remoteManifest"""
|
||||
"put ""$runnerPath"" ""$remoteRunner"""
|
||||
) | Set-Content -Encoding ASCII -LiteralPath $sftpBatchPath
|
||||
|
||||
try {
|
||||
& sftp.exe -b $sftpBatchPath $SshTarget
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "sftp upload failed for yggdrasil-streampixels"
|
||||
}
|
||||
|
||||
& ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $SshTarget "chmod +x '$remoteRunner' && bash '$remoteRunner'"
|
||||
$remoteExit = $LASTEXITCODE
|
||||
& ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $SshTarget "rm -f '$remoteRunner'"
|
||||
if ($remoteExit -ne 0) {
|
||||
throw "remote deploy/check failed for yggdrasil-streampixels"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $runnerPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $sftpBatchPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "yggdrasil-streampixels deployed with CultLib snapshot."
|
||||
Write-Host " streamPixelsCommit=$streamPixelsCommit"
|
||||
Write-Host " cultLibCommit=$cultLibCommit"
|
||||
Write-Host " artifactSha256=$sourceHash"
|
||||
Write-Host " cultLibSha256=$cultLibHash"
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@echo off
|
||||
echo health-mimir-eve-dashboard.cmd archived: Mimir Eve dashboard health is daemon-published over CultNet/RUDP as mimir.cultnet-rudp-provider-health. Do not probe localhost HTTP for daemon health. 1>&2
|
||||
exit /b 2
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@echo off
|
||||
echo health-nightwing-eve-browser-reference.cmd archived: Nightwing Eve browser reference health is daemon-published over CultNet/RUDP as nightwing.cultnet-rudp-browser-reference-health. Use health-nightwing-eve-browser-reference.ps1 only as an explicit witness inspection, not Idunn health truth.
|
||||
exit /b 2
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
param(
|
||||
[string] $SshTarget = "nightwing",
|
||||
[string] $ExpectedDocumentRoot = "/opt/gamecult/eve-browser-reference",
|
||||
[string] $RemoteCultCacheWitness = "/var/lib/gamecult/eve-browser-reference/cultcache/eve-browser-reference.service.cc",
|
||||
[int] $MaxWitnessAgeSeconds = 120
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Fail-IdunnHealth {
|
||||
param(
|
||||
[string] $State,
|
||||
[string] $Message
|
||||
)
|
||||
|
||||
Write-Output "idunn.health.state=$State"
|
||||
throw $Message
|
||||
}
|
||||
|
||||
function Invoke-SshText {
|
||||
param(
|
||||
[string] $Command,
|
||||
[string] $FailureMessage,
|
||||
[string] $FailureState = "failed"
|
||||
)
|
||||
|
||||
$output = ssh.exe -o BatchMode=yes -o ConnectTimeout=5 $SshTarget $Command
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($output)) {
|
||||
Fail-IdunnHealth -State $FailureState -Message $FailureMessage
|
||||
}
|
||||
|
||||
return $output
|
||||
}
|
||||
|
||||
function Get-CultCacheInspection {
|
||||
param(
|
||||
[string] $WitnessCopyPath,
|
||||
[string] $InspectorPackageJson
|
||||
)
|
||||
|
||||
$nodeScript = @'
|
||||
const fs = require("fs");
|
||||
const { createRequire } = require("module");
|
||||
|
||||
const witnessCopyPath = process.argv[2];
|
||||
const inspectorPackageJson = process.argv[3];
|
||||
const requireCultCacheInspection = createRequire(inspectorPackageJson);
|
||||
const { inspectCultCacheBytes } = requireCultCacheInspection("cultcache-ts");
|
||||
const inspection = inspectCultCacheBytes(witnessCopyPath, fs.readFileSync(witnessCopyPath));
|
||||
|
||||
function firstRecord(schemaName) {
|
||||
return inspection.records.find((record) => record.schemaName === schemaName) ?? null;
|
||||
}
|
||||
|
||||
function previewArray(record) {
|
||||
return Array.isArray(record?.payloadPreview) ? record.payloadPreview : [];
|
||||
}
|
||||
|
||||
function normalizeRecord(schemaName) {
|
||||
const record = firstRecord(schemaName);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const preview = previewArray(record);
|
||||
switch (schemaName) {
|
||||
case "mimir.eve_browser_reference_manifest":
|
||||
return {
|
||||
schemaName: record.schemaName,
|
||||
storedAt: record.storedAt,
|
||||
daemonId: preview[0] ?? "",
|
||||
siteTitle: preview[1] ?? "",
|
||||
documentRoot: preview[2] ?? "",
|
||||
entryPoint: preview[3] ?? "",
|
||||
port: preview[4] ?? 0,
|
||||
assetCount: Array.isArray(preview[5]) ? preview[5].length : 0,
|
||||
surfaceTransport: preview[6] ?? "",
|
||||
};
|
||||
case "mimir.eve_browser_reference_static_surface":
|
||||
return {
|
||||
schemaName: record.schemaName,
|
||||
storedAt: record.storedAt,
|
||||
daemonId: preview[0] ?? "",
|
||||
updatedAtUtc: preview[1] ?? "",
|
||||
documentRoot: preview[2] ?? "",
|
||||
entryPoint: preview[3] ?? "",
|
||||
assetCount: Array.isArray(preview[4]) ? preview[4].length : 0,
|
||||
fixtureCount: Array.isArray(preview[5]) ? preview[5].length : 0,
|
||||
currentCutLine: preview[6] ?? "",
|
||||
};
|
||||
case "mimir.eve_browser_reference_command_boundary":
|
||||
return {
|
||||
schemaName: record.schemaName,
|
||||
storedAt: record.storedAt,
|
||||
daemonId: preview[0] ?? "",
|
||||
updatedAtUtc: preview[1] ?? "",
|
||||
mode: preview[2] ?? "",
|
||||
writesAccepted: preview[3] === true,
|
||||
operatorInputAuthority: preview[4] ?? "",
|
||||
lifecycleAuthority: preview[5] ?? "",
|
||||
acceptedCommands: Array.isArray(preview[6]) ? preview[6] : [],
|
||||
rejectedCommands: Array.isArray(preview[7]) ? preview[7] : [],
|
||||
};
|
||||
case "mimir.eve_browser_reference_transport_profile":
|
||||
return {
|
||||
schemaName: record.schemaName,
|
||||
storedAt: record.storedAt,
|
||||
daemonId: preview[0] ?? "",
|
||||
updatedAtUtc: preview[1] ?? "",
|
||||
currentState: preview[2] ?? "",
|
||||
inputTransport: preview[3] ?? "",
|
||||
outputTransport: preview[4] ?? "",
|
||||
healthContract: preview[5] ?? "",
|
||||
idunnRudpHealth: preview[6] ?? "",
|
||||
witnessPath: preview[7] ?? "",
|
||||
currentCutLine: preview[8] ?? "",
|
||||
};
|
||||
case "idunn.daemon_health":
|
||||
return {
|
||||
schemaName: record.schemaName,
|
||||
storedAt: record.storedAt,
|
||||
daemonId: preview[0] ?? "",
|
||||
state: preview[1] ?? "",
|
||||
detail: preview[2] ?? "",
|
||||
observedAt: preview[3] ?? "",
|
||||
healthContract: preview[4] ?? "",
|
||||
publicationSource: preview[5] ?? "",
|
||||
transport: preview[6] ?? "",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
schemaName: record.schemaName,
|
||||
storedAt: record.storedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
format: inspection.format,
|
||||
catalogSchemaVersions: inspection.catalog.map((entry) => entry.schemaVersion),
|
||||
manifest: normalizeRecord("mimir.eve_browser_reference_manifest"),
|
||||
staticSurface: normalizeRecord("mimir.eve_browser_reference_static_surface"),
|
||||
commandBoundary: normalizeRecord("mimir.eve_browser_reference_command_boundary"),
|
||||
transportProfile: normalizeRecord("mimir.eve_browser_reference_transport_profile"),
|
||||
daemonHealth: normalizeRecord("idunn.daemon_health"),
|
||||
}));
|
||||
'@
|
||||
|
||||
$inspectionJson = $nodeScript | node - $WitnessCopyPath $InspectorPackageJson
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($inspectionJson)) {
|
||||
Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness inspection failed."
|
||||
}
|
||||
|
||||
try {
|
||||
return $inspectionJson | ConvertFrom-Json
|
||||
} catch {
|
||||
Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache inspection returned malformed JSON."
|
||||
}
|
||||
}
|
||||
|
||||
ssh.exe -o BatchMode=yes -o ConnectTimeout=5 $SshTarget "systemctl is-active --quiet nightwing-eve-browser-reference.service"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail-IdunnHealth -State "failed" -Message "nightwing-eve-browser-reference.service is not active on Nightwing."
|
||||
}
|
||||
|
||||
$cultCacheStat = Invoke-SshText `
|
||||
-Command "stat -c '%s %Y %U %G' '$RemoteCultCacheWitness' 2>/dev/null" `
|
||||
-FailureMessage "Nightwing Eve browser reference CultCache witness is missing at $RemoteCultCacheWitness."
|
||||
|
||||
$cultCacheParts = $cultCacheStat.Trim() -split '\s+'
|
||||
if ($cultCacheParts.Length -lt 4) {
|
||||
Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness stat output was malformed: $cultCacheStat"
|
||||
}
|
||||
|
||||
$cultCacheSize = [int64]$cultCacheParts[0]
|
||||
$cultCacheModifiedUtc = [DateTimeOffset]::FromUnixTimeSeconds([int64]$cultCacheParts[1])
|
||||
$cultCacheOwner = $cultCacheParts[2]
|
||||
$cultCacheGroup = $cultCacheParts[3]
|
||||
$cultCacheAgeSeconds = ([DateTimeOffset]::UtcNow - $cultCacheModifiedUtc).TotalSeconds
|
||||
if ($cultCacheSize -le 0) {
|
||||
Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness at $RemoteCultCacheWitness is empty."
|
||||
}
|
||||
|
||||
if ($cultCacheAgeSeconds -gt $MaxWitnessAgeSeconds) {
|
||||
Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness is stale ($([math]::Round($cultCacheAgeSeconds))s old)."
|
||||
}
|
||||
|
||||
if ($cultCacheOwner -ne "metacrat" -or $cultCacheGroup -ne "metacrat") {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference CultCache witness owner is ${cultCacheOwner}:$cultCacheGroup, expected metacrat:metacrat."
|
||||
}
|
||||
|
||||
try {
|
||||
$cultCacheInspectorPackageJson = (Resolve-Path (Join-Path $PSScriptRoot "..\..\CultLib\packages\cultcache-ts\package.json")).Path
|
||||
} catch {
|
||||
Fail-IdunnHealth -State "failed" -Message "CultCache inspector package is missing from the local Odin body."
|
||||
}
|
||||
|
||||
$cultCacheWitnessCopy = Join-Path ([System.IO.Path]::GetTempPath()) ("nightwing-eve-browser-reference-" + [Guid]::NewGuid().ToString("N") + ".cc")
|
||||
try {
|
||||
$remoteCultCacheBase64 = Invoke-SshText `
|
||||
-Command "base64 -w0 '$RemoteCultCacheWitness' 2>/dev/null" `
|
||||
-FailureMessage "Nightwing Eve browser reference CultCache witness could not be copied from Nightwing."
|
||||
[System.IO.File]::WriteAllBytes($cultCacheWitnessCopy, [Convert]::FromBase64String($remoteCultCacheBase64.Trim()))
|
||||
$inspection = Get-CultCacheInspection -WitnessCopyPath $cultCacheWitnessCopy -InspectorPackageJson $cultCacheInspectorPackageJson
|
||||
} catch [FormatException] {
|
||||
Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness copy was not valid base64."
|
||||
} finally {
|
||||
if (Test-Path $cultCacheWitnessCopy) {
|
||||
Remove-Item $cultCacheWitnessCopy -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
if ($inspection.format -ne "cultcache.store.v1") {
|
||||
Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness has unexpected format '$($inspection.format)'."
|
||||
}
|
||||
|
||||
$requiredCultCacheSchemas = @(
|
||||
"mimir.eve_browser_reference_manifest.v1",
|
||||
"mimir.eve_browser_reference_static_surface.v1",
|
||||
"mimir.eve_browser_reference_command_boundary.v1",
|
||||
"mimir.eve_browser_reference_transport_profile.v1",
|
||||
"idunn.daemon_health.v1"
|
||||
)
|
||||
|
||||
$publishedCultCacheSchemas = @($inspection.catalogSchemaVersions)
|
||||
foreach ($schema in $requiredCultCacheSchemas) {
|
||||
if ($publishedCultCacheSchemas -notcontains $schema) {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference CultCache witness is missing schema '$schema'."
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $inspection.manifest) {
|
||||
Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness is missing its manifest record."
|
||||
}
|
||||
|
||||
if ($inspection.manifest.documentRoot -ne $ExpectedDocumentRoot) {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference manifest document root is '$($inspection.manifest.documentRoot)', expected '$ExpectedDocumentRoot'."
|
||||
}
|
||||
|
||||
if ($inspection.manifest.entryPoint -ne "index.html") {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference manifest entry point is '$($inspection.manifest.entryPoint)', expected 'index.html'."
|
||||
}
|
||||
|
||||
if ($inspection.manifest.surfaceTransport -ne "static-http-lowering") {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference manifest transport is '$($inspection.manifest.surfaceTransport)', expected 'static-http-lowering'."
|
||||
}
|
||||
|
||||
if ($null -eq $inspection.staticSurface) {
|
||||
Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness is missing its static-surface record."
|
||||
}
|
||||
|
||||
if ($inspection.staticSurface.documentRoot -ne $ExpectedDocumentRoot) {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference static-surface document root is '$($inspection.staticSurface.documentRoot)', expected '$ExpectedDocumentRoot'."
|
||||
}
|
||||
|
||||
if ($null -eq $inspection.commandBoundary) {
|
||||
Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness is missing its command boundary record."
|
||||
}
|
||||
|
||||
if ($inspection.commandBoundary.mode -ne "static-read-only-lowering") {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference command boundary mode is '$($inspection.commandBoundary.mode)', expected 'static-read-only-lowering'."
|
||||
}
|
||||
|
||||
if ($inspection.commandBoundary.writesAccepted) {
|
||||
Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference command boundary unexpectedly accepts writes."
|
||||
}
|
||||
|
||||
if ($inspection.commandBoundary.lifecycleAuthority -ne "idunn-supervisor-command.restart") {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference lifecycle authority is '$($inspection.commandBoundary.lifecycleAuthority)', expected 'idunn-supervisor-command.restart'."
|
||||
}
|
||||
|
||||
if ($null -eq $inspection.transportProfile) {
|
||||
Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness is missing its transport profile record."
|
||||
}
|
||||
|
||||
if ($inspection.transportProfile.currentState -ne "partial-rudp-health-and-provider-store-live") {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference transport profile state is '$($inspection.transportProfile.currentState)', expected 'partial-rudp-health-and-provider-store-live'."
|
||||
}
|
||||
|
||||
if ($inspection.transportProfile.inputTransport -ne "http-static-lowering") {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference input transport is '$($inspection.transportProfile.inputTransport)', expected 'http-static-lowering'."
|
||||
}
|
||||
|
||||
if ($inspection.transportProfile.outputTransport -ne "daemon-owned-cultcache-boundary-store + daemon-published-rudp-health") {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference output transport is '$($inspection.transportProfile.outputTransport)', expected 'daemon-owned-cultcache-boundary-store + daemon-published-rudp-health'."
|
||||
}
|
||||
|
||||
if ($inspection.transportProfile.healthContract -ne "nightwing.cultnet-rudp-browser-reference-health") {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference health contract is '$($inspection.transportProfile.healthContract)', expected 'nightwing.cultnet-rudp-browser-reference-health'."
|
||||
}
|
||||
|
||||
if ($inspection.transportProfile.idunnRudpHealth -ne "idunn.health-published-separately") {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference transport profile Idunn health publication is '$($inspection.transportProfile.idunnRudpHealth)', expected 'idunn.health-published-separately'."
|
||||
}
|
||||
|
||||
if ($inspection.transportProfile.witnessPath -ne $RemoteCultCacheWitness) {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference transport profile witness path is '$($inspection.transportProfile.witnessPath)', expected '$RemoteCultCacheWitness'."
|
||||
}
|
||||
|
||||
if ($null -eq $inspection.daemonHealth) {
|
||||
Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference CultCache witness is missing its daemon-health record."
|
||||
}
|
||||
|
||||
if ($inspection.daemonHealth.daemonId -ne "nightwing-eve-browser-reference") {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference daemon-health record advertises daemon '$($inspection.daemonHealth.daemonId)', expected 'nightwing-eve-browser-reference'."
|
||||
}
|
||||
|
||||
if ($inspection.daemonHealth.state -ne "healthy") {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference daemon-health state is '$($inspection.daemonHealth.state)', expected 'healthy'."
|
||||
}
|
||||
|
||||
if ($inspection.daemonHealth.healthContract -ne "nightwing.cultnet-rudp-browser-reference-health") {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference daemon-health contract is '$($inspection.daemonHealth.healthContract)', expected 'nightwing.cultnet-rudp-browser-reference-health'."
|
||||
}
|
||||
|
||||
if ($inspection.daemonHealth.publicationSource -ne "daemon-published") {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference daemon-health publication source is '$($inspection.daemonHealth.publicationSource)', expected 'daemon-published'."
|
||||
}
|
||||
|
||||
if ($inspection.daemonHealth.transport -ne "cultnet.transport.rudp.v0") {
|
||||
Fail-IdunnHealth -State "degraded" -Message "Nightwing Eve browser reference daemon-health transport is '$($inspection.daemonHealth.transport)', expected 'cultnet.transport.rudp.v0'."
|
||||
}
|
||||
|
||||
try {
|
||||
$daemonHealthObservedAtUtc = [DateTimeOffset]::Parse($inspection.daemonHealth.observedAt)
|
||||
} catch {
|
||||
Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference daemon-health observedAt is malformed: '$($inspection.daemonHealth.observedAt)'."
|
||||
}
|
||||
|
||||
$daemonHealthAgeSeconds = ([DateTimeOffset]::UtcNow - $daemonHealthObservedAtUtc.ToUniversalTime()).TotalSeconds
|
||||
if ($daemonHealthAgeSeconds -gt $MaxWitnessAgeSeconds) {
|
||||
Fail-IdunnHealth -State "failed" -Message "Nightwing Eve browser reference daemon-health record is stale ($([math]::Round($daemonHealthAgeSeconds))s old)."
|
||||
}
|
||||
|
||||
Write-Host "Nightwing Eve browser reference is active, and its daemon-owned CultCache witness now certifies manifest, boundary, and daemon-health truth directly."
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@echo off
|
||||
echo health-nightwing-eve-dashboard.cmd archived: Nightwing Eve dashboard broker health is not daemon truth. Rebuild typed CultMesh/Odin dashboard state before restoring a health check. 1>&2
|
||||
exit /b 2
|
||||
|
|
@ -1 +0,0 @@
|
|||
throw "health-nightwing-eve-dashboard.ps1 archived: Nightwing Eve dashboard broker health is not daemon truth. Inspect typed CultMesh/Odin dashboard state after the publisher is rebuilt."
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@echo off
|
||||
echo health-odin.cmd archived: Odin health is daemon-published over CultNet/RUDP as odin.cultnet-rudp-provider-health. Use health-odin.ps1 only as an explicit daemon witness inspection, not Idunn health truth.
|
||||
exit /b 2
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
param(
|
||||
[string] $StateDir = (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\odin"),
|
||||
[int] $MaxSilenceSeconds = 120,
|
||||
[string] $CultNetRudpBind = "0.0.0.0:17871"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$pidPath = Join-Path $StateDir "odin.pid"
|
||||
$outLog = Join-Path $StateDir "odin.out.log"
|
||||
$errLog = Join-Path $StateDir "odin.err.log"
|
||||
$cachePath = Join-Path $StateDir "odin.ccmp"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $pidPath)) {
|
||||
throw "Odin PID file is missing at $pidPath"
|
||||
}
|
||||
|
||||
$pidText = (Get-Content -LiteralPath $pidPath -Raw).Trim()
|
||||
if ($pidText -notmatch '^\d+$') {
|
||||
throw "Odin PID file is invalid at ${pidPath}: '$pidText'"
|
||||
}
|
||||
|
||||
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $pidText" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $process) {
|
||||
throw "Odin process $pidText is not running"
|
||||
}
|
||||
|
||||
foreach ($pattern in @(
|
||||
'node.exe',
|
||||
'src\odin-coordinator.cjs',
|
||||
"--stateDir $StateDir",
|
||||
"--cultnet-rudp-bind $CultNetRudpBind",
|
||||
'--idunn-daemon odin',
|
||||
'--idunn-health-contract odin.cultnet-rudp-provider-health'
|
||||
)) {
|
||||
if ($process.CommandLine -notlike "*$pattern*") {
|
||||
throw "Odin process command line is missing expected segment ${pattern}: $($process.CommandLine)"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($logPath in @($outLog, $errLog)) {
|
||||
if (-not (Test-Path -LiteralPath $logPath)) {
|
||||
throw "Odin log is missing at $logPath"
|
||||
}
|
||||
}
|
||||
|
||||
$newestLogWriteUtc = @($outLog, $errLog) |
|
||||
ForEach-Object { (Get-Item -LiteralPath $_).LastWriteTimeUtc } |
|
||||
Sort-Object -Descending |
|
||||
Select-Object -First 1
|
||||
$logAgeSeconds = ([DateTime]::UtcNow - $newestLogWriteUtc).TotalSeconds
|
||||
if ($logAgeSeconds -gt $MaxSilenceSeconds) {
|
||||
throw "Odin logs have been silent for $([math]::Round($logAgeSeconds))s, exceeding $MaxSilenceSeconds seconds"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $cachePath)) {
|
||||
throw "Odin CultMesh store is missing at $cachePath"
|
||||
}
|
||||
|
||||
$rudpPort = [int](($CultNetRudpBind -split ':')[-1])
|
||||
$udpListener = Get-NetUDPEndpoint -LocalPort $rudpPort -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.OwningProcess -eq [int]$pidText } |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $udpListener) {
|
||||
throw "Odin is not listening on UDP port $rudpPort as process $pidText"
|
||||
}
|
||||
|
||||
Write-Host "Odin healthy: pid=$pidText cultmesh_rudp_udp=$rudpPort store=$cachePath log_age=$([math]::Round($logAgeSeconds))s"
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@echo off
|
||||
echo health-raven-capture-mux.cmd archived: Raven capture mux no longer owns Muninn daemon health; Muninn publishes muninn.cultnet-rudp-remote-telemetry-health over CultNet/RUDP.
|
||||
exit /b 2
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
Write-Error "health-raven-capture-mux.ps1 archived: Raven capture mux no longer owns Muninn daemon health; Muninn publishes muninn.cultnet-rudp-remote-telemetry-health over CultNet/RUDP."
|
||||
exit 2
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@echo off
|
||||
echo health-stonks.cmd archived: Stonks health is daemon-published over CultNet/RUDP as stonks.cultnet-rudp-market-health. Do not probe localhost HTTP for daemon health. 1>&2
|
||||
exit /b 2
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@echo off
|
||||
echo health-vili.cmd archived: Vili health is daemon-published over CultNet/RUDP as vili.cultnet-rudp-animation-health. Do not probe HTTP for daemon health. 1>&2
|
||||
exit /b 2
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@echo off
|
||||
echo health-voidbot.cmd archived: VoidBot health is daemon-published over CultNet/RUDP as voidbot.cultnet-rudp-stack-health. Use health-voidbot.ps1 only as an explicit witness inspection, not Idunn health truth. 1>&2
|
||||
exit /b 2
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
param(
|
||||
[string] $VoidBotRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "VoidBot"),
|
||||
[int] $MaxOrchestratorAgeSeconds = 180,
|
||||
[int] $MaxCultMeshStoreAgeSeconds = 180
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Fail-IdunnHealth {
|
||||
param(
|
||||
[string] $State,
|
||||
[string] $Message
|
||||
)
|
||||
|
||||
Write-Output "idunn.health.state=$State"
|
||||
throw $Message
|
||||
}
|
||||
|
||||
function Read-JsonFile {
|
||||
param([Parameter(Mandatory = $true)][string] $Path)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
|
||||
if ([string]::IsNullOrWhiteSpace($raw)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Get-JsonPropertyValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $false)] $Object,
|
||||
[Parameter(Mandatory = $true)][string] $Name,
|
||||
[Parameter(Mandatory = $false)] $Default = $null
|
||||
)
|
||||
|
||||
if ($null -eq $Object) {
|
||||
return $Default
|
||||
}
|
||||
|
||||
$property = $Object.PSObject.Properties[$Name]
|
||||
if ($null -eq $property) {
|
||||
return $Default
|
||||
}
|
||||
|
||||
return $property.Value
|
||||
}
|
||||
|
||||
function Get-ProcessOrFail {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string] $Role,
|
||||
[Parameter(Mandatory = $true)][int] $ProcessId
|
||||
)
|
||||
|
||||
$process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
|
||||
if ($null -eq $process) {
|
||||
Fail-IdunnHealth -State "failed" -Message "VoidBot $Role process PID $ProcessId is not running."
|
||||
}
|
||||
|
||||
return $process
|
||||
}
|
||||
|
||||
function Assert-StoreContains {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string] $StorePath,
|
||||
[Parameter(Mandatory = $true)][string[]] $Tokens
|
||||
)
|
||||
|
||||
$rg = (Get-Command rg.exe -ErrorAction SilentlyContinue).Source
|
||||
if ([string]::IsNullOrWhiteSpace($rg)) {
|
||||
Fail-IdunnHealth -State "degraded" -Message "ripgrep is required to inspect the VoidBot CultMesh witness store."
|
||||
}
|
||||
|
||||
foreach ($token in $Tokens) {
|
||||
& $rg -a -q --fixed-strings -- $token $StorePath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail-IdunnHealth -State "degraded" -Message "VoidBot CultMesh witness store is missing '$token'."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$runtimePath = Join-Path $VoidBotRoot ".voidbot\status\runtime-stack.json"
|
||||
$orchestratorPath = Join-Path $VoidBotRoot ".voidbot\status\gamecult-orchestrator.json"
|
||||
$cultMeshStorePath = Join-Path $VoidBotRoot ".voidbot\status\cultmesh\voidbot-swarm-state.cc"
|
||||
|
||||
$runtime = Read-JsonFile -Path $runtimePath
|
||||
if ($null -eq $runtime) {
|
||||
Fail-IdunnHealth -State "failed" -Message "VoidBot runtime state is missing at $runtimePath."
|
||||
}
|
||||
|
||||
if (-not [bool](Get-JsonPropertyValue -Object $runtime -Name "ready" -Default $false)) {
|
||||
Fail-IdunnHealth -State "failed" -Message "VoidBot runtime stack reports ready=false."
|
||||
}
|
||||
|
||||
$runtimeStage = [string](Get-JsonPropertyValue -Object $runtime -Name "stage" -Default "unknown")
|
||||
if ($runtimeStage -ne "ready") {
|
||||
Fail-IdunnHealth -State "failed" -Message "VoidBot runtime stack stage is '$runtimeStage', expected 'ready'."
|
||||
}
|
||||
|
||||
$bot = Get-JsonPropertyValue -Object $runtime -Name "bot"
|
||||
$worker = Get-JsonPropertyValue -Object $runtime -Name "worker"
|
||||
$botPid = if ($null -ne $bot) { [int](Get-JsonPropertyValue -Object $bot -Name "pid" -Default 0) } else { 0 }
|
||||
$workerPid = if ($null -ne $worker) { [int](Get-JsonPropertyValue -Object $worker -Name "pid" -Default 0) } else { 0 }
|
||||
if ($botPid -le 0) {
|
||||
Fail-IdunnHealth -State "failed" -Message "VoidBot runtime state does not contain a live bot PID."
|
||||
}
|
||||
if ($workerPid -le 0) {
|
||||
Fail-IdunnHealth -State "failed" -Message "VoidBot runtime state does not contain a live worker PID."
|
||||
}
|
||||
|
||||
$botProcess = Get-ProcessOrFail -Role "bot" -ProcessId $botPid
|
||||
$workerProcess = Get-ProcessOrFail -Role "worker" -ProcessId $workerPid
|
||||
|
||||
$orchestrator = Read-JsonFile -Path $orchestratorPath
|
||||
if ($null -eq $orchestrator -or $null -eq $orchestrator.organs) {
|
||||
Fail-IdunnHealth -State "failed" -Message "VoidBot orchestrator state is missing at $orchestratorPath."
|
||||
}
|
||||
|
||||
$surfaceState = $orchestrator.organs.PSObject.Properties["voidbot-swarm-surface"]
|
||||
if ($null -eq $surfaceState) {
|
||||
Fail-IdunnHealth -State "failed" -Message "VoidBot orchestrator does not report the voidbot-swarm-surface organ."
|
||||
}
|
||||
$surfaceState = $surfaceState.Value
|
||||
|
||||
if ($surfaceState.lastStatus -ne "ok") {
|
||||
Fail-IdunnHealth -State "degraded" -Message "VoidBot swarm surface organ lastStatus is '$($surfaceState.lastStatus)', expected 'ok'."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($surfaceState.lastFinishedAt)) {
|
||||
Fail-IdunnHealth -State "failed" -Message "VoidBot swarm surface organ has no lastFinishedAt timestamp."
|
||||
}
|
||||
|
||||
$surfaceFinishedAt = [DateTimeOffset]::Parse($surfaceState.lastFinishedAt).ToUniversalTime()
|
||||
$surfaceAgeSeconds = ([DateTimeOffset]::UtcNow - $surfaceFinishedAt).TotalSeconds
|
||||
if ($surfaceAgeSeconds -gt $MaxOrchestratorAgeSeconds) {
|
||||
Fail-IdunnHealth -State "failed" -Message "VoidBot swarm surface organ is stale ($([math]::Round($surfaceAgeSeconds))s old)."
|
||||
}
|
||||
|
||||
$orchestratorFile = Get-Item -LiteralPath $orchestratorPath -ErrorAction SilentlyContinue
|
||||
if ($null -eq $orchestratorFile) {
|
||||
Fail-IdunnHealth -State "failed" -Message "VoidBot orchestrator state file is missing at $orchestratorPath."
|
||||
}
|
||||
$orchestratorAgeSeconds = ([DateTimeOffset]::UtcNow - [DateTimeOffset]$orchestratorFile.LastWriteTimeUtc).TotalSeconds
|
||||
if ($orchestratorAgeSeconds -gt $MaxOrchestratorAgeSeconds) {
|
||||
Fail-IdunnHealth -State "failed" -Message "VoidBot orchestrator state file is stale ($([math]::Round($orchestratorAgeSeconds))s old)."
|
||||
}
|
||||
|
||||
$cultMeshStore = Get-Item -LiteralPath $cultMeshStorePath -ErrorAction SilentlyContinue
|
||||
if ($null -eq $cultMeshStore) {
|
||||
Fail-IdunnHealth -State "failed" -Message "VoidBot CultMesh witness store is missing at $cultMeshStorePath."
|
||||
}
|
||||
if ($cultMeshStore.Length -le 0) {
|
||||
Fail-IdunnHealth -State "failed" -Message "VoidBot CultMesh witness store at $cultMeshStorePath is empty."
|
||||
}
|
||||
|
||||
$cultMeshStoreAgeSeconds = ([DateTimeOffset]::UtcNow - [DateTimeOffset]$cultMeshStore.LastWriteTimeUtc).TotalSeconds
|
||||
if ($cultMeshStoreAgeSeconds -gt $MaxCultMeshStoreAgeSeconds) {
|
||||
Fail-IdunnHealth -State "failed" -Message "VoidBot CultMesh witness store is stale ($([math]::Round($cultMeshStoreAgeSeconds))s old)."
|
||||
}
|
||||
|
||||
Assert-StoreContains -StorePath $cultMeshStorePath -Tokens @(
|
||||
"voidbot.provider_advertisement_catalog.v0",
|
||||
"idunn.command_boundary.v1",
|
||||
"idunn.daemon_transport_profile.v1",
|
||||
"voidbot.providers",
|
||||
"voidbot.discord",
|
||||
"voidbot.reddit",
|
||||
"voidbot.archive",
|
||||
"voidbot.source",
|
||||
"voidbot.repo_face",
|
||||
"voidbot.swarm",
|
||||
"command-boundary:voidbot",
|
||||
"transport:voidbot"
|
||||
)
|
||||
|
||||
Write-Host "VoidBot runtime is ready, bot PID $($botProcess.Id) and worker PID $($workerProcess.Id) are live, and the daemon-owned CultMesh witness store is fresh."
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@echo off
|
||||
echo health-weksa.cmd archived: Weksa health is daemon-published over CultNet/RUDP as weksa.cultnet-rudp-provider-health. Do not run local command health for daemon truth. 1>&2
|
||||
exit /b 2
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@echo off
|
||||
echo health-yggdrasil-heimdall.cmd archived: Heimdall health is daemon-published over CultNet/RUDP as heimdall.cultnet-rudp-provider-health. Use health-yggdrasil-source-app.ps1 only as an explicit deployment witness, not Idunn health truth.
|
||||
exit /b 2
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@echo off
|
||||
echo health-yggdrasil-repixelizer.cmd archived: Repixelizer health is daemon-published over CultNet/RUDP as repixelizer.cultnet-rudp-service-health. Use health-yggdrasil-source-app.ps1 only as an explicit deployment witness, not Idunn health truth.
|
||||
exit /b 2
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
param(
|
||||
[Parameter(Mandatory = $true)] [string] $AppId,
|
||||
[Parameter(Mandatory = $true)] [string] $RepoRoot,
|
||||
[Parameter(Mandatory = $true)] [string] $RemoteAppHome,
|
||||
[Parameter(Mandatory = $true)] [string] $CheckScript,
|
||||
[string] $SshTarget = "yggdrasil"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$localCommit = (git -C $RepoRoot rev-parse HEAD).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($localCommit)) {
|
||||
throw "Could not determine git revision for $RepoRoot"
|
||||
}
|
||||
|
||||
$remoteCheck = "/home/gamecultadmin/$([IO.Path]::GetFileName($CheckScript))"
|
||||
$scpArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=10")
|
||||
$sshArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=10", $SshTarget)
|
||||
scp.exe @scpArgs $CheckScript "${SshTarget}:$remoteCheck" | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "scp check script failed for $AppId" }
|
||||
|
||||
$manifest = ssh.exe @sshArgs "cat '$RemoteAppHome/deployment-manifest.txt' 2>/dev/null"
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($manifest)) {
|
||||
throw "$AppId deploy manifest is missing on Yggdrasil."
|
||||
}
|
||||
|
||||
$remoteCommit = ""
|
||||
foreach ($line in $manifest) {
|
||||
if ($line -like "gitCommit=*") {
|
||||
$remoteCommit = $line.Substring("gitCommit=".Length).Trim()
|
||||
}
|
||||
}
|
||||
|
||||
if ($remoteCommit -ne $localCommit) {
|
||||
throw "$AppId deploy is stale. remote=$remoteCommit local=$localCommit"
|
||||
}
|
||||
|
||||
ssh.exe @sshArgs "chmod +x '$remoteCheck' && sudo -n bash '$remoteCheck'"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$AppId remote health check failed."
|
||||
}
|
||||
|
||||
Write-Host "$AppId is active and deployed at $localCommit."
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@echo off
|
||||
echo health-yggdrasil-streampixels.cmd archived: StreamPixels health is daemon-published over CultNet/RUDP as streampixels.cultnet-rudp-service-health. Use health-yggdrasil-source-app.ps1 only as an explicit deployment witness, not Idunn health truth.
|
||||
exit /b 2
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "deploy-yggdrasil-heimdall must run as root through Idunn" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source_tar=/srv/heimdall/heimdall-source.tar
|
||||
cultlib_tar=/srv/heimdall/heimdall-cultlib.tar
|
||||
releases=/srv/heimdall/app/releases
|
||||
current=/srv/heimdall/app/current
|
||||
shared_cultlib="$releases/CultLib"
|
||||
compose=/srv/compose/yggdrasil-apps.yaml
|
||||
|
||||
for required in "$source_tar" "$cultlib_tar" "$compose"; do
|
||||
if [ ! -f "$required" ]; then
|
||||
echo "missing audited Heimdall deployment input: $required" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
artifact_sha=$(sha256sum "$source_tar" | awk '{print $1}')
|
||||
release="$releases/sha-${artifact_sha}"
|
||||
previous=$(readlink -f "$current" 2>/dev/null || true)
|
||||
|
||||
if [ ! -f "$release/.idunn-build-complete" ]; then
|
||||
staging="$release.staging.$$"
|
||||
rm -rf "$staging"
|
||||
install -d -o heimdall -g heimdall "$staging" "$shared_cultlib"
|
||||
tar -xf "$source_tar" -C "$staging"
|
||||
rm -rf "$shared_cultlib/packages/cultcache-ts" "$shared_cultlib/packages/cultnet-ts"
|
||||
tar -xf "$cultlib_tar" -C "$shared_cultlib"
|
||||
|
||||
docker run --rm \
|
||||
-v /srv/heimdall:/srv/heimdall \
|
||||
-w "$staging" \
|
||||
node:24.14.1-bookworm-slim@sha256:b506e7321f176aae77317f99d67a24b272c1f09f1d10f1761f2773447d8da26c \
|
||||
sh -c 'corepack enable && pnpm install --frozen-lockfile && pnpm typecheck && pnpm test && pnpm build && pnpm prune --prod'
|
||||
|
||||
printf '%s\n' "$artifact_sha" > "$staging/.idunn-build-complete"
|
||||
chown -R heimdall:heimdall "$staging" "$shared_cultlib"
|
||||
mv "$staging" "$release"
|
||||
fi
|
||||
|
||||
ln -sfn "$release" "$current"
|
||||
chown -h heimdall:heimdall "$current"
|
||||
|
||||
if ! docker compose -f "$compose" up -d --force-recreate heimdall; then
|
||||
if [ -n "$previous" ] && [ -d "$previous" ]; then
|
||||
ln -sfn "$previous" "$current"
|
||||
chown -h heimdall:heimdall "$current"
|
||||
docker compose -f "$compose" up -d --force-recreate heimdall || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
healthy=0
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:4100/healthz >/dev/null; then
|
||||
healthy=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$healthy" -ne 1 ]; then
|
||||
echo "Heimdall failed health verification after deploying $artifact_sha" >&2
|
||||
if [ -n "$previous" ] && [ -d "$previous" ]; then
|
||||
ln -sfn "$previous" "$current"
|
||||
chown -h heimdall:heimdall "$current"
|
||||
docker compose -f "$compose" up -d --force-recreate heimdall || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Heimdall deployed audited source artifact $artifact_sha"
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
@echo off
|
||||
if not "%IDUNN_ACTUATOR%"=="1" (
|
||||
echo restart-nightwing-eve-browser-reference.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" (
|
||||
echo restart-nightwing-eve-browser-reference.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0restart-nightwing-eve-browser-reference.ps1
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
param(
|
||||
[string] $SshTarget = "nwroot",
|
||||
[string] $Service = "nightwing-eve-browser-reference.service"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") {
|
||||
throw "restart-nightwing-eve-browser-reference.ps1 is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path."
|
||||
}
|
||||
|
||||
ssh.exe -o BatchMode=yes -o ConnectTimeout=5 $SshTarget "systemctl restart $Service && systemctl is-active --quiet $Service"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Remote restart failed for $Service on $SshTarget with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
Write-Host "Restarted $Service on $SshTarget."
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@echo off
|
||||
echo restart-nightwing-eve-dashboard.cmd archived: Mimir.EveDashboard's HTTP/WebSocket broker is cut. Rebuild a typed CultMesh/Odin dashboard publisher before restoring Idunn lifecycle authority. 1>&2
|
||||
exit /b 2
|
||||
|
|
@ -1 +0,0 @@
|
|||
throw "restart-nightwing-eve-dashboard.ps1 archived: Mimir.EveDashboard's HTTP/WebSocket broker is cut. Rebuild a typed CultMesh/Odin dashboard publisher before restoring Idunn lifecycle authority."
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
@echo off
|
||||
if not "%IDUNN_ACTUATOR%"=="1" (
|
||||
echo restart-odin.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" (
|
||||
echo restart-odin.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File %~dp0restart-odin.ps1
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
param(
|
||||
[string] $StateDir = (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\odin"),
|
||||
[string] $CultNetRudpBind = "0.0.0.0:17871",
|
||||
[string] $IdunnRudpHealth = $(if ($env:ODIN_IDUNN_RUDP_HEALTH) { $env:ODIN_IDUNN_RUDP_HEALTH } else { $env:IDUNN_RUDP_HEALTH }),
|
||||
[string] $IdunnDaemon = "odin",
|
||||
[string] $IdunnHealthContract = "odin.cultnet-rudp-provider-health"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") {
|
||||
throw "restart-odin.ps1 is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path."
|
||||
}
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$stopScript = Join-Path $PSScriptRoot "stop-odin.ps1"
|
||||
$startScript = Join-Path $PSScriptRoot "start-odin.ps1"
|
||||
$powerShellExe = Join-Path $env:WINDIR "System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $powerShellExe)) {
|
||||
throw "PowerShell executable is missing at $powerShellExe"
|
||||
}
|
||||
|
||||
$startArgs = @(
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-File", $startScript,
|
||||
"-StateDir", $StateDir,
|
||||
"-CultNetRudpBind", $CultNetRudpBind,
|
||||
"-IdunnDaemon", $IdunnDaemon,
|
||||
"-IdunnHealthContract", $IdunnHealthContract
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($IdunnRudpHealth)) {
|
||||
$startArgs += @("-IdunnRudpHealth", $IdunnRudpHealth)
|
||||
}
|
||||
|
||||
& $powerShellExe -NoProfile -ExecutionPolicy Bypass -File $stopScript -StateDir $StateDir
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "stop-odin.ps1 failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
& $powerShellExe @startArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "start-odin.ps1 failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
@echo off
|
||||
if not "%IDUNN_ACTUATOR%"=="1" (
|
||||
echo restart-stonks.cmd is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" (
|
||||
echo restart-stonks.cmd is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0restart-stonks.ps1
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$stonksRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "Stonks")
|
||||
$stateDir = Join-Path $stonksRoot "scratch\stonks"
|
||||
$pidPath = Join-Path $stateDir "stonks.pid"
|
||||
$startScript = Join-Path $stonksRoot "scripts\start-stonks.ps1"
|
||||
|
||||
if (Test-Path -LiteralPath $pidPath) {
|
||||
$pidText = (Get-Content -LiteralPath $pidPath -Raw).Trim()
|
||||
if ($pidText -match "^\d+$") {
|
||||
$process = Get-Process -Id ([int] $pidText) -ErrorAction SilentlyContinue
|
||||
if ($null -ne $process) {
|
||||
Stop-Process -Id $process.Id -Force
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
Remove-Item -LiteralPath $pidPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $startScript
|
||||
exit $LASTEXITCODE
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
@echo off
|
||||
if not "%IDUNN_ACTUATOR%"=="1" (
|
||||
echo restart-vili.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" (
|
||||
echo restart-vili.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0restart-vili.ps1" %*
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
param(
|
||||
[string] $RavenHost = "raven",
|
||||
[string] $LocalViliRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "Vili"),
|
||||
[string] $LocalCultLibRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "CultLib"),
|
||||
[string] $RemoteViliRoot = "E:\Projects\Vili",
|
||||
[int] $Port = 8824,
|
||||
[string] $HostName = "0.0.0.0",
|
||||
[string] $StateRoot = "E:\Projects\Vili\.vili",
|
||||
[string] $IdunnRudpHealth = $(if ($env:VILI_IDUNN_RUDP_HEALTH) { $env:VILI_IDUNN_RUDP_HEALTH } else { $env:IDUNN_RUDP_HEALTH }),
|
||||
[string] $IdunnDaemon = "vili",
|
||||
[string] $IdunnHealthContract = "vili.cultnet-rudp-animation-health",
|
||||
[int] $IdunnHealthIntervalSeconds = 15
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") {
|
||||
throw "restart-vili.ps1 is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($IdunnRudpHealth)) {
|
||||
throw "Idunn RUDP health endpoint must be supplied by VILI_IDUNN_RUDP_HEALTH, IDUNN_RUDP_HEALTH, or -IdunnRudpHealth; no WireGuard endpoint default is allowed."
|
||||
}
|
||||
|
||||
$localFiles = @(
|
||||
(Join-Path $LocalViliRoot "package.json"),
|
||||
(Join-Path $LocalViliRoot "package-lock.json"),
|
||||
(Join-Path $LocalViliRoot "scripts\idunn-rudp.cjs"),
|
||||
(Join-Path $LocalViliRoot "scripts\install-vili-task.ps1"),
|
||||
(Join-Path $LocalViliRoot "scripts\restart-vili-daemon.cmd"),
|
||||
(Join-Path $LocalViliRoot "scripts\restart-vili-daemon.ps1"),
|
||||
(Join-Path $LocalViliRoot "scripts\run-hidden-powershell.vbs"),
|
||||
(Join-Path $LocalViliRoot "scripts\run-vili-daemon.ps1"),
|
||||
(Join-Path $LocalViliRoot "scripts\start-vili-daemon.ps1"),
|
||||
(Join-Path $LocalViliRoot "scripts\vili-daemon.mjs"),
|
||||
(Join-Path $LocalCultLibRoot "packages\cultnet-ts"),
|
||||
(Join-Path $LocalCultLibRoot "packages\cultcache-ts"),
|
||||
(Join-Path $LocalCultLibRoot "node_modules\ajv"),
|
||||
(Join-Path $LocalCultLibRoot "node_modules\fast-deep-equal"),
|
||||
(Join-Path $LocalCultLibRoot "node_modules\fast-uri"),
|
||||
(Join-Path $LocalCultLibRoot "node_modules\json-schema-traverse"),
|
||||
(Join-Path $LocalCultLibRoot "node_modules\require-from-string"),
|
||||
(Join-Path $LocalViliRoot "node_modules\@msgpack")
|
||||
)
|
||||
|
||||
foreach ($path in $localFiles) {
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
throw "Required Vili runtime path not found: $path"
|
||||
}
|
||||
}
|
||||
|
||||
$syncId = [guid]::NewGuid().ToString("N")
|
||||
$remoteSyncRootPs = "$RemoteViliRoot\.odin-vili-sync-$syncId"
|
||||
$remoteSyncRootSftp = ($remoteSyncRootPs -replace "\\", "/")
|
||||
$remoteTempPs = "C:\Windows\Temp\odin-raven-vili-restart-$syncId.ps1"
|
||||
$remoteTempSftp = ($remoteTempPs -replace "\\", "/")
|
||||
$remoteBundlePs = "C:\Windows\Temp\odin-raven-vili-bundle-$syncId.zip"
|
||||
$remoteBundleSftp = ($remoteBundlePs -replace "\\", "/")
|
||||
$localRemoteScript = Join-Path $env:TEMP "odin-raven-vili-restart-$syncId.ps1"
|
||||
$localSftpBatch = Join-Path $env:TEMP "odin-raven-vili-restart-$syncId.sftp"
|
||||
$localBundleRoot = Join-Path $env:TEMP "odin-raven-vili-bundle-$syncId"
|
||||
$localBundleScripts = Join-Path $localBundleRoot "scripts"
|
||||
$localBundleNodeModules = Join-Path $localBundleRoot "node_modules"
|
||||
$localBundleZip = Join-Path $env:TEMP "odin-raven-vili-bundle-$syncId.zip"
|
||||
|
||||
$remoteScript = @"
|
||||
`$ErrorActionPreference = "Stop"
|
||||
`$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
`$remoteRoot = "$RemoteViliRoot"
|
||||
`$syncRoot = "$remoteSyncRootPs"
|
||||
`$scriptsRoot = Join-Path `$remoteRoot "scripts"
|
||||
`$stateRoot = "$StateRoot"
|
||||
`$nodeModulesRoot = Join-Path `$remoteRoot "node_modules"
|
||||
`$pidPath = Join-Path `$stateRoot "vili-daemon.pid"
|
||||
|
||||
try {
|
||||
Expand-Archive -LiteralPath "$remoteBundlePs" -DestinationPath `$syncRoot -Force
|
||||
if (-not (Test-Path -LiteralPath `$syncRoot)) {
|
||||
throw "Uploaded sync root not found at `$syncRoot"
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath `$pidPath) {
|
||||
`$existingPid = (Get-Content -LiteralPath `$pidPath -Raw -ErrorAction SilentlyContinue).Trim()
|
||||
if (`$existingPid -match '^\d+$') {
|
||||
`$process = Get-Process -Id ([int] `$existingPid) -ErrorAction SilentlyContinue
|
||||
if (`$null -ne `$process) {
|
||||
Stop-Process -Id `$process.Id -Force
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
}
|
||||
Remove-Item -LiteralPath `$pidPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path `$scriptsRoot | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path `$stateRoot | Out-Null
|
||||
|
||||
Copy-Item -LiteralPath (Join-Path `$syncRoot "package.json") -Destination (Join-Path `$remoteRoot "package.json") -Force
|
||||
Copy-Item -LiteralPath (Join-Path `$syncRoot "package-lock.json") -Destination (Join-Path `$remoteRoot "package-lock.json") -Force
|
||||
|
||||
`$scriptFiles = @(
|
||||
"idunn-rudp.cjs",
|
||||
"install-vili-task.ps1",
|
||||
"restart-vili-daemon.cmd",
|
||||
"restart-vili-daemon.ps1",
|
||||
"run-hidden-powershell.vbs",
|
||||
"run-vili-daemon.ps1",
|
||||
"start-vili-daemon.ps1",
|
||||
"vili-daemon.mjs"
|
||||
)
|
||||
|
||||
foreach (`$name in `$scriptFiles) {
|
||||
Copy-Item -LiteralPath (Join-Path `$syncRoot "scripts\`$name") -Destination (Join-Path `$scriptsRoot `$name) -Force
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath `$nodeModulesRoot) {
|
||||
Remove-Item -LiteralPath `$nodeModulesRoot -Recurse -Force
|
||||
}
|
||||
Move-Item -LiteralPath (Join-Path `$syncRoot "node_modules") -Destination `$nodeModulesRoot -Force
|
||||
|
||||
& powershell.exe @(
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-File", (Join-Path `$scriptsRoot "install-vili-task.ps1"),
|
||||
"-TaskName", "GameCult\Vili",
|
||||
"-Port", "$Port",
|
||||
"-HostName", "$HostName",
|
||||
"-StateRoot", "$StateRoot",
|
||||
"-IdunnRudpHealth", "$IdunnRudpHealth",
|
||||
"-IdunnDaemon", "$IdunnDaemon",
|
||||
"-IdunnHealthContract", "$IdunnHealthContract",
|
||||
"-IdunnHealthIntervalSeconds", "$IdunnHealthIntervalSeconds"
|
||||
)
|
||||
|
||||
& powershell.exe @(
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-File", (Join-Path `$scriptsRoot "restart-vili-daemon.ps1"),
|
||||
"-Port", "$Port",
|
||||
"-HostName", "$HostName",
|
||||
"-StateRoot", "$StateRoot",
|
||||
"-IdunnRudpHealth", "$IdunnRudpHealth",
|
||||
"-IdunnDaemon", "$IdunnDaemon",
|
||||
"-IdunnHealthContract", "$IdunnHealthContract",
|
||||
"-IdunnHealthIntervalSeconds", "$IdunnHealthIntervalSeconds"
|
||||
)
|
||||
|
||||
`$task = Get-ScheduledTask -TaskName "Vili" -TaskPath "\GameCult\" -ErrorAction Stop
|
||||
`$action = @(`$task.Actions)[0]
|
||||
if (`$action.Execute -notmatch '(^|\\)wscript\.exe$') {
|
||||
throw "GameCult\Vili action executes `$(`$action.Execute), expected wscript.exe"
|
||||
}
|
||||
if (`$action.Arguments -notlike "*run-hidden-powershell.vbs*") {
|
||||
throw "GameCult\Vili action arguments do not reference run-hidden-powershell.vbs"
|
||||
}
|
||||
`$taskHealthChecks = @(
|
||||
(`$action.Arguments -like "*-IdunnRudpHealth*"),
|
||||
(`$action.Arguments -like "*$IdunnRudpHealth*"),
|
||||
(`$action.Arguments -like "*-IdunnDaemon*"),
|
||||
(`$action.Arguments -like "*$IdunnDaemon*"),
|
||||
(`$action.Arguments -like "*-IdunnHealthContract*"),
|
||||
(`$action.Arguments -like "*$IdunnHealthContract*")
|
||||
)
|
||||
if (`$taskHealthChecks -contains `$false) {
|
||||
throw "GameCult\Vili action arguments do not carry the Idunn RUDP health contract."
|
||||
}
|
||||
|
||||
`$process = Get-CimInstance Win32_Process |
|
||||
Where-Object { `$_.CommandLine -like '*vili-daemon.mjs*' } |
|
||||
Select-Object -First 1
|
||||
if (`$null -eq `$process) {
|
||||
throw "Vili process is not running after restart."
|
||||
}
|
||||
`$processHealthChecks = @(
|
||||
(`$process.CommandLine -like "*--idunn-rudp-health*"),
|
||||
(`$process.CommandLine -like "*$IdunnRudpHealth*"),
|
||||
(`$process.CommandLine -like "*--idunn-daemon*"),
|
||||
(`$process.CommandLine -like "*$IdunnDaemon*"),
|
||||
(`$process.CommandLine -like "*--idunn-health-contract*"),
|
||||
(`$process.CommandLine -like "*$IdunnHealthContract*")
|
||||
)
|
||||
if (`$processHealthChecks -contains `$false) {
|
||||
throw "Vili process command line does not include the Idunn RUDP health arguments."
|
||||
}
|
||||
|
||||
`$operatorStatePath = Join-Path `$stateRoot "operator-state.json"
|
||||
`$deadline = (Get-Date).AddSeconds(30)
|
||||
`$state = `$null
|
||||
while ((Get-Date) -lt `$deadline) {
|
||||
if (Test-Path -LiteralPath `$operatorStatePath) {
|
||||
try {
|
||||
`$state = Get-Content -LiteralPath `$operatorStatePath -Raw | ConvertFrom-Json
|
||||
if (`$null -ne `$state.idunnRudpHealth -and `$state.idunnRudpHealth.status -eq "published") {
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
if (`$null -eq `$state -or `$null -eq `$state.idunnRudpHealth) {
|
||||
throw "Vili daemon state file did not report idunnRudpHealth after restart."
|
||||
}
|
||||
if (`$state.idunnRudpHealth.status -ne "published") {
|
||||
throw "Vili idunnRudpHealth status is `$(`$state.idunnRudpHealth.status): `$(`$state.idunnRudpHealth.error)"
|
||||
}
|
||||
|
||||
Write-Host "Vili Raven runtime repaired and restarted with Idunn RUDP health."
|
||||
Write-Host "ProcessId=`$(`$process.ProcessId)"
|
||||
Write-Host "TaskArguments=`$(`$action.Arguments)"
|
||||
Write-Host "HealthStatus=`$(`$state.idunnRudpHealth.status)"
|
||||
Write-Host "HealthContract=`$(`$state.idunnRudpHealth.contract)"
|
||||
} finally {
|
||||
Remove-Item -LiteralPath `$syncRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath "$remoteBundlePs" -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
"@
|
||||
|
||||
$batchLines = @(
|
||||
"put ""$localBundleZip"" ""$remoteBundleSftp""",
|
||||
"put ""$localRemoteScript"" ""$remoteTempSftp"""
|
||||
)
|
||||
|
||||
try {
|
||||
$moduleCopies = @(
|
||||
@{ Source = (Join-Path $LocalCultLibRoot "packages\cultnet-ts"); Destination = (Join-Path $localBundleNodeModules "cultnet-ts") },
|
||||
@{ Source = (Join-Path $LocalCultLibRoot "packages\cultcache-ts"); Destination = (Join-Path $localBundleNodeModules "cultcache-ts") },
|
||||
@{ Source = (Join-Path $LocalCultLibRoot "node_modules\ajv"); Destination = (Join-Path $localBundleNodeModules "ajv") },
|
||||
@{ Source = (Join-Path $LocalCultLibRoot "node_modules\fast-deep-equal"); Destination = (Join-Path $localBundleNodeModules "fast-deep-equal") },
|
||||
@{ Source = (Join-Path $LocalCultLibRoot "node_modules\fast-uri"); Destination = (Join-Path $localBundleNodeModules "fast-uri") },
|
||||
@{ Source = (Join-Path $LocalCultLibRoot "node_modules\json-schema-traverse"); Destination = (Join-Path $localBundleNodeModules "json-schema-traverse") },
|
||||
@{ Source = (Join-Path $LocalCultLibRoot "node_modules\require-from-string"); Destination = (Join-Path $localBundleNodeModules "require-from-string") },
|
||||
@{ Source = (Join-Path $LocalViliRoot "node_modules\@msgpack"); Destination = (Join-Path $localBundleNodeModules "@msgpack") }
|
||||
)
|
||||
New-Item -ItemType Directory -Force -Path $localBundleScripts, $localBundleNodeModules | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $LocalViliRoot "package.json") -Destination (Join-Path $localBundleRoot "package.json") -Force
|
||||
Copy-Item -LiteralPath (Join-Path $LocalViliRoot "package-lock.json") -Destination (Join-Path $localBundleRoot "package-lock.json") -Force
|
||||
$scriptCopies = @(
|
||||
"idunn-rudp.cjs",
|
||||
"install-vili-task.ps1",
|
||||
"restart-vili-daemon.cmd",
|
||||
"restart-vili-daemon.ps1",
|
||||
"run-hidden-powershell.vbs",
|
||||
"run-vili-daemon.ps1",
|
||||
"start-vili-daemon.ps1",
|
||||
"vili-daemon.mjs"
|
||||
)
|
||||
foreach ($scriptName in $scriptCopies) {
|
||||
Copy-Item -LiteralPath (Join-Path $LocalViliRoot "scripts\$scriptName") -Destination (Join-Path $localBundleScripts $scriptName) -Force
|
||||
}
|
||||
foreach ($copy in $moduleCopies) {
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $copy.Destination) | Out-Null
|
||||
Copy-Item -LiteralPath $copy.Source -Destination $copy.Destination -Recurse -Force
|
||||
}
|
||||
if (Test-Path -LiteralPath $localBundleZip) {
|
||||
Remove-Item -LiteralPath $localBundleZip -Force
|
||||
}
|
||||
Compress-Archive -Path (Join-Path $localBundleRoot "*") -DestinationPath $localBundleZip -CompressionLevel Optimal
|
||||
Set-Content -LiteralPath $localRemoteScript -Encoding ASCII -Value $remoteScript
|
||||
Set-Content -LiteralPath $localSftpBatch -Encoding ASCII -Value ($batchLines -join "`r`n")
|
||||
& sftp.exe -b $localSftpBatch $RavenHost
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
$remoteRunner = @"
|
||||
`$ErrorActionPreference = "Stop"
|
||||
try {
|
||||
& "$remoteTempPs"
|
||||
`$code = `$LASTEXITCODE
|
||||
} finally {
|
||||
Remove-Item -LiteralPath "$remoteTempPs" -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
exit `$code
|
||||
"@
|
||||
$encodedRunner = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteRunner))
|
||||
& ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $RavenHost "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encodedRunner"
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $localRemoteScript, $localSftpBatch -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $localBundleZip -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $localBundleRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$restartExit = $LASTEXITCODE
|
||||
exit $restartExit
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
@echo off
|
||||
if not "%IDUNN_ACTUATOR%"=="1" (
|
||||
echo restart-weksa.cmd is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" (
|
||||
echo restart-weksa.cmd is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
call %~dp0..\..\weksa\scripts\restart-weksa-daemon.cmd
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
param(
|
||||
[string] $StateDir = (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\odin"),
|
||||
[string] $CultNetRudpBind = "0.0.0.0:17871",
|
||||
[string] $IdunnRudpHealth = $(if ($env:ODIN_IDUNN_RUDP_HEALTH) { $env:ODIN_IDUNN_RUDP_HEALTH } else { $env:IDUNN_RUDP_HEALTH }),
|
||||
[string] $IdunnDaemon = "odin",
|
||||
[string] $IdunnHealthContract = "odin.cultnet-rudp-provider-health",
|
||||
[switch] $Foreground
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$scriptPath = Join-Path $repoRoot "src\odin-coordinator.cjs"
|
||||
$pidPath = Join-Path $StateDir "odin.pid"
|
||||
$outLog = Join-Path $StateDir "odin.out.log"
|
||||
$errLog = Join-Path $StateDir "odin.err.log"
|
||||
$nodeExe = (Get-Command node.exe -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source)
|
||||
if (-not $nodeExe) {
|
||||
$nodeExe = "C:\Program Files\nodejs\node.exe"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $nodeExe)) {
|
||||
throw "Node.js executable is missing; checked PATH and $nodeExe"
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($IdunnRudpHealth)) {
|
||||
throw "Odin Idunn health publication requires -IdunnRudpHealth, ODIN_IDUNN_RUDP_HEALTH, or IDUNN_RUDP_HEALTH; no localhost default is assumed."
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $StateDir | Out-Null
|
||||
|
||||
if (Test-Path $pidPath) {
|
||||
$oldPid = (Get-Content -Raw $pidPath).Trim()
|
||||
if ($oldPid -match "^\d+$") {
|
||||
$old = Get-Process -Id ([int] $oldPid) -ErrorAction SilentlyContinue
|
||||
if ($old) {
|
||||
throw "Odin already appears to be running as PID $oldPid."
|
||||
}
|
||||
}
|
||||
Remove-Item -LiteralPath $pidPath -Force
|
||||
}
|
||||
|
||||
$env:NODE_PATH = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "CultLib\packages")
|
||||
$args = @(
|
||||
$scriptPath,
|
||||
"--stateDir", $StateDir,
|
||||
"--cultnet-rudp-bind", $CultNetRudpBind,
|
||||
"--idunn-rudp-health", $IdunnRudpHealth,
|
||||
"--idunn-daemon", $IdunnDaemon,
|
||||
"--idunn-health-contract", $IdunnHealthContract
|
||||
)
|
||||
|
||||
if ($Foreground) {
|
||||
& $nodeExe @args
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
$proc = Start-Process -FilePath $nodeExe -ArgumentList $args -WorkingDirectory $repoRoot -WindowStyle Hidden -PassThru -RedirectStandardOutput $outLog -RedirectStandardError $errLog
|
||||
$proc.Id | Set-Content -Encoding ASCII -LiteralPath $pidPath
|
||||
Start-Sleep -Seconds 1
|
||||
if ($proc.HasExited) {
|
||||
$detail = ""
|
||||
if (Test-Path $outLog) { $detail += Get-Content -Raw $outLog }
|
||||
if (Test-Path $errLog) { $detail += Get-Content -Raw $errLog }
|
||||
throw "Odin exited immediately with code $($proc.ExitCode).`n$detail"
|
||||
}
|
||||
|
||||
Write-Host "Odin started as PID $($proc.Id)."
|
||||
Write-Host "CultMesh/RUDP document catalog: $CultNetRudpBind"
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
param(
|
||||
[string] $StateDir = (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\odin")
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$pidPath = Join-Path $StateDir "odin.pid"
|
||||
if (-not (Test-Path $pidPath)) {
|
||||
Write-Host "No Odin PID file found at $pidPath."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$odinPid = (Get-Content -Raw $pidPath).Trim()
|
||||
if ($odinPid -match "^\d+$") {
|
||||
$process = Get-Process -Id ([int] $odinPid) -ErrorAction SilentlyContinue
|
||||
if ($process) {
|
||||
Stop-Process -Id $process.Id
|
||||
Write-Host "Stopped Odin PID $odinPid."
|
||||
}
|
||||
}
|
||||
|
||||
Remove-Item -LiteralPath $pidPath -Force
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const { buildConfig, loadCultRuntime } = require("./odin/config.cjs");
|
||||
const { createCultMeshRudpDocumentServer } = require("./odin/cultnet-rudp.cjs");
|
||||
const { defineOdinDocuments } = require("./odin/documents.cjs");
|
||||
const { createInterfaceDiscovery } = require("./odin/interfaces.cjs");
|
||||
const { createIdunnRudpHealthPublisher, publishIdunnRudpHealth } = require("./odin/idunn-rudp.cjs");
|
||||
const { createLayoutStore } = require("./odin/layout.cjs");
|
||||
const { createLiveProviderRegistry } = require("./odin/provider-ingress.cjs");
|
||||
const { createStateBuilder } = require("./odin/state.cjs");
|
||||
|
||||
const config = buildConfig(process.argv.slice(2));
|
||||
fs.mkdirSync(config.stateDir, { recursive: true });
|
||||
const idunnRudpHealthPublisher = createIdunnRudpHealthPublisher(config.idunnRudpHealth);
|
||||
|
||||
const cultRuntime = loadCultRuntime();
|
||||
if (cultRuntime.error) {
|
||||
console.error("CultMesh runtime unavailable; durable mesh catalog disabled:", cultRuntime.error.message);
|
||||
}
|
||||
|
||||
const documents = defineOdinDocuments(cultRuntime.defineDocumentType);
|
||||
const allDocumentDefinitions = Object.values(documents).filter(Boolean);
|
||||
const layoutStore = createLayoutStore({
|
||||
definition: documents.interfaceLayoutDefinition,
|
||||
getNode: async () => meshNodePromise ? await meshNodePromise : null,
|
||||
layoutPath: config.layoutPath,
|
||||
});
|
||||
const liveProviderRegistry = createLiveProviderRegistry();
|
||||
const interfaceDiscovery = createInterfaceDiscovery({
|
||||
CultMesh: cultRuntime.CultMesh,
|
||||
documents,
|
||||
interfaceBindingStores: config.interfaceBindingStores,
|
||||
liveProviderRegistry,
|
||||
});
|
||||
const stateBuilder = createStateBuilder({
|
||||
cachePath: config.cachePath,
|
||||
gamecultTextDocumentStorePath: config.gamecultTextDocumentStorePath,
|
||||
interfaceDiscovery,
|
||||
layoutStore,
|
||||
stonksBurstSize: config.stonksBurstSize,
|
||||
});
|
||||
|
||||
let meshNodePromise = null;
|
||||
let cultMeshRudpDocumentServer = null;
|
||||
let currentState = stateBuilder.buildPendingState("Coordinator starting");
|
||||
let lastRefresh = {
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
error: null,
|
||||
startedAt: null,
|
||||
};
|
||||
let lastIdunnRudpHealthPublishedAt = null;
|
||||
let observedRudpDocuments = 0;
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
async function main() {
|
||||
if (cultRuntime.CultMesh && documents.surfaceDefinition) {
|
||||
meshNodePromise = createDurableSurfaceNode();
|
||||
}
|
||||
if (config.cultnetRudpBind) {
|
||||
cultMeshRudpDocumentServer = createCultMeshRudpDocumentServer({
|
||||
CultMesh: cultRuntime.CultMesh,
|
||||
bind: config.cultnetRudpBind,
|
||||
documents,
|
||||
getCache: async () => {
|
||||
if (!meshNodePromise) {
|
||||
throw new Error("CultMesh runtime is unavailable; Odin cannot serve a CultMesh/RUDP document catalog.");
|
||||
}
|
||||
return (await meshNodePromise).cache;
|
||||
},
|
||||
onDocumentPutRaw: async (document) => {
|
||||
if (observedRudpDocuments < 40) {
|
||||
observedRudpDocuments += 1;
|
||||
console.error(
|
||||
`Odin RUDP document #${observedRudpDocuments} schema=${document.schemaId} key=${document.recordKey} source=${document.sourceRuntimeId || "unknown"} remote=${document.remote?.address}:${document.remote?.port}`,
|
||||
);
|
||||
}
|
||||
liveProviderRegistry.ingestDocument(document, document.remote);
|
||||
await persistRudpDocumentPut(document);
|
||||
},
|
||||
});
|
||||
await cultMeshRudpDocumentServer.start();
|
||||
console.log(`CultMesh/RUDP document catalog: ${config.cultnetRudpBind}`);
|
||||
}
|
||||
|
||||
console.log(`Durable surface cache: ${config.cachePath}`);
|
||||
|
||||
await refresh();
|
||||
scheduleRefresh();
|
||||
}
|
||||
|
||||
async function persistRudpDocumentPut(document) {
|
||||
if (!meshNodePromise || !document?.schemaId || !document?.recordKey) return;
|
||||
const definition = documentDefinitionForSchema(document.schemaId);
|
||||
if (!definition) return;
|
||||
const node = await meshNodePromise;
|
||||
await node.put(definition, document.recordKey, normalizeRudpPayload(document.payload));
|
||||
}
|
||||
|
||||
function documentDefinitionForSchema(schemaId) {
|
||||
return allDocumentDefinitions.find((definition) =>
|
||||
definition?.schemaId === schemaId
|
||||
|| definition?.schemaVersion === schemaId
|
||||
|| definition?.schemaName === schemaId
|
||||
|| definition?.type === schemaId
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRudpPayload(payload) {
|
||||
if (Array.isArray(payload) && payload.length === 1 && payload[0] && typeof payload[0] === "object" && !Array.isArray(payload[0])) {
|
||||
return payload[0];
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
setTimeout(() => {
|
||||
refresh()
|
||||
.catch((error) => console.error("refresh failed:", error))
|
||||
.finally(() => scheduleRefresh());
|
||||
}, config.intervalMs);
|
||||
}
|
||||
|
||||
async function createDurableSurfaceNode() {
|
||||
try {
|
||||
return await cultRuntime.CultMesh.createNode(config.cachePath, { documents: allDocumentDefinitions });
|
||||
} catch (error) {
|
||||
if (!fs.existsSync(config.cachePath)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const corruptPath = `${config.cachePath}.corrupt-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||
fs.renameSync(config.cachePath, corruptPath);
|
||||
console.error(`CultMesh surface cache was unreadable and has been quarantined: ${corruptPath}`);
|
||||
console.error(`CultMesh surface cache read error: ${error.message}`);
|
||||
return cultRuntime.CultMesh.createNode(config.cachePath, { documents: allDocumentDefinitions });
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const started = Date.now();
|
||||
lastRefresh = {
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
error: null,
|
||||
startedAt: new Date(started).toISOString(),
|
||||
};
|
||||
try {
|
||||
currentState = await stateBuilder.buildState();
|
||||
await persistState(currentState);
|
||||
lastRefresh = {
|
||||
completedAt: new Date().toISOString(),
|
||||
durationMs: Date.now() - started,
|
||||
error: null,
|
||||
startedAt: new Date(started).toISOString(),
|
||||
};
|
||||
await publishOdinHealth("active", `Odin provider catalog refreshed in ${lastRefresh.durationMs}ms`);
|
||||
} catch (error) {
|
||||
lastRefresh = {
|
||||
completedAt: new Date().toISOString(),
|
||||
durationMs: Date.now() - started,
|
||||
error: error.message,
|
||||
startedAt: new Date(started).toISOString(),
|
||||
};
|
||||
await publishOdinHealth("failed", `Odin provider refresh failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function publishOdinHealth(state, detail) {
|
||||
if (!idunnRudpHealthPublisher) return;
|
||||
try {
|
||||
await publishIdunnRudpHealth(idunnRudpHealthPublisher, {
|
||||
state,
|
||||
detail,
|
||||
observedAt: new Date().toISOString(),
|
||||
});
|
||||
lastIdunnRudpHealthPublishedAt = Date.now();
|
||||
} catch (error) {
|
||||
const lastPublishedAgeMs = lastIdunnRudpHealthPublishedAt === null
|
||||
? Number.POSITIVE_INFINITY
|
||||
: Date.now() - lastIdunnRudpHealthPublishedAt;
|
||||
if (lastPublishedAgeMs > Math.max(60_000, config.intervalMs * 4)) {
|
||||
console.error("Idunn RUDP health publish failed:", error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function persistState(state) {
|
||||
if (config.writeDebugSurfaceJson) {
|
||||
fs.writeFileSync(path.join(config.stateDir, "latest-surface.json"), JSON.stringify(state, null, 2), "utf8");
|
||||
}
|
||||
if (!meshNodePromise || !documents.surfaceDefinition) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const node = await meshNodePromise;
|
||||
await node.put(documents.surfaceDefinition, config.surfaceKey, state);
|
||||
await node.flush?.(true);
|
||||
} catch (error) {
|
||||
console.error("CultMesh snapshot write failed:", error.message);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const Module = require("module");
|
||||
const path = require("path");
|
||||
const { parseArgs } = require("./utils.cjs");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..", "..");
|
||||
|
||||
process.env.NODE_PATH = [
|
||||
path.resolve(repoRoot, "..", "CultLib", "packages"),
|
||||
process.env.NODE_PATH || "",
|
||||
].filter(Boolean).join(path.delimiter);
|
||||
Module._initPaths();
|
||||
|
||||
function buildConfig(argv) {
|
||||
const args = parseArgs(argv);
|
||||
const cultnetRudpBind = args["cultnet-rudp-bind"] ? String(args["cultnet-rudp-bind"]) : "";
|
||||
const intervalMs = Number(args.intervalMs || 5000);
|
||||
const stateDir = args.stateDir || path.join(repoRoot, "scratch", "odin");
|
||||
const cachePath = args.cachePath || path.join(stateDir, "odin.ccmp");
|
||||
const layoutPath = args.layoutPath || path.join(stateDir, "interface-layout.json");
|
||||
const writeDebugSurfaceJson = Boolean(args.writeDebugSurfaceJson || args["write-debug-surface-json"] || process.env.ODIN_WRITE_DEBUG_SURFACE_JSON === "1");
|
||||
if (args.observationLogPath || args.observationFreshSeconds) {
|
||||
throw new Error("Odin no longer tails Mimir observation logs. Publish observation records through CultMesh/Odin provider discovery instead.");
|
||||
}
|
||||
const gamecultTextDocumentStorePath = args.gamecultTextDocumentStorePath || path.join(repoRoot, "..", "VoidBot", ".voidbot", "private", "gamecult-text-documents.cc");
|
||||
const stonksBurstSize = Math.max(1, Number(args.stonksBurstSize || 8));
|
||||
const idunnRudpHealth = args["idunn-rudp-health"] ? {
|
||||
endpoint: String(args["idunn-rudp-health"]),
|
||||
daemonId: String(args["idunn-daemon"] || "odin"),
|
||||
healthContract: String(args["idunn-health-contract"] || "odin.cultnet-rudp-provider-health"),
|
||||
privateKeyPath: args["idunn-health-private-key"]
|
||||
? String(args["idunn-health-private-key"])
|
||||
: "",
|
||||
sourceRuntimeId: String(args["idunn-source-runtime"] || "odin-coordinator"),
|
||||
} : null;
|
||||
const interfaceBindingStores = String(
|
||||
args.interfaceBindingStore ||
|
||||
process.env.ODIN_INTERFACE_BINDING_STORES ||
|
||||
"",
|
||||
)
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
args,
|
||||
cachePath,
|
||||
cultnetRudpBind,
|
||||
gamecultTextDocumentStorePath,
|
||||
interfaceBindingStores,
|
||||
idunnRudpHealth,
|
||||
intervalMs,
|
||||
layoutPath,
|
||||
repoRoot,
|
||||
stateDir,
|
||||
stonksBurstSize,
|
||||
surfaceKey: "surface:gamecult.network.status",
|
||||
writeDebugSurfaceJson,
|
||||
};
|
||||
}
|
||||
|
||||
function loadCultRuntime() {
|
||||
try {
|
||||
const { CultMesh } = require("cultmesh-ts");
|
||||
const { defineDocumentType } = require("cultcache-ts");
|
||||
return { CultMesh, defineDocumentType, error: null };
|
||||
} catch (error) {
|
||||
return { CultMesh: null, defineDocumentType: null, error };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { buildConfig, loadCultRuntime, repoRoot };
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const path = require("path");
|
||||
const { createRequire } = require("module");
|
||||
const { createSocket } = require("dgram");
|
||||
|
||||
const requireCultNet = createRequire(path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"CultLib",
|
||||
"packages",
|
||||
"cultnet-ts",
|
||||
"package.json",
|
||||
));
|
||||
|
||||
const {
|
||||
CultNetDocumentRegistry,
|
||||
decodeRudpPacket,
|
||||
defineCultNetDocumentBinding,
|
||||
} = requireCultNet("./dist/index.js");
|
||||
|
||||
const ODIN_RUDP_CONNECTION_ID = 0x0d1d0002;
|
||||
|
||||
function createCultMeshRudpDocumentServer(options) {
|
||||
if (!options?.bind) return null;
|
||||
if (!options.CultMesh?.createRudpDocumentServer) {
|
||||
throw new Error("Odin requires a CultMesh runtime with createRudpDocumentServer.");
|
||||
}
|
||||
if (!options.documents?.surfaceDefinition) {
|
||||
throw new Error("Odin CultMesh/RUDP document server requires a surface document definition.");
|
||||
}
|
||||
if (typeof options.getCache !== "function") {
|
||||
throw new Error("Odin CultMesh/RUDP document server requires an async getCache function.");
|
||||
}
|
||||
|
||||
const bind = parseEndpoint(options.bind);
|
||||
const socket = createSocket(bind.host.includes(":") ? "udp6" : "udp4");
|
||||
let observedPackets = 0;
|
||||
socket.on("message", (wire, remote) => {
|
||||
if (observedPackets >= 20) return;
|
||||
observedPackets += 1;
|
||||
try {
|
||||
const packet = decodeRudpPacket(wire);
|
||||
console.error(
|
||||
`Odin RUDP observed packet #${observedPackets} ${packet.packetType} connection=${packet.connectionId.toString(16)} channel=${packet.channelId} from ${remote.address}:${remote.port} bytes=${wire.length}`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(
|
||||
`Odin RUDP observed undecodable packet #${observedPackets} from ${remote.address}:${remote.port} bytes=${wire.length}: ${message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
const registry = new CultNetDocumentRegistry(
|
||||
Object.values(options.documents)
|
||||
.filter(Boolean)
|
||||
.map((definition) => defineCultNetDocumentBinding({ definition })),
|
||||
);
|
||||
const server = options.CultMesh.createRudpDocumentServer(
|
||||
"odin-cultmesh-catalog",
|
||||
ODIN_RUDP_CONNECTION_ID,
|
||||
{
|
||||
bindHost: bind.host,
|
||||
bindPort: bind.port,
|
||||
documents: registry,
|
||||
socket,
|
||||
getCache: options.getCache,
|
||||
onError: (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (typeof options.logError === "function") {
|
||||
options.logError(`CultMesh/RUDP document server error: ${message}`);
|
||||
return;
|
||||
}
|
||||
console.error(`CultMesh/RUDP document server error: ${message}`);
|
||||
},
|
||||
onDocumentPutRaw: (document) => {
|
||||
if (typeof options.onDocumentPutRaw === "function") {
|
||||
return options.onDocumentPutRaw(document);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
bind,
|
||||
start: () => server.start(),
|
||||
close: () => server.close(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseEndpoint(value) {
|
||||
const text = String(value || "").trim();
|
||||
const ipv6 = text.match(/^\[([^\]]+)\]:(\d+)$/);
|
||||
if (ipv6) return { host: ipv6[1], port: parsePort(ipv6[2]) };
|
||||
const index = text.lastIndexOf(":");
|
||||
if (index <= 0) {
|
||||
throw new Error(`CultMesh/RUDP bind must be host:port, got "${value}".`);
|
||||
}
|
||||
return { host: text.slice(0, index), port: parsePort(text.slice(index + 1)) };
|
||||
}
|
||||
|
||||
function parsePort(value) {
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
throw new Error(`CultMesh/RUDP port is invalid: ${value}`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createCultMeshRudpDocumentServer,
|
||||
};
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
// The swarm document-type catalog, as Odin consumes it.
|
||||
//
|
||||
// The 45 definitions used to live here. Almost none of them are Odin's: they
|
||||
// describe documents that cross service boundaries, and every service that
|
||||
// decodes another's documents needs the same ones. They lived here because Odin
|
||||
// was written first. They now live in CultLib as cultcache-ts's
|
||||
// defineSwarmDocuments, beside the defineDocumentType that builds them, so
|
||||
// Hermodr can leave without forking a second definition of one wire format.
|
||||
|
||||
const path = require("path");
|
||||
const { createRequire } = require("module");
|
||||
|
||||
const cultLibRoot = process.env.CULTLIB_ROOT
|
||||
? path.resolve(process.env.CULTLIB_ROOT)
|
||||
: path.resolve(__dirname, "..", "..", "..", "CultLib");
|
||||
|
||||
const requireCultCache = createRequire(path.resolve(cultLibRoot, "packages", "cultcache-ts", "package.json"));
|
||||
|
||||
const { defineSwarmDocuments } = requireCultCache("./dist/index.js");
|
||||
|
||||
// Odin's consumers know this name. The catalog is not Odin's, but the call is.
|
||||
const defineOdinDocuments = defineSwarmDocuments;
|
||||
|
||||
module.exports = { defineOdinDocuments, defineSwarmDocuments };
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
// Idunn health publication, as Odin consumes it.
|
||||
//
|
||||
// The transport used to live here: 300-odd lines of socket handling, RUDP
|
||||
// handshaking, Ed25519 signing, and packet receipt, of which almost nothing was
|
||||
// about Idunn. That is generic CultNet client work and now lives in CultLib as
|
||||
// cultnet-ts's signed-daemon-health module.
|
||||
//
|
||||
// What remains is what Odin actually needs to know: Idunn's wire contract, and
|
||||
// the shape of the calls its consumers already make. The constants are defined
|
||||
// by Idunn in docs/signed-daemon-health-authority.md. They are declared here,
|
||||
// not decided here; if they change, that document changes first.
|
||||
|
||||
const path = require("path");
|
||||
const { createRequire } = require("module");
|
||||
|
||||
const cultLibRoot = process.env.CULTLIB_ROOT
|
||||
? path.resolve(process.env.CULTLIB_ROOT)
|
||||
: path.resolve(__dirname, "..", "..", "..", "CultLib");
|
||||
|
||||
const requireCultNet = createRequire(path.resolve(cultLibRoot, "packages", "cultnet-ts", "package.json"));
|
||||
|
||||
const {
|
||||
CULTNET_RUDP_PROTOCOL_ID,
|
||||
createSignedDaemonHealthPublisher,
|
||||
publishSignedDaemonHealth,
|
||||
signedDaemonHealthPayload,
|
||||
signedDaemonHealthSigningMessage,
|
||||
} = requireCultNet("./dist/index.js");
|
||||
|
||||
// Idunn's contract. Owner: GameCult/Idunn, docs/signed-daemon-health-authority.md.
|
||||
const IDUNN_HEALTH_CONTRACT = Object.freeze({
|
||||
connectionId: 0x1d0d0001,
|
||||
signedSchemaId: "idunn.signed_daemon_health.v1",
|
||||
unsignedSchemaId: "idunn.daemon_health",
|
||||
messageIdPrefix: "odin-health",
|
||||
});
|
||||
|
||||
function createIdunnRudpHealthPublisher(options) {
|
||||
if (!options) return null;
|
||||
return createSignedDaemonHealthPublisher({
|
||||
sourceRuntimeId: "odin-coordinator",
|
||||
...options,
|
||||
contract: IDUNN_HEALTH_CONTRACT,
|
||||
});
|
||||
}
|
||||
|
||||
const publishIdunnRudpHealth = publishSignedDaemonHealth;
|
||||
const signedHealthPayload = signedDaemonHealthPayload;
|
||||
|
||||
// The signing purpose is Idunn's signed schema id, so a statement signed for
|
||||
// Idunn cannot verify as another service's health.
|
||||
function signingMessage(payload) {
|
||||
return signedDaemonHealthSigningMessage(IDUNN_HEALTH_CONTRACT.signedSchemaId, payload);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CULTNET_RUDP_PROTOCOL_ID,
|
||||
IDUNN_HEALTH_CONTRACT,
|
||||
createIdunnRudpHealthPublisher,
|
||||
publishIdunnRudpHealth,
|
||||
signedHealthPayload,
|
||||
signingMessage,
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,279 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const { clampNumber, positiveNumber, stableId } = require("./utils.cjs");
|
||||
|
||||
const DEFAULT_LAYOUT_ID = "odin.providers";
|
||||
|
||||
function createLayoutStore({ definition, getNode, layoutPath } = {}) {
|
||||
let cachedLayout = null;
|
||||
let attemptedMigration = false;
|
||||
|
||||
return {
|
||||
async readLayout() {
|
||||
const meshLayout = await readCultMeshLayout();
|
||||
if (meshLayout) return meshLayout;
|
||||
if (!attemptedMigration) {
|
||||
attemptedMigration = true;
|
||||
const migrated = readJsonMigrationLayout(layoutPath);
|
||||
if (migrated) {
|
||||
cachedLayout = migrated;
|
||||
await this.writeLayout(migrated).catch(() => undefined);
|
||||
return normalizeLayout(migrated);
|
||||
}
|
||||
}
|
||||
return cachedLayout || defaultLayout();
|
||||
},
|
||||
|
||||
async writeLayout(layout) {
|
||||
const normalized = normalizeLayout(layout);
|
||||
cachedLayout = normalized;
|
||||
const node = await layoutNode();
|
||||
if (!node || !definition) {
|
||||
throw new Error("Odin CultMesh layout store unavailable; refusing to write layout sidecar fallback.");
|
||||
}
|
||||
await node.put(definition, DEFAULT_LAYOUT_ID, normalized);
|
||||
await node.flush?.(true);
|
||||
},
|
||||
|
||||
async applyClientCommand(command) {
|
||||
if (!command || command.type !== "odin-layout-intent" || !command.providerId) return;
|
||||
const layout = await this.readLayout();
|
||||
layout.updatedAt = new Date().toISOString();
|
||||
layout.tiles ||= {};
|
||||
const current = layout.tiles[command.providerId] || defaultLayoutFor({ providerId: command.providerId }, []);
|
||||
const next = { ...current };
|
||||
if (command.action === "focus") {
|
||||
Object.assign(next, fullscreenLayoutIntent(command.providerId, -100));
|
||||
} else if (command.action === "resize") {
|
||||
next.w = clampNumber((next.w || 1) + Number(command.dw || 0), 1, 4);
|
||||
next.h = clampNumber((next.h || 1) + Number(command.dh || 0), 1, 4);
|
||||
} else if (command.action === "move") {
|
||||
next.x = clampNumber((next.x || 0) + Number(command.dx || 0), 0, 12);
|
||||
next.y = clampNumber((next.y || 0) + Number(command.dy || 0), 0, 12);
|
||||
} else if (command.action === "toggle") {
|
||||
next.visible = !next.visible;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
layout.tiles[command.providerId] = next;
|
||||
await this.writeLayout(layout);
|
||||
},
|
||||
};
|
||||
|
||||
async function readCultMeshLayout() {
|
||||
const node = await layoutNode();
|
||||
if (!node || !definition) return null;
|
||||
const record = await node.get(definition, DEFAULT_LAYOUT_ID);
|
||||
const normalized = record ? normalizeLayout(record) : null;
|
||||
if (normalized) cachedLayout = normalized;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function layoutNode() {
|
||||
return typeof getNode === "function" ? await getNode() : null;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLayout() {
|
||||
return { schema: "odin.interface_layout.v1", layoutId: DEFAULT_LAYOUT_ID, tiles: {} };
|
||||
}
|
||||
|
||||
function normalizeLayout(layout) {
|
||||
return {
|
||||
...defaultLayout(),
|
||||
...(layout && typeof layout === "object" ? layout : {}),
|
||||
schema: "odin.interface_layout.v1",
|
||||
layoutId: DEFAULT_LAYOUT_ID,
|
||||
tiles: layout && typeof layout.tiles === "object" && layout.tiles ? layout.tiles : {},
|
||||
};
|
||||
}
|
||||
|
||||
function readJsonMigrationLayout(layoutPath) {
|
||||
if (!layoutPath) return null;
|
||||
try {
|
||||
return normalizeLayout(JSON.parse(fs.readFileSync(layoutPath, "utf8")));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLayoutFor(entry, interfaces) {
|
||||
const index = Math.max(0, interfaces.findIndex((candidate) => candidate.providerId === entry.providerId));
|
||||
const intent = surfaceLayoutIntent(entry);
|
||||
const preferredHeight = positiveNumber(intent.preferredHeight, entry.providerId === "voidbot.swarm" ? 18 : 12);
|
||||
const minHeight = positiveNumber(intent.minHeight, 6);
|
||||
const preferredWidth = positiveNumber(intent.preferredWidth, 72);
|
||||
const minWidth = positiveNumber(intent.minWidth, 36);
|
||||
const priority = Number.isFinite(intent.priority) ? intent.priority : index;
|
||||
return {
|
||||
tileId: stableId(entry.providerId || "interface"),
|
||||
visible: true,
|
||||
priority,
|
||||
x: index % 2,
|
||||
y: Math.floor(index / 2),
|
||||
w: 1,
|
||||
h: Math.max(1, Math.ceil(preferredHeight / 12)),
|
||||
minWidth,
|
||||
minHeight,
|
||||
preferredWidth,
|
||||
preferredHeight,
|
||||
density: intent.density || "dense",
|
||||
viewportMode: intent.viewportMode || "nested-scroll",
|
||||
signalWeight: intent.signalWeight || 1,
|
||||
tree: intent.tree || null,
|
||||
};
|
||||
}
|
||||
|
||||
function fullscreenLayoutIntent(tileId, priority = -100) {
|
||||
return {
|
||||
tileId: stableId(tileId || "fullscreen"),
|
||||
visible: true,
|
||||
priority,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 4,
|
||||
h: 4,
|
||||
minWidth: 96,
|
||||
minHeight: 36,
|
||||
preferredWidth: 192,
|
||||
preferredHeight: 48,
|
||||
density: "comfortable",
|
||||
viewportMode: "fullscreen",
|
||||
};
|
||||
}
|
||||
|
||||
function mergeLayoutIntent(existing, entry, interfaces) {
|
||||
const base = existing && typeof existing === "object"
|
||||
? { ...existing }
|
||||
: defaultLayoutFor(entry, interfaces);
|
||||
const intent = surfaceLayoutIntent(entry);
|
||||
const fallback = defaultLayoutFor(entry, interfaces);
|
||||
const denseTree = isDenseLayoutStrategy(intent.layoutStrategy);
|
||||
const minWidth = positiveNumber(intent.minWidth, positiveNumber(base.minWidth, fallback.minWidth));
|
||||
const minHeight = positiveNumber(intent.minHeight, positiveNumber(base.minHeight, fallback.minHeight));
|
||||
const preferredWidth = positiveNumber(intent.preferredWidth, positiveNumber(base.preferredWidth, fallback.preferredWidth));
|
||||
const preferredHeight = positiveNumber(intent.preferredHeight, positiveNumber(base.preferredHeight, fallback.preferredHeight));
|
||||
const desiredW = Math.max(1, Math.min(4, Math.ceil(preferredWidth / 72)));
|
||||
const desiredH = Math.max(1, Math.min(4, Math.ceil(preferredHeight / 12)));
|
||||
return {
|
||||
...base,
|
||||
minWidth,
|
||||
minHeight,
|
||||
preferredWidth,
|
||||
preferredHeight,
|
||||
h: denseTree && intent.viewportMode !== "fullscreen" ? desiredH : Math.max(positiveNumber(base.h, fallback.h), desiredH),
|
||||
w: denseTree && intent.viewportMode !== "fullscreen" ? desiredW : Math.max(positiveNumber(base.w, fallback.w), desiredW),
|
||||
priority: Number.isFinite(intent.priority) ? Math.min(Number(base.priority ?? intent.priority), intent.priority) : base.priority,
|
||||
density: intent.density || base.density || fallback.density,
|
||||
viewportMode: intent.viewportMode || base.viewportMode || fallback.viewportMode,
|
||||
layoutStrategy: intent.layoutStrategy || base.layoutStrategy || fallback.layoutStrategy,
|
||||
signalWeight: intent.signalWeight || base.signalWeight || fallback.signalWeight || 1,
|
||||
tree: intent.tree || base.tree || fallback.tree || null,
|
||||
};
|
||||
}
|
||||
|
||||
function surfaceLayoutIntent(entry) {
|
||||
const root = entry?.surface?.root;
|
||||
if (!root || typeof root !== "object") return {};
|
||||
const layout = root.layout && typeof root.layout === "object" ? root.layout : {};
|
||||
const props = root.props && typeof root.props === "object" ? root.props : {};
|
||||
const propLayout = props.layout && typeof props.layout === "object" ? props.layout : {};
|
||||
const tree = analyzeElementTree(root);
|
||||
const derived = denseTreeLayoutIntent(tree);
|
||||
return normalizeDenseSurfaceIntent({ ...derived, ...propLayout, ...layout, tree }, derived);
|
||||
}
|
||||
|
||||
function analyzeElementTree(root) {
|
||||
const stats = {
|
||||
elementCount: 0,
|
||||
leafCount: 0,
|
||||
branchCount: 0,
|
||||
maxDepth: 0,
|
||||
textCells: 0,
|
||||
listLikeBranchCount: 0,
|
||||
kindCounts: {},
|
||||
};
|
||||
|
||||
visit(root, 1);
|
||||
stats.signalWeight = Math.max(1, stats.branchCount * 2 + stats.leafCount + Math.ceil(stats.textCells / 48));
|
||||
return stats;
|
||||
|
||||
function visit(element, depth) {
|
||||
if (!element || typeof element !== "object") return;
|
||||
stats.elementCount += 1;
|
||||
stats.maxDepth = Math.max(stats.maxDepth, depth);
|
||||
const kind = String(element.kind || "unknown");
|
||||
stats.kindCounts[kind] = (stats.kindCounts[kind] || 0) + 1;
|
||||
const text = textValue(element);
|
||||
stats.textCells += text.length + Math.max(0, text.split(/\r?\n/).length - 1) * 8;
|
||||
const children = Array.isArray(element.children) ? element.children.filter((child) => child && typeof child === "object") : [];
|
||||
if (!children.length) {
|
||||
stats.leafCount += 1;
|
||||
return;
|
||||
}
|
||||
stats.branchCount += 1;
|
||||
if (children.length >= 4 && children.every((child) => String(child.kind || "") === "text" && !(Array.isArray(child.children) && child.children.length))) {
|
||||
stats.listLikeBranchCount += 1;
|
||||
}
|
||||
for (const child of children) visit(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function denseTreeLayoutIntent(tree) {
|
||||
const signal = Math.max(1, tree.signalWeight || 1);
|
||||
const depthPressure = Math.max(0, tree.maxDepth - 2);
|
||||
const textPressure = Math.sqrt(Math.max(1, tree.textCells) / 18);
|
||||
const branchPressure = Math.sqrt(Math.max(1, tree.branchCount));
|
||||
return {
|
||||
minWidth: Math.min(72, 28 + depthPressure * 7),
|
||||
minHeight: Math.min(18, 5 + depthPressure * 2),
|
||||
preferredWidth: Math.min(144, 44 + Math.sqrt(signal) * 7 + depthPressure * 5),
|
||||
preferredHeight: Math.min(36, 8 + textPressure + branchPressure * 3 + tree.listLikeBranchCount * 2),
|
||||
density: "dense",
|
||||
viewportMode: "nested-scroll",
|
||||
layoutStrategy: "dense-tree",
|
||||
signalWeight: signal,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDenseSurfaceIntent(intent, derived) {
|
||||
const viewportMode = intent.viewportMode || derived.viewportMode;
|
||||
if (viewportMode === "fullscreen") {
|
||||
return intent;
|
||||
}
|
||||
return {
|
||||
...intent,
|
||||
minWidth: Math.min(72, positiveNumber(intent.minWidth, derived.minWidth)),
|
||||
minHeight: Math.min(18, positiveNumber(intent.minHeight, derived.minHeight)),
|
||||
preferredWidth: Math.min(144, positiveNumber(intent.preferredWidth, derived.preferredWidth)),
|
||||
preferredHeight: Math.min(36, positiveNumber(intent.preferredHeight, derived.preferredHeight)),
|
||||
density: intent.density || derived.density || "dense",
|
||||
viewportMode,
|
||||
layoutStrategy: intent.layoutStrategy || derived.layoutStrategy || "dense-tree",
|
||||
signalWeight: intent.signalWeight || derived.signalWeight || 1,
|
||||
};
|
||||
}
|
||||
|
||||
function isDenseLayoutStrategy(strategy) {
|
||||
const value = String(strategy || "");
|
||||
return value === "dense-tree" || value.startsWith("dense-");
|
||||
}
|
||||
|
||||
function textValue(element) {
|
||||
if (typeof element.text === "string") return element.text;
|
||||
const props = element.props && typeof element.props === "object" ? element.props : {};
|
||||
if (typeof props.text === "string") return props.text;
|
||||
if (typeof props.title === "string") return props.title;
|
||||
return "";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
analyzeElementTree,
|
||||
createLayoutStore,
|
||||
defaultLayoutFor,
|
||||
denseTreeLayoutIntent,
|
||||
fullscreenLayoutIntent,
|
||||
mergeLayoutIntent,
|
||||
surfaceLayoutIntent,
|
||||
};
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const {
|
||||
CultCache,
|
||||
SingleFileMessagePackBackingStore,
|
||||
defineDocumentRegistry,
|
||||
defineDocumentType,
|
||||
} = require("cultcache-ts");
|
||||
|
||||
const textDocumentSetDefinition = defineDocumentType({
|
||||
type: "gamecult.text_document_set",
|
||||
global: true,
|
||||
schema: {
|
||||
parse(value) {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error("gamecult.text_document_set must be an object");
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function buildMarqueeText({ interfaces, textDocumentStorePath, stonksBurstSize = 8 }) {
|
||||
const [securities, stanzas] = await Promise.all([
|
||||
Promise.resolve(stonksSegments({ interfaces })),
|
||||
poetryStanzas(textDocumentStorePath),
|
||||
]);
|
||||
return stanzaBurstTape(stanzas, securities, stonksBurstSize).join(" / ");
|
||||
}
|
||||
|
||||
function stonksSegments({ interfaces }) {
|
||||
return stonksSegmentsFromInterfaces(interfaces);
|
||||
}
|
||||
|
||||
function stonksSegmentsFromInterfaces(interfaces) {
|
||||
const stonks = interfaces.find((entry) => String(entry.providerId || "").toLowerCase() === "stonks.market");
|
||||
const explicit = String(stonks?.surface?.root?.props?.marqueeText || "").trim();
|
||||
return splitSegments(explicit).slice(0, 16);
|
||||
}
|
||||
|
||||
async function poetryStanzas(textDocumentStorePath) {
|
||||
if (!textDocumentStorePath || !fs.existsSync(textDocumentStorePath)) return [];
|
||||
try {
|
||||
const cache = CultCache.builder()
|
||||
.withRegistry(defineDocumentRegistry(textDocumentSetDefinition))
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(textDocumentStorePath))
|
||||
.build();
|
||||
await cache.pullAllBackingStores();
|
||||
const documentSet = cache.getGlobal(textDocumentSetDefinition);
|
||||
return array(documentSet?.documents).flatMap(documentStanzas);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function documentStanzas(document) {
|
||||
const stanzas = [];
|
||||
let stanza = [];
|
||||
for (const line of array(document?.lines)) {
|
||||
if (isStanzaMarker(line)) {
|
||||
pushStanza(stanzas, stanza);
|
||||
stanza = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = array(line).join(" / ").replace(/\s+/g, " ").trim();
|
||||
if (text) stanza.push(text);
|
||||
}
|
||||
pushStanza(stanzas, stanza);
|
||||
return stanzas;
|
||||
}
|
||||
|
||||
function pushStanza(stanzas, stanza) {
|
||||
if (stanza.length) stanzas.push(stanza.join(" / "));
|
||||
}
|
||||
|
||||
function stanzaBurstTape(stanzas, securities, burstSize) {
|
||||
const output = [];
|
||||
const stonkBurstSize = Math.max(1, Number(burstSize) || 8);
|
||||
const stanzaList = stanzas.length ? stanzas : [];
|
||||
const securityList = securities.length ? securities : [];
|
||||
const cycles = Math.max(stanzaList.length, securityList.length ? Math.ceil(securityList.length / stonkBurstSize) : 0);
|
||||
for (let index = 0; index < cycles; index += 1) {
|
||||
if (stanzaList.length) output.push(stanzaList[index % stanzaList.length]);
|
||||
for (let offset = 0; offset < stonkBurstSize && securityList.length; offset += 1) {
|
||||
output.push(securityList[(index * stonkBurstSize + offset) % securityList.length]);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function splitSegments(text) {
|
||||
return String(text || "")
|
||||
.split(/\s+\/\s+/)
|
||||
.map((segment) => segment.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isStanzaMarker(line) {
|
||||
const parts = array(line);
|
||||
return parts.length === 1 && String(parts[0]).trim().toLowerCase() === "[stanza]";
|
||||
}
|
||||
|
||||
function array(value) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildMarqueeText,
|
||||
};
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFile } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function dockerSnapshot() {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("docker", ["ps", "--format", "{{json .}}"], { timeout: 3000 });
|
||||
const containers = stdout.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
.map((item) => ({
|
||||
name: item.Names,
|
||||
image: item.Image,
|
||||
status: item.Status,
|
||||
ports: item.Ports,
|
||||
}));
|
||||
return { state: "ok", containers, error: "" };
|
||||
} catch (error) {
|
||||
return { state: "error", containers: [], error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function adbSnapshot() {
|
||||
const adb = path.join(process.env.LOCALAPPDATA || "", "Android", "Sdk", "platform-tools", "adb.exe");
|
||||
if (!fs.existsSync(adb)) {
|
||||
return { state: "missing", devices: [], error: `missing ${adb}` };
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execFileAsync(adb, ["devices"], { timeout: 3000 });
|
||||
const devices = stdout.split(/\r?\n/)
|
||||
.map((line) => line.match(/^(\S+)\s+(device|offline|unauthorized)$/))
|
||||
.filter(Boolean)
|
||||
.map((match) => ({ serial: match[1], state: match[2] }));
|
||||
return { state: "ok", devices, error: "" };
|
||||
} catch (error) {
|
||||
return { state: "error", devices: [], error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
adbSnapshot,
|
||||
dockerSnapshot,
|
||||
};
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
function createLiveProviderRegistry(options = {}) {
|
||||
const maxAgeMs = Math.max(1_000, Number(options.maxAgeMs || 120_000));
|
||||
const records = new Map();
|
||||
|
||||
function ingestDocument(document, source = {}) {
|
||||
if (!document?.schemaId || !document?.recordKey) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
records.set(`${document.schemaId}:${document.recordKey}`, {
|
||||
...document,
|
||||
receivedAtMs: now,
|
||||
source,
|
||||
});
|
||||
prune(now);
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const now = Date.now();
|
||||
prune(now);
|
||||
|
||||
const advertisements = new Map();
|
||||
const surfaces = new Map();
|
||||
const commandBoundaries = new Map();
|
||||
const transportProfiles = new Map();
|
||||
|
||||
for (const record of records.values()) {
|
||||
const payload = normalizeDocumentPayload(record);
|
||||
switch (record.schemaId) {
|
||||
case "gamecult.eve.provider_advertisement":
|
||||
case "gamecult.eve.provider_advertisement.v1":
|
||||
if (payload?.providerId) {
|
||||
advertisements.set(String(payload.providerId), { ...record, payload });
|
||||
}
|
||||
break;
|
||||
case "gamecult.eve.surface_state":
|
||||
case "gamecult.eve.surface_state.v0":
|
||||
case "gamecult.eve.surface_state.v1":
|
||||
if (surfaceProviderId(payload)) {
|
||||
surfaces.set(String(surfaceProviderId(payload)), { ...record, payload });
|
||||
}
|
||||
break;
|
||||
default: {
|
||||
const providerId = payload.provider_id || payload.providerId;
|
||||
if (!providerId) break;
|
||||
if (String(record.schemaId).endsWith(".command_boundary")) {
|
||||
commandBoundaries.set(String(providerId), payload);
|
||||
} else if (String(record.schemaId).endsWith(".transport_profile")) {
|
||||
transportProfiles.set(String(providerId), payload);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const providerAdvertisements = [...advertisements.values()]
|
||||
.map((record) => normalizeProviderAdvertisementRecord(record, commandBoundaries, transportProfiles))
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => String(left.id).localeCompare(String(right.id)));
|
||||
|
||||
const interfaces = providerAdvertisements
|
||||
.map((provider) => {
|
||||
const surfaceRecord = surfaces.get(String(provider.id));
|
||||
if (!surfaceRecord?.payload?.surface?.root) {
|
||||
return null;
|
||||
}
|
||||
return liveProviderInterface(provider, surfaceRecord.payload, surfaceRecord.source);
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => String(left.providerId).localeCompare(String(right.providerId)));
|
||||
|
||||
return { interfaces, providerAdvertisements };
|
||||
}
|
||||
|
||||
function prune(now = Date.now()) {
|
||||
for (const [key, record] of records) {
|
||||
if (now - record.receivedAtMs > maxAgeMs) {
|
||||
records.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ingestDocument,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDocumentPayload(record) {
|
||||
const payload = record?.payload;
|
||||
switch (record?.schemaId) {
|
||||
case "gamecult.eve.surface_state":
|
||||
case "gamecult.eve.surface_state.v0":
|
||||
case "gamecult.eve.surface_state.v1":
|
||||
if (Array.isArray(payload) && payload.length >= 5) {
|
||||
return {
|
||||
provider_id: payload[0],
|
||||
title: payload[1],
|
||||
version: payload[2],
|
||||
updated_at: payload[3],
|
||||
surface: payload[4],
|
||||
};
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
let normalized = payload;
|
||||
for (let depth = 0; depth < 4; depth += 1) {
|
||||
if (Array.isArray(normalized) && normalized.length === 1 && normalized[0] && typeof normalized[0] === "object") {
|
||||
normalized = normalized[0];
|
||||
continue;
|
||||
}
|
||||
if (normalized?.value && typeof normalized.value === "object" && !Array.isArray(normalized.value)) {
|
||||
normalized = normalized.value;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return normalized || {};
|
||||
}
|
||||
|
||||
function surfaceProviderId(surfaceState) {
|
||||
return surfaceState?.providerId || surfaceState?.provider_id || null;
|
||||
}
|
||||
|
||||
function surfaceTitle(surfaceState) {
|
||||
return surfaceState?.title || surfaceState?.Title || null;
|
||||
}
|
||||
|
||||
function surfaceVersion(surfaceState) {
|
||||
return surfaceState?.version || surfaceState?.Version || 0;
|
||||
}
|
||||
|
||||
function surfaceUpdatedAt(surfaceState) {
|
||||
return surfaceState?.updatedAt || surfaceState?.updated_at || surfaceState?.UpdatedAt || null;
|
||||
}
|
||||
|
||||
function normalizeProviderAdvertisementRecord(record, commandBoundaries, transportProfiles) {
|
||||
const advertisement = record.payload;
|
||||
const providerId = advertisement?.providerId || advertisement?.provider?.id || advertisement?.id;
|
||||
if (!providerId) {
|
||||
return null;
|
||||
}
|
||||
const endpoints = sanitizeProviderEndpoints(advertisement.endpoints);
|
||||
const routes = sanitizeProviderEndpoints(advertisement.routes);
|
||||
const providerEndpoint = advertisement.cultMeshAddress
|
||||
|| providerTypedEndpoint(routes)
|
||||
|| providerTypedEndpoint(endpoints)
|
||||
|| typedProviderEndpoint(advertisement.provider?.endpoint)
|
||||
|| `cultmesh:live:${providerId}`;
|
||||
return {
|
||||
id: providerId,
|
||||
title: advertisement.title || advertisement.provider?.title || providerId,
|
||||
description: advertisement.description || "Provider-owned CultNet/RUDP advertisement.",
|
||||
version: String(advertisement.version || 0),
|
||||
endpoint: providerEndpoint,
|
||||
canonicalService: advertisement.canonicalService || null,
|
||||
locatedService: advertisement.locatedService || null,
|
||||
cultMeshAddress: advertisement.cultMeshAddress || null,
|
||||
endpoints,
|
||||
routes,
|
||||
inputStreams: Array.isArray(advertisement.inputStreams) ? advertisement.inputStreams : [],
|
||||
activeStreams: Array.isArray(advertisement.activeStreams) ? advertisement.activeStreams : [],
|
||||
availableSources: Array.isArray(advertisement.availableSources) ? advertisement.availableSources : [],
|
||||
transportEndpoint: providerTypedEndpoint(routes)
|
||||
|| typedProviderEndpoint(advertisement.provider?.endpoint)
|
||||
|| providerEndpoint,
|
||||
capabilities: advertisement.provider?.capabilities || advertisement.capabilities || [],
|
||||
operatorState: null,
|
||||
commandBoundary: commandBoundaries.get(String(providerId)) || null,
|
||||
transportProfile: transportProfiles.get(String(providerId)) || null,
|
||||
usesCultMesh: false,
|
||||
transport: "CultNet/RUDP provider advertisement",
|
||||
status: advertisement.status || "unknown",
|
||||
updatedAt: advertisement.updatedAt || record.storedAt || new Date(record.receivedAtMs).toISOString(),
|
||||
source: sourceLabel(record.source),
|
||||
commandSurface: advertisement.commandSurface || null,
|
||||
};
|
||||
}
|
||||
|
||||
function typedProviderEndpoint(endpoint) {
|
||||
const value = typeof endpoint === "string" ? endpoint.trim() : "";
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return value.startsWith("cultmesh:") ? value : null;
|
||||
}
|
||||
|
||||
function normalizeProviderEndpoints(endpoints) {
|
||||
if (!Array.isArray(endpoints)) {
|
||||
return [];
|
||||
}
|
||||
return endpoints.map((entry) => {
|
||||
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
||||
return entry;
|
||||
}
|
||||
return {
|
||||
transport: "witness",
|
||||
address: String(entry || ""),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeProviderEndpoints(endpoints) {
|
||||
return normalizeProviderEndpoints(endpoints).filter((entry) => {
|
||||
const transport = String(entry?.transport || "").toLowerCase();
|
||||
const address = String(entry?.address || "").trim();
|
||||
if (address.startsWith("cultmesh:")) {
|
||||
return true;
|
||||
}
|
||||
if (transport.includes("cultmesh") || transport.includes("cultcache")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function providerTypedEndpoint(endpoints) {
|
||||
return sanitizeProviderEndpoints(endpoints).find((entry) =>
|
||||
String(entry?.address || "").startsWith("cultmesh:")
|
||||
)?.address || null;
|
||||
}
|
||||
|
||||
function liveProviderInterface(provider, surfaceState, source) {
|
||||
const surface = surfaceState.surface || null;
|
||||
return {
|
||||
providerId: provider.id,
|
||||
title: surfaceTitle(surfaceState) || provider.title || provider.id,
|
||||
state: "active",
|
||||
detail: `${surface?.root?.kind || surface?.root?.type || "surface"} ${countSurfaceNodes(surface, surfaceState)} nodes via live CultNet/RUDP announcement`,
|
||||
version: surfaceVersion(surfaceState),
|
||||
updatedAt: surfaceUpdatedAt(surfaceState) || provider.updatedAt || new Date().toISOString(),
|
||||
source: sourceLabel(source),
|
||||
manifest: provider,
|
||||
canonicalService: provider.canonicalService || null,
|
||||
locatedService: provider.locatedService || null,
|
||||
cultMeshAddress: provider.cultMeshAddress || null,
|
||||
endpoints: sanitizeProviderEndpoints(provider.endpoints),
|
||||
routes: sanitizeProviderEndpoints(provider.routes),
|
||||
commandBoundary: provider.commandBoundary || null,
|
||||
transportProfile: provider.transportProfile || null,
|
||||
surface,
|
||||
};
|
||||
}
|
||||
|
||||
function countSurfaceNodes(surface, state = null) {
|
||||
if (Array.isArray(state?.nodes)) {
|
||||
return state.nodes.length;
|
||||
}
|
||||
if (!surface?.root) {
|
||||
return 0;
|
||||
}
|
||||
let count = 0;
|
||||
const stack = [surface.root];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop();
|
||||
if (!node || typeof node !== "object") continue;
|
||||
count += 1;
|
||||
if (Array.isArray(node.children)) {
|
||||
for (const child of node.children) stack.push(child);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function sourceLabel(source) {
|
||||
if (!source?.address) {
|
||||
return "cultnet-rudp:unknown";
|
||||
}
|
||||
return `cultnet-rudp://${source.address}:${source.port}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createLiveProviderRegistry,
|
||||
};
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const { dashboardUnavailable } = require("./interfaces.cjs");
|
||||
const {
|
||||
adbSnapshot,
|
||||
dockerSnapshot,
|
||||
} = require("./probes.cjs");
|
||||
const { buildMarqueeText } = require("./marquee.cjs");
|
||||
const { buildPendingSurface, buildSurface } = require("./surface.cjs");
|
||||
const { stableId } = require("./utils.cjs");
|
||||
|
||||
function createStateBuilder({ cachePath, gamecultTextDocumentStorePath, interfaceDiscovery, layoutStore, stonksBurstSize }) {
|
||||
let version = 0;
|
||||
|
||||
function buildPendingState(message) {
|
||||
return {
|
||||
type: "dashboard-state",
|
||||
schema: "mimir.eve_dashboard_state.v1",
|
||||
providerId: "odin.providers",
|
||||
title: "Odin Provider Catalog",
|
||||
version,
|
||||
updatedAt: new Date().toISOString(),
|
||||
selectedNodeId: "coordinator-starting",
|
||||
lutPreset: "terminal",
|
||||
nodes: [],
|
||||
providerCatalog: [],
|
||||
surface: buildPendingSurface(message),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildState() {
|
||||
version += 1;
|
||||
const observedAt = new Date().toISOString();
|
||||
const [
|
||||
docker,
|
||||
adb,
|
||||
discovery,
|
||||
] = await Promise.all([
|
||||
dockerSnapshot(),
|
||||
adbSnapshot(),
|
||||
interfaceDiscovery.discoverAll(),
|
||||
]);
|
||||
const discoveredInterfaces = discovery.interfaces;
|
||||
const providerAdvertisements = discovery.providerAdvertisements;
|
||||
const interfaceById = new Map(discoveredInterfaces.map((entry) => [entry.providerId, entry]));
|
||||
const interfaces = [...interfaceById.values()];
|
||||
const marqueeText = await buildMarqueeText({ interfaces, textDocumentStorePath: gamecultTextDocumentStorePath, stonksBurstSize });
|
||||
const interfaceSummary = interfaces.map((entry) => `${entry.providerId}:${entry.state}`).join(", ");
|
||||
const layout = await layoutStore.readLayout();
|
||||
const verseEvidence = [...providerAdvertisements, ...interfaces];
|
||||
const voidBotDashboard = interfaceById.get("voidbot.swarm") || dashboardUnavailable("voidbot.swarm", "discovery", "not discovered");
|
||||
const mimirLiveStats = interfaceById.get("mimir.live.stats") || dashboardUnavailable("mimir.live.stats", "discovery", "not discovered");
|
||||
const periwinkleServices = [
|
||||
...adbFaultServices(adb),
|
||||
];
|
||||
|
||||
const verses = [
|
||||
verse("starfire.local", "Starfire", "coordinator", "active", ["docker", "adb", "eve-provider"], [
|
||||
service("odin", "Odin all-seer", "active", "asgard.starfire.odin/eve/tui"),
|
||||
service("docker", "Docker", docker.state === "ok" ? "active" : "warn", `${docker.containers.length} running`),
|
||||
service("adb", "ADB transport", adbState(adb), adbDetail(adb)),
|
||||
service("cultcache", "Odin CultCache", fs.existsSync(cachePath) ? "active" : "waiting", require("path").basename(cachePath)),
|
||||
service("interfaces", "Eve interfaces", interfaces.some((entry) => entry.surface?.root) ? "active" : "waiting", interfaceSummary || "no provider surfaces"),
|
||||
service("voidbot-swarm", "VoidBot Swarm", voidBotDashboard.state, voidBotDashboard.detail),
|
||||
service("mimir-live-stats", "Mimir Live Stats", mimirLiveStats.state, mimirLiveStats.detail),
|
||||
...docker.containers.map((container) => service(`docker-${container.name}`, container.name, "active", container.image)),
|
||||
]),
|
||||
verse("nightwing.local", "Nightwing", "dashboard-renderer", bodyStatus(verseEvidence, "nightwing"), ["eve-tui", "gpu-worker"], bodyServices(verseEvidence, "nightwing")),
|
||||
verse("eve.ipad", "EVE", "ios-client", bodyStatus(verseEvidence, "eve"), ["native-eve"], bodyServices(verseEvidence, "eve")),
|
||||
periwinkleServices.length ? verse("periwinkle.android", "Periwinkle", "android-client", periwinkleStatus(periwinkleServices), ["sensor-edge", "adb-transport"], periwinkleServices) : null,
|
||||
verse("raven.local", "Raven", "local-peer", bodyStatus(verseEvidence, "raven"), ["cultmesh-provider"], bodyServices(verseEvidence, "raven")),
|
||||
verse("yggdrasil.ops", "Yggdrasil", "ops-host", bodyStatus(verseEvidence, "yggdrasil"), ["cultmesh-provider"], bodyServices(verseEvidence, "yggdrasil")),
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
type: "dashboard-state",
|
||||
schema: "mimir.eve_dashboard_state.v1",
|
||||
providerId: "odin.providers",
|
||||
title: "Odin Provider Catalog",
|
||||
version,
|
||||
updatedAt: observedAt,
|
||||
selectedNodeId: "verse-starfire.local",
|
||||
lutPreset: "terminal",
|
||||
nodes: verses.map((entry, index) => ({
|
||||
id: `verse-${entry.verseId}`,
|
||||
label: `${entry.name}\n${entry.role}`,
|
||||
kind: "cultmesh-verse",
|
||||
visible: true,
|
||||
x: -0.8 + (index % 3) * 0.8,
|
||||
y: -0.45 + Math.floor(index / 3) * 0.55,
|
||||
z: 0,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
width: 0.42,
|
||||
height: 0.2,
|
||||
health: entry.status,
|
||||
detail: entry.capabilities.join(", "),
|
||||
})),
|
||||
providerCatalog: providerAdvertisements,
|
||||
surface: buildSurface({ observedAt, docker, adb, verses, interfaces, layout, marqueeText }),
|
||||
};
|
||||
}
|
||||
|
||||
return { buildPendingState, buildState };
|
||||
}
|
||||
|
||||
function verse(verseId, name, role, status, capabilities, services = []) {
|
||||
return { verseId, name, role, status, capabilities, services };
|
||||
}
|
||||
|
||||
function service(id, name, state, detail = "") {
|
||||
return { id: stableId(id), name, state, detail: String(detail || "") };
|
||||
}
|
||||
|
||||
function bodyStatus(entries, body) {
|
||||
const matches = bodyEntries(entries, body);
|
||||
if (!matches.length) return "waiting";
|
||||
if (matches.some((entry) => providerState(entry) === "active")) return "active";
|
||||
if (matches.some((entry) => providerState(entry) === "warn")) return "warn";
|
||||
return "waiting";
|
||||
}
|
||||
|
||||
function bodyServices(entries, body) {
|
||||
const matches = bodyEntries(entries, body);
|
||||
if (!matches.length) {
|
||||
return [service(`${body}-cultmesh-witnesses`, `${titleCase(body)} CultMesh witnesses`, "waiting", "no provider advertisement or interface discovered")];
|
||||
}
|
||||
|
||||
return matches
|
||||
.slice(0, 8)
|
||||
.map((entry) => service(
|
||||
`${body}-${entry.providerId || entry.id || entry.title || "provider"}`,
|
||||
entry.title || entry.providerId || entry.id || `${titleCase(body)} provider`,
|
||||
providerState(entry),
|
||||
providerDetail(entry),
|
||||
));
|
||||
}
|
||||
|
||||
function bodyEntries(entries, body) {
|
||||
const token = String(body).toLowerCase();
|
||||
return entries.filter((entry) => providerBodyText(entry).includes(token));
|
||||
}
|
||||
|
||||
function providerBodyText(entry) {
|
||||
return [
|
||||
entry.providerId,
|
||||
entry.id,
|
||||
entry.title,
|
||||
entry.canonicalService,
|
||||
entry.locatedService,
|
||||
entry.cultMeshAddress,
|
||||
entry.verseId,
|
||||
entry.source,
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
}
|
||||
|
||||
function providerState(entry) {
|
||||
const state = String(entry.state || entry.status || "").toLowerCase();
|
||||
if (["active", "ok", "ready", "running", "published"].includes(state)) return "active";
|
||||
if (["warn", "warning", "degraded"].includes(state)) return "warn";
|
||||
if (["failed", "error", "offline"].includes(state)) return "warn";
|
||||
return entry.surface?.root ? "active" : "waiting";
|
||||
}
|
||||
|
||||
function providerDetail(entry) {
|
||||
return [
|
||||
entry.detail,
|
||||
entry.cultMeshAddress,
|
||||
entry.locatedService,
|
||||
entry.source,
|
||||
].filter(Boolean).join(" / ") || "provider-owned CultMesh discovery";
|
||||
}
|
||||
|
||||
function titleCase(value) {
|
||||
const text = String(value || "");
|
||||
return text ? text[0].toUpperCase() + text.slice(1) : "";
|
||||
}
|
||||
|
||||
function adbState(adb) {
|
||||
if (adb.state !== "ok") return adb.state;
|
||||
return adbFaultDevices(adb).length ? "warn" : "ok";
|
||||
}
|
||||
|
||||
function adbDetail(adb) {
|
||||
if (adb.error) return adb.error;
|
||||
if (adb.devices.length === 0) return "no devices";
|
||||
return adb.devices.map((device) => `${device.serial}:${device.state}`).join(", ");
|
||||
}
|
||||
|
||||
function adbFaultDevices(adb) {
|
||||
return adb.devices.filter((device) => device.state !== "device");
|
||||
}
|
||||
|
||||
function adbFaultServices(adb) {
|
||||
if (adb.state !== "ok") {
|
||||
return [service("adb-transport", "ADB transport", adb.state, adbDetail(adb))];
|
||||
}
|
||||
|
||||
return adbFaultDevices(adb).map((device) =>
|
||||
service(`adb-${device.serial}`, "ADB device fault", device.state, `${device.serial}:${device.state}`),
|
||||
);
|
||||
}
|
||||
|
||||
function periwinkleStatus(services) {
|
||||
if (services.some((entry) => entry.state === "active")) return "active";
|
||||
if (services.some((entry) => entry.state === "stale")) return "stale";
|
||||
if (services.some((entry) => entry.state === "offline" || entry.state === "unauthorized" || entry.state === "error" || entry.state === "missing")) return "warn";
|
||||
return "waiting";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createStateBuilder,
|
||||
service,
|
||||
verse,
|
||||
};
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const { analyzeElementTree, fullscreenLayoutIntent, mergeLayoutIntent } = require("./layout.cjs");
|
||||
const { stableId } = require("./utils.cjs");
|
||||
|
||||
function buildSurface({ observedAt, docker, adb, verses, interfaces, layout, marqueeText = "" }) {
|
||||
const activeInterfaces = interfaces.filter((entry) => entry?.surface?.root);
|
||||
return {
|
||||
schema: "gamecult.eve.surface.v1",
|
||||
id: "gamecult.network.status.surface",
|
||||
title: "Odin",
|
||||
root: {
|
||||
id: "network-root",
|
||||
kind: "dashboard",
|
||||
props: {
|
||||
title: "Odin Provider Catalog",
|
||||
observedAt,
|
||||
summary: `${activeInterfaces.length} Eve surfaces`,
|
||||
marqueeText,
|
||||
layout: fullscreenLayoutIntent("odin.providers", -100),
|
||||
presentation: {
|
||||
theme: "bifrost-pride",
|
||||
frame: "animated-rainbow",
|
||||
dividerMotion: "bifrost-rainbow-dance",
|
||||
intent: "Odin preserves provider-owned surfaces for catalog/proxy access; Gjallar owns Nightwing overview composition and lowering.",
|
||||
},
|
||||
},
|
||||
children: [
|
||||
...activeInterfaces.map((entry) => {
|
||||
const tree = analyzeElementTree(entry.surface.root);
|
||||
return {
|
||||
id: `interface-${stableId(entry.providerId)}`,
|
||||
kind: "interface",
|
||||
props: {
|
||||
title: entry.title || entry.providerId,
|
||||
verseUri: verseUri(entry),
|
||||
canonicalService: entry.canonicalService || "",
|
||||
locatedService: entry.locatedService || "",
|
||||
cultMeshAddress: entry.cultMeshAddress || "",
|
||||
endpoints: entry.endpoints || [],
|
||||
routes: entry.routes || [],
|
||||
providerId: entry.providerId,
|
||||
source: entry.source,
|
||||
status: entry.state,
|
||||
detail: entry.detail,
|
||||
overviewSignal: hasOverviewSignal(entry),
|
||||
version: entry.version,
|
||||
updatedAt: entry.updatedAt,
|
||||
layout: mergeLayoutIntent(layout.tiles?.[entry.providerId], entry, interfaces),
|
||||
tree,
|
||||
packing: {
|
||||
strategy: "nested-dense-signal",
|
||||
flattening: "forbidden-when-surface-root-has-children",
|
||||
listPolicy: "group-or-tree; never render provider signal as a single log/list blob",
|
||||
},
|
||||
},
|
||||
children: [entry.surface.root],
|
||||
};
|
||||
}),
|
||||
],
|
||||
},
|
||||
assets: [],
|
||||
};
|
||||
}
|
||||
|
||||
function hasOverviewSignal(entry) {
|
||||
const root = entry?.surface?.root;
|
||||
if (!root) return false;
|
||||
|
||||
const providerId = String(entry.providerId || "").toLowerCase();
|
||||
const explicit = root.props?.overview || entry.manifest?.overview;
|
||||
if (explicit?.visible === false) return false;
|
||||
if (explicit?.visible === true || explicit?.signal === "live-ops") return true;
|
||||
|
||||
const text = surfaceText(root);
|
||||
if (providerId.includes("voidbot.swarm")) return /\bctb\b|\bs[0-9.]+\s+h[0-9.]+/i.test(text);
|
||||
if (providerId.includes("stonks.market")) return stonksHasOverviewSignal(root, text);
|
||||
if (providerId.includes("mimir.stream.layout")) return false;
|
||||
if (providerId.includes("spotiverse")) return spotiverseHasOverviewSignal(root, text);
|
||||
if (providerId.includes("streampixels")) return streamPixelsHasOverviewSignal(root, text);
|
||||
if (providerId.includes("mimir.live.stats")) return mimirHasOverviewSignal(root, text);
|
||||
|
||||
if (/\b(rate limit|playing:\s*none|queue:\s*empty|unavailable|not discovered)\b/i.test(text)) return false;
|
||||
return hasMetricElement(root) || /\b(users?|viewers?|events?|traffic|ingest|throughput|latency|dropout|queue|pressure|saturation|error|fault|warning)\b/i.test(text);
|
||||
}
|
||||
|
||||
function stonksHasOverviewSignal(root, text) {
|
||||
if (root.props?.overview?.visible === true) return true;
|
||||
return /\b(Market pulse|BITCOIN|ETHEREUM|SOLANA|DOGECOIN|equities|crypto|vol|request traffic|source health)\b/i.test(text);
|
||||
}
|
||||
|
||||
function spotiverseHasOverviewSignal(root, text) {
|
||||
const status = String(root.props?.status || "").toLowerCase();
|
||||
if (status === "warn" && /\brate limit\b/i.test(text)) return false;
|
||||
if (/\bplaying:\s*none\b/i.test(text)) return false;
|
||||
if (/\bqueue:\s*empty|queue:\s*empty or unavailable\b/i.test(text)) return false;
|
||||
return /\bplaying:\s*(?!none\b).+|\bqueue:\s*(?!empty\b|0\b).+|\brequests?:\s*[1-9]|\bvolume:\s*[1-9]/i.test(text);
|
||||
}
|
||||
|
||||
function streamPixelsHasOverviewSignal(root, text) {
|
||||
return [
|
||||
/\b(live\s*)?(users?|viewers?)\s*[:=]?\s*[1-9]/i,
|
||||
/\bevents?(\s*traffic|\s*\/\s*s|\s*per\s*sec)?\s*[:=]?\s*[1-9]/i,
|
||||
/\bingest\b.*\b(saturation|pressure|traffic|throughput|[1-9][0-9]*(\.[0-9]+)?)\b/i,
|
||||
/\b(queue|backlog|pressure)\s*[:=]?\s*[1-9]/i,
|
||||
/\b(errors?|dropped|5xx)\s*[:=]?\s*[1-9]/i,
|
||||
/\bthroughput\s*[:=]?\s*[1-9]/i,
|
||||
].some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
function mimirHasOverviewSignal(root, text) {
|
||||
return [
|
||||
/\bdropout\b.*\b[1-9]/i,
|
||||
/\bstale\s+streams?\s*[:=]?\s*[1-9]/i,
|
||||
/\blatency\b.*\b([2-9][0-9]{2,}|[1-9]\d+\.\d+ms)\b/i,
|
||||
/\bconfidence\b.*\b0\.[0-4]/i,
|
||||
].some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
function surfaceText(root) {
|
||||
const parts = [];
|
||||
visitSurface(root, (node) => {
|
||||
const props = node.props || {};
|
||||
for (const key of ["title", "label", "text", "status", "detail", "summary", "value"]) {
|
||||
if (props[key] !== undefined && props[key] !== null) parts.push(String(props[key]));
|
||||
}
|
||||
});
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function hasMetricElement(root) {
|
||||
let found = false;
|
||||
visitSurface(root, (node) => {
|
||||
if (node.kind === "metric") found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function visitSurface(node, visitor) {
|
||||
if (!node || typeof node !== "object") return;
|
||||
visitor(node);
|
||||
for (const child of Array.isArray(node.children) ? node.children : []) {
|
||||
visitSurface(child, visitor);
|
||||
}
|
||||
}
|
||||
|
||||
function buildPendingSurface(message) {
|
||||
return {
|
||||
schema: "gamecult.eve.surface.v1",
|
||||
id: "gamecult.network.status.surface",
|
||||
title: "Odin",
|
||||
root: stack("root", [pane("Coordinator", [text("status", message)])]),
|
||||
assets: [],
|
||||
};
|
||||
}
|
||||
|
||||
function stack(id, children) {
|
||||
return { id, kind: "stack", props: {}, children };
|
||||
}
|
||||
|
||||
function pane(title, children) {
|
||||
return { id: stableId(`pane-${title}`), kind: "pane", props: { title }, children };
|
||||
}
|
||||
|
||||
function card(id, children, tone = "default") {
|
||||
return { id, kind: "card", props: { title: id }, style: { tone }, children };
|
||||
}
|
||||
|
||||
function text(id, value) {
|
||||
return { id, kind: "text", props: { text: value }, children: [] };
|
||||
}
|
||||
|
||||
function metric(id, label, value, tone) {
|
||||
return { id, kind: "metric", props: { label, text: `${label}: ${value}`, value, tone }, children: [] };
|
||||
}
|
||||
|
||||
function verseUri(entry) {
|
||||
if (entry.cultMeshAddress) return String(entry.cultMeshAddress);
|
||||
if (entry.locatedService) return String(entry.locatedService);
|
||||
const providerId = String(entry.providerId || "").trim();
|
||||
if (hasBodyPrefix(providerId)) return providerId;
|
||||
const body = sourceBody(entry.source);
|
||||
if (!body || providerId.startsWith(`${body}.`)) return providerId;
|
||||
return `${body}.${providerId}`;
|
||||
}
|
||||
|
||||
function hasBodyPrefix(providerId) {
|
||||
return ["starfire", "yggdrasil", "nightwing", "eve", "periwinkle", "raven"].some((body) => providerId.startsWith(`${body}.`));
|
||||
}
|
||||
|
||||
function sourceBody(source) {
|
||||
const value = String(source || "");
|
||||
if (value.startsWith("cultmesh:")) return "starfire";
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function adbTone(adb) {
|
||||
if (adb.state !== "ok") return "warn";
|
||||
return adb.devices.some((device) => device.state !== "device") ? "warn" : "ok";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildPendingSurface,
|
||||
buildSurface,
|
||||
card,
|
||||
hasOverviewSignal,
|
||||
metric,
|
||||
pane,
|
||||
stack,
|
||||
text,
|
||||
};
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { defineOdinDocuments } = require("../src/odin/documents.cjs");
|
||||
|
||||
test("Odin persists Ghostlight's public schema catalog envelope", () => {
|
||||
const documents = defineOdinDocuments((definition) => definition);
|
||||
const catalog = documents.ghostlightSchemaCatalogDefinition;
|
||||
|
||||
assert.equal(catalog.type, "ghostlight.schema_catalog");
|
||||
assert.equal(catalog.schemaId, "ghostlight.schema_catalog.v1");
|
||||
assert.equal(catalog.schemaVersion, "ghostlight.schema_catalog.v1");
|
||||
assert.equal(catalog.name({ providerId: "gamecult.ghostlight.dungeon" }), "gamecult.ghostlight.dungeon");
|
||||
});
|
||||
|
||||
test("Odin's unavailable-runtime projection includes the Ghostlight catalog slot", () => {
|
||||
const documents = defineOdinDocuments(null);
|
||||
assert.equal(documents.ghostlightSchemaCatalogDefinition, null);
|
||||
});
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { defineOdinDocuments } = require("../src/odin/documents.cjs");
|
||||
|
||||
test("Odin registers the typed Heimdall access discovery documents", () => {
|
||||
const documents = defineOdinDocuments((definition) => definition);
|
||||
|
||||
assert.equal(
|
||||
documents.evePluginAdvertisementDefinition.schemaId,
|
||||
"gamecult.eve.plugin_advertisement.v1",
|
||||
);
|
||||
assert.equal(
|
||||
documents.heimdallCommandBoundaryDefinition.schemaId,
|
||||
"heimdall.command_boundary.v1",
|
||||
);
|
||||
assert.equal(
|
||||
documents.heimdallTransportProfileDefinition.schemaId,
|
||||
"heimdall.transport_profile.v1",
|
||||
);
|
||||
});
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const test = require("node:test");
|
||||
|
||||
const {
|
||||
IDUNN_HEALTH_CONTRACT,
|
||||
signedHealthPayload,
|
||||
signingMessage,
|
||||
} = require("../src/odin/idunn-rudp.cjs");
|
||||
|
||||
test("Odin signs the complete canonical provider-health statement", () => {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const publicDer = publicKey.export({ type: "spki", format: "der" });
|
||||
const rawPublicKey = Buffer.from(publicDer.subarray(publicDer.length - 32));
|
||||
const signerIdentityId = crypto
|
||||
.createHash("sha256")
|
||||
.update(Buffer.from("gamecult.provider-health.identity.v1\0"))
|
||||
.update(rawPublicKey)
|
||||
.digest("hex");
|
||||
const publisher = {
|
||||
daemonId: "yggdrasil-odin",
|
||||
healthContract: "odin.cultnet-rudp-provider-health",
|
||||
privateKey,
|
||||
publisherIncarnationId: "24e0dc7f-6a92-4aa8-9f56-fb513d265e13",
|
||||
publisherSequence: 0,
|
||||
signerIdentityId,
|
||||
sourceRuntimeId: "odin-yggdrasil",
|
||||
contract: IDUNN_HEALTH_CONTRACT,
|
||||
};
|
||||
|
||||
const signed = signedHealthPayload(
|
||||
publisher,
|
||||
{ state: "active", detail: "catalog current" },
|
||||
"2026-08-21T12:34:56.789Z",
|
||||
);
|
||||
|
||||
assert.equal(signed.statement[0], "idunn.signed_daemon_health.v1");
|
||||
assert.equal(signed.statement[1], "yggdrasil-odin");
|
||||
assert.equal(signed.statement[2], "odin.cultnet-rudp-provider-health");
|
||||
assert.equal(signed.statement[3], "odin-yggdrasil");
|
||||
assert.equal(signed.statement[8], 1);
|
||||
assert.equal(signed.statement[9], 1787315696789);
|
||||
assert.equal(signed.statement[15].byteLength, 64);
|
||||
assert.equal(
|
||||
crypto.verify(
|
||||
null,
|
||||
signingMessage(signed.unsignedPayload),
|
||||
publicKey,
|
||||
Buffer.from(signed.statement[15]),
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const tampered = Buffer.from(signed.unsignedPayload);
|
||||
tampered[tampered.length - 1] ^= 1;
|
||||
assert.equal(
|
||||
crypto.verify(
|
||||
null,
|
||||
signingMessage(tampered),
|
||||
publicKey,
|
||||
Buffer.from(signed.statement[15]),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { createLiveProviderRegistry } = require("../src/odin/provider-ingress.cjs");
|
||||
|
||||
test("live provider registry unwraps Rust compatibility records", () => {
|
||||
const registry = createLiveProviderRegistry();
|
||||
registry.ingestDocument({
|
||||
schemaId: "gamecult.eve.provider_advertisement.v1",
|
||||
recordKey: "muninn.telemetry.nightwing",
|
||||
payload: [{
|
||||
value: {
|
||||
providerId: "muninn.telemetry.nightwing",
|
||||
title: "Muninn Nightwing Telemetry",
|
||||
status: "active",
|
||||
cultMeshAddress: "asgard.nightwing.muninn/telemetry",
|
||||
capabilities: ["muninn.move_evidence_stream"],
|
||||
},
|
||||
}],
|
||||
}, { address: "10.77.0.3", port: 40000 });
|
||||
|
||||
const snapshot = registry.snapshot();
|
||||
assert.equal(snapshot.providerAdvertisements.length, 1);
|
||||
assert.equal(snapshot.providerAdvertisements[0].id, "muninn.telemetry.nightwing");
|
||||
assert.deepEqual(snapshot.providerAdvertisements[0].capabilities, ["muninn.move_evidence_stream"]);
|
||||
assert.equal(snapshot.providerAdvertisements[0].source, "cultnet-rudp://10.77.0.3:40000");
|
||||
});
|
||||
Loading…
Reference in a new issue