Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
882a1cd1fd | ||
|
|
9197504037 | ||
|
|
2ec1d85071 | ||
|
|
49b4b95fa9 | ||
|
|
5e8bb3229a | ||
|
|
946145a85e | ||
|
|
6e6297d6e4 | ||
|
|
4bcaeec9b4 | ||
|
|
881e9c4e5e | ||
|
|
4b9863b0fe | ||
|
|
3a8afb08b3 | ||
|
|
bd6df14ad3 | ||
|
|
a287ca3918 | ||
|
|
1fc871ef73 | ||
|
|
959c5e0bd3 | ||
|
|
3a57868bf1 | ||
|
|
0503ca1c5c | ||
|
|
d890ab8bca | ||
|
|
9e0265adac | ||
|
|
f1d8b3eb13 | ||
|
|
ba5de43e46 | ||
|
|
916d0783b1 | ||
|
|
b34cc17a59 | ||
|
|
9552f4d700 | ||
|
|
46e9581528 | ||
|
|
d8a3390670 | ||
|
|
b96803d97c | ||
|
|
11ecb7b37a | ||
|
|
e6416a0dfd | ||
|
|
38548c42c2 | ||
|
|
c9d700dcac | ||
|
|
235cc81b17 |
4109 changed files with 992420 additions and 92249 deletions
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -1 +0,0 @@
|
|||
/schemas/cultnet/epiphany.pipeline.*.schema.json text eol=lf
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
|
|
@ -1,12 +1,13 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
/target/
|
||||
epiphany-core/target/
|
||||
epiphany-core/Cargo.lock
|
||||
epiphany-openai-adapter/target/
|
||||
epiphany-openai-adapter/Cargo.lock
|
||||
epiphany-openai-codex-spine/target/
|
||||
epiphany-openai-codex-spine/Cargo.lock
|
||||
epiphany-openai-runtime/target/
|
||||
epiphany-openai-runtime/Cargo.lock
|
||||
epiphany-codex-bridge/target/
|
||||
epiphany-codex-bridge/Cargo.lock
|
||||
apps/epiphany-gui/node_modules/
|
||||
|
|
@ -18,19 +19,14 @@ apps/epiphany-gui/src-tauri/target/
|
|||
.epiphany-dogfood/
|
||||
.epiphany-heartbeats/
|
||||
.epiphany-face/
|
||||
.epiphany-character-loop/
|
||||
.epiphany-rumination/
|
||||
.epiphany-character-loop/
|
||||
.epiphany-run/
|
||||
.epiphany-bootstrap/
|
||||
.epiphany-status/
|
||||
.voidbot/
|
||||
state/agent-heartbeats.json
|
||||
state/runtime-spine.msgpack
|
||||
state/thread-state.msgpack
|
||||
state/*.msgpack.lock
|
||||
state/*.cc
|
||||
state/runtime-spine.cc
|
||||
state/thread-state.cc
|
||||
state/local-verse.cc
|
||||
state/memory-graph.cc
|
||||
state/*.cc.lock
|
||||
state/*.ccmp
|
||||
state/*.ccmp.lock
|
||||
state/*.projection.json
|
||||
vendor/cultmesh-rs/target/
|
||||
|
|
|
|||
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[submodule "vendor/cultcache-rs"]
|
||||
path = vendor/cultcache-rs
|
||||
url = https://github.com/GameCult/cultcache-rs.git
|
||||
[submodule "vendor/cultnet-rs"]
|
||||
path = vendor/cultnet-rs
|
||||
url = https://github.com/GameCult/cultnet-rs.git
|
||||
159
AGENTS.md
159
AGENTS.md
|
|
@ -2,10 +2,7 @@
|
|||
|
||||
## Project Purpose
|
||||
|
||||
This repo is the forge-temple of Epiphany: an opinionated agent harness, born
|
||||
as a Codex fork and now backend-agnostic. Codex, Claude, and OpenAI-compatible
|
||||
APIs speak to it through connectors that share one standardized
|
||||
Epiphany-facing CultNet API, the same one Ghostlight uses. It is the harness where
|
||||
This repo is the forge-temple of Epiphany: an opinionated Codex fork where
|
||||
external typed state, explicit mental maps, bounded scratch work, verifier
|
||||
evidence, and anti-churn discipline are wired into the blessed harness instead
|
||||
of taped onto the chat transcript like a purity seal over a cracked reactor.
|
||||
|
|
@ -61,7 +58,7 @@ machine-spirit awake.
|
|||
worthy of rehydration.
|
||||
- Treat `state/scratch.md` as disposable noospheric scratch for one bounded
|
||||
rite. It is a workbench, not a reliquary.
|
||||
- Treat `state/ledgers.msgpack` as the CultCache-shaped branch/evidence
|
||||
- Treat `state/ledgers.cc` as the CultCache-shaped branch/evidence
|
||||
reliquary: what was learned, verified, rejected, or accepted after the smoke
|
||||
cleared.
|
||||
- Treat `notes/epiphany-fork-implementation-plan.md` as the current campaign
|
||||
|
|
@ -73,56 +70,37 @@ machine-spirit awake.
|
|||
git history, commit messages, smoke artifacts, or targeted logs unless it
|
||||
changes what the next awakened agent should believe.
|
||||
- Do not pour volatile phase/status sludge into the evidence reliquary. Keep
|
||||
canonical current status and next action in `state/map.yaml`; let
|
||||
`notes/fresh-workspace-handoff.md` carry compact narrative and re-entry
|
||||
context that points back to the map. Use `state/ledgers.msgpack` only for
|
||||
branch ledger state and belief-changing records.
|
||||
|
||||
## Codex Mind Steward Route
|
||||
|
||||
- The global dedicated-worker contract applies to every nontrivial Codex task
|
||||
in this repository. Reserve one sub-agent exclusively as `mind_steward`; its
|
||||
active inputs are `state/map.yaml`, `notes/fresh-workspace-handoff.md`,
|
||||
`notes/epiphany-current-algorithmic-map.md`, the implementation plan, native
|
||||
state status, and only the exact current source needed to falsify one steering
|
||||
claim. It owns no implementation work.
|
||||
- Reserve those persistence surfaces from concurrent edits while the steward is
|
||||
active. Before trusting the persisted next action or completing a phase
|
||||
boundary, the root reviews the result, routes owner-required proposals, and
|
||||
applies only steward-owned or owner-admitted changes.
|
||||
- `state/map.yaml` solely owns canonical executive state, current status,
|
||||
decision constraints, and next action. The algorithmic map owns the detailed,
|
||||
source-grounded mechanism. The handoff carries narrative and re-entry context;
|
||||
it may echo or point to those owners, but it must not independently decide
|
||||
current direction. Route volatile work to scratch and only belief-changing
|
||||
evidence to the ledger. Agent or Persona memory changes use their established
|
||||
typed admission path. When the factual owner is Modeling, Soul, or the
|
||||
operator, the steward returns an exact evidence-backed proposal rather than
|
||||
stealing that authority.
|
||||
- A no-change audit writes nothing. Missing inspection or mutation access is an
|
||||
explicit Mind defect; do not manufacture a maintenance receipt around it.
|
||||
current status in `state/map.yaml` and `notes/fresh-workspace-handoff.md`.
|
||||
Use `state/ledgers.cc` only for branch ledger state and belief-changing
|
||||
records.
|
||||
|
||||
## Important Paths
|
||||
|
||||
- Project root: `F:\Projects\Epiphany`
|
||||
- Fork implementation plan: `F:\Projects\Epiphany\notes\epiphany-fork-implementation-plan.md`
|
||||
- Handoff summary: `F:\Projects\Epiphany\notes\fresh-workspace-handoff.md`
|
||||
- Epiphany algorithmic map: `F:\Projects\Epiphany\notes\epiphany-current-algorithmic-map.md`
|
||||
- Epiphany safety architecture: `F:\Projects\Epiphany\notes\epiphany-safety-architecture.md`
|
||||
- State CLI: `cargo run --manifest-path F:\Projects\Epiphany\Cargo.toml --bin epiphany-state -- ...`
|
||||
- Project root: `E:\Projects\EpiphanyAgent`
|
||||
- Vendored Codex repo: `E:\Projects\EpiphanyAgent\vendor\codex`
|
||||
- Fork implementation plan: `E:\Projects\EpiphanyAgent\notes\epiphany-fork-implementation-plan.md`
|
||||
- Handoff summary: `E:\Projects\EpiphanyAgent\notes\fresh-workspace-handoff.md`
|
||||
- Epiphany algorithmic map: `E:\Projects\EpiphanyAgent\notes\epiphany-current-algorithmic-map.md`
|
||||
- Epiphany safety architecture: `E:\Projects\EpiphanyAgent\notes\epiphany-safety-architecture.md`
|
||||
- State CLI: `cargo run --manifest-path E:\Projects\EpiphanyAgent\epiphany-core\Cargo.toml --bin epiphany-state -- ...`
|
||||
- Pre-compaction helper: `cargo run --manifest-path E:\Projects\EpiphanyAgent\epiphany-core\Cargo.toml --bin epiphany-prepare-compaction -- ...`
|
||||
|
||||
## Useful Commands
|
||||
|
||||
Use the already-built native state tool for routine sight. On this Windows
|
||||
workstation the shared Codex target is the operator binary cache; do not make a
|
||||
read-only status check compile `epiphany-core` in the repository-local target.
|
||||
If the binary is absent or source work specifically requires rebuilding it,
|
||||
name the shared target explicitly:
|
||||
Use the native Rust tools for state and compaction:
|
||||
|
||||
```powershell
|
||||
& 'C:\Users\Meta\.cargo-target-codex\debug\epiphany-state.exe' status
|
||||
$env:CARGO_TARGET_DIR='C:\Users\Meta\.cargo-target-codex'
|
||||
cargo run --manifest-path .\Cargo.toml --bin epiphany-state -- add-evidence --type research --status ok --note '...'
|
||||
cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-state -- status
|
||||
cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-state -- add-evidence --type research --status ok --note '...'
|
||||
cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-prepare-compaction --
|
||||
```
|
||||
|
||||
Useful Codex repo searches:
|
||||
|
||||
```powershell
|
||||
rg -n "pub enum ModeKind|TUI_VISIBLE_COLLABORATION_MODES" .\vendor\codex\codex-rs\protocol\src\config_types.rs
|
||||
rg -n "builtin_collaboration_mode_presets|fn plan_preset|fn default_preset" .\vendor\codex\codex-rs\models-manager\src\collaboration_mode_presets.rs
|
||||
rg -n "collaboration_mode_label|collaboration_mode_indicator|set_collaboration_mask" .\vendor\codex\codex-rs\tui\src\chatwidget.rs
|
||||
```
|
||||
|
||||
## Session Bootstrap And Re-entry Rite
|
||||
|
|
@ -137,29 +115,19 @@ a servitor with a nail gun:
|
|||
- `notes/epiphany-fork-implementation-plan.md`
|
||||
- `notes/epiphany-safety-architecture.md` when the task touches capability growth, autonomy, permissions, governance, or deployment authority
|
||||
2. run:
|
||||
- `& 'C:\Users\Meta\.cargo-target-codex\debug\epiphany-state.exe' status`
|
||||
- if that binary is absent, set
|
||||
`$env:CARGO_TARGET_DIR='C:\Users\Meta\.cargo-target-codex'` before the
|
||||
`cargo run` fallback
|
||||
3. dispatch the dedicated `mind_steward` and reserve the canonical persistence
|
||||
surfaces while it performs the bounded audit above
|
||||
4. review its result, route owner-required proposals, apply only steward-owned
|
||||
or owner-admitted changes, then restate the audited current next action before
|
||||
touching the machine
|
||||
5. if the user only asked to rehydrate or reorient, stop after orientation and
|
||||
- `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-state -- status`
|
||||
3. restate the current next action from the persisted state before touching the
|
||||
machine
|
||||
4. if the user only asked to rehydrate or reorient, stop after orientation and
|
||||
await explicit continuation; persisted next action is not automatic
|
||||
execution authority
|
||||
|
||||
After compaction, resume, or any suspicious loss of continuity:
|
||||
|
||||
1. rerun the existing shared-target `epiphany-state.exe status`; rebuild it only
|
||||
through `C:\Users\Meta\.cargo-target-codex` when absent or intentionally stale
|
||||
1. rerun `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-state -- status`
|
||||
2. reread `state/map.yaml` and `notes/fresh-workspace-handoff.md`
|
||||
3. follow up with or replace the dedicated `mind_steward`, review its bounded
|
||||
audit, route owner-required proposals, and apply only steward-owned or
|
||||
owner-admitted changes
|
||||
4. treat the audited persisted next action as authoritative unless fresh
|
||||
evidence in the repo contradicts the machine-map
|
||||
3. treat the persisted next action as authoritative unless fresh evidence in
|
||||
the repo contradicts the machine-map
|
||||
|
||||
When context pressure rises and the dark approaches:
|
||||
|
||||
|
|
@ -173,11 +141,11 @@ is negligence with a dramatic soundtrack.
|
|||
|
||||
When the user says to prepare for imminent compaction:
|
||||
|
||||
1. run the existing shared-target `epiphany-state.exe status` and `git status --short --branch` before editing persistence surfaces
|
||||
2. inspect the canonical map, handoff, scratch, evidence, and git state directly
|
||||
1. run `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-prepare-compaction --` before editing persistence surfaces
|
||||
2. use its warnings as the checklist for map, handoff, scratch, evidence, and git hygiene
|
||||
3. update only the state that actually needs to change
|
||||
4. rerun `epiphany-state.exe status` and `git status --short --branch` after edits
|
||||
5. commit the completed persistence pass unless the work is deliberately mid-surgery
|
||||
4. run `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-prepare-compaction --` again after edits
|
||||
5. fix errors, address warnings, and commit the completed persistence pass unless the work is deliberately mid-surgery
|
||||
|
||||
## Operating Discipline
|
||||
|
||||
|
|
@ -186,7 +154,7 @@ When the user says to prepare for imminent compaction:
|
|||
state richly to the human, but must not inspect or edit another Epiphany
|
||||
workspace. Cross-agent needs travel coordinator-to-coordinator through
|
||||
visible swarm messages and callbacks.
|
||||
- Humans talk to Face. Sub-agents may talk soul-to-soul through typed
|
||||
- Humans talk to Persona. Sub-agents may talk soul-to-soul through typed
|
||||
coordinator channels, findings, patches, heartbeat outputs, and swarm
|
||||
communications; Aquarium should surface their internals for inspection
|
||||
without making every organ a human chat endpoint.
|
||||
|
|
@ -196,9 +164,9 @@ When the user says to prepare for imminent compaction:
|
|||
front of an unlocked door.
|
||||
- Heartbeat scheduling should behave like physiology. Do not wake a lane again
|
||||
while its previous heartbeat turn is still running; cooldown starts after
|
||||
completion, not at launch. When no typed work obligation is active, let the
|
||||
swarm sleep. Heartbeat owns no rumination, dreaming, personality, or memory
|
||||
mutation path and may not invent work to justify a pulse.
|
||||
completion, not at launch. When no coordinator work is active, let the swarm
|
||||
sleep: slow rumination, memory distillation, and dreaming without hammering
|
||||
every organ at work tempo.
|
||||
- Prefer one clear hypothesis per rite.
|
||||
- Verify with checks that reflect the true objective, not just proxy incense.
|
||||
- Revert or discard changes that do not clearly improve the target.
|
||||
|
|
@ -213,12 +181,7 @@ When the user says to prepare for imminent compaction:
|
|||
- After committing a major completed pass, push upstream unless the user asked
|
||||
not to push yet or there is a concrete reason to keep the commit local for a
|
||||
moment.
|
||||
- Before handoff, compaction, or phase boundaries, follow up with the dedicated
|
||||
`mind_steward`, review its bounded maintenance result, route owner-required
|
||||
proposals, apply only steward-owned or owner-admitted changes, then sync
|
||||
`state/map.yaml`, add distilled evidence when the lesson changes future belief,
|
||||
make the map's next action explicit, and refresh
|
||||
`notes/fresh-workspace-handoff.md` as narrative context that points to it.
|
||||
- Before handoff, compaction, or phase boundaries, sync `state/map.yaml`, add distilled evidence when the lesson changes future belief, refresh `notes/fresh-workspace-handoff.md`, and make the next action explicit.
|
||||
- Do not write handoff notes that trap the next session in indefinite tiny hardening work. Bounded slices are a landing discipline, not a roadmap; when a phase is complete enough, name the next larger organ to build.
|
||||
|
||||
## Dogfood Supervision Quarantine
|
||||
|
|
@ -247,48 +210,10 @@ operator-enginseer, not the implementation servitor.
|
|||
run contaminated for supervision purposes, and continue from sealed
|
||||
projections instead of letting the worker's stream steer the supervisor.
|
||||
|
||||
## Source And Build Economy
|
||||
|
||||
- Verified capability is the asset. Epiphany-owned source, tests, schemas,
|
||||
dependencies, executable targets, and compiler artifacts are carrying costs.
|
||||
Minimize authority surfaces and build fan-out, not line count; readable code
|
||||
and consequence-bearing verifier reach remain invariants.
|
||||
- The target invariant is one Cargo package owner per production entrypoint.
|
||||
`epiphany-core` is a library; the release bundle owns runtime and maintenance
|
||||
entrypoints, including the single `epiphany-state` steward CLI.
|
||||
Leaf library crates disable automatic binaries; their production entrypoint
|
||||
sources are admitted only by the release bundle. Do not point multiple
|
||||
packages at the same binary source or preserve a binary because a stale
|
||||
manifest, smoke command, or historical document still names it.
|
||||
- Admit a new executable only when a live consumer requires an independent
|
||||
process, privilege boundary, lifecycle, or deployment contract. Tests,
|
||||
fixtures, probes, and one-off migrations belong in existing library or
|
||||
integration-test surfaces unless they must cross such a boundary.
|
||||
- Local Cargo checks must name one package and a focused library, integration
|
||||
test, or binary target. Do not run local workspace-wide, `--all-targets`, or
|
||||
otherwise unspecified multi-binary builds without explicit operator
|
||||
authorization. Idunn owns release-wide build execution. Before an authorized
|
||||
broad build, inventory the target graph, output root, current output size,
|
||||
free disk, expected budget, and terminal retention.
|
||||
- An interrupted build remains unsettled work. Before retrying, inventory every
|
||||
output root it may have written, including absolute path, size, last write,
|
||||
owning run or revision, live process status, and retention decision. Settle
|
||||
that run before creating another target root; cleanup machinery is not a
|
||||
substitute for deleting unowned source or target fan-out.
|
||||
|
||||
## Verification Guardrails
|
||||
|
||||
- Use focused checks for the surface being changed instead of defaulting to a
|
||||
whole-repo ritual. Not every prayer needs a cathedral.
|
||||
- For Epiphany control-plane behavior changes, run the focused existing library
|
||||
or integration tests that exercise the changed consequence, plus touched
|
||||
app-server mapper/handler tests when applicable. Do not create or resurrect a
|
||||
standalone smoke executable merely to test a library or contract.
|
||||
- Source, test, and target counts are pressure signals, not acceptance metrics.
|
||||
Do not delete exact conflict, substitution, concurrency, lifecycle, re-entry,
|
||||
or user-visible consequence proofs merely to improve a count. Preserve the
|
||||
claim in the smallest owning library or integration surface, and delete a
|
||||
verifier only when the claim is dead, duplicated, or covered by stronger
|
||||
evidence.
|
||||
- For Epiphany control-plane behavior changes, run focused Rust checks against the touched app-server mapper/handler tests plus any matching native `epiphany-core` smoke binary. The old Python smoke shims have been cut; do not resurrect them.
|
||||
- For Codex Rust work on this Windows machine, set `$env:CARGO_TARGET_DIR='C:\Users\Meta\.cargo-target-codex'`.
|
||||
- Do not parallelize cargo builds or tests against the same target directory.
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
Repository doctrine for this project lives in AGENTS.md, shared with Codex.
|
||||
|
||||
@AGENTS.md
|
||||
3021
Cargo.lock
generated
3021
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
90
Cargo.toml
90
Cargo.toml
|
|
@ -1,90 +0,0 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"epiphany-core",
|
||||
"epiphany-model-adapter",
|
||||
"epiphany-openai-runtime",
|
||||
"epiphany-pipeline",
|
||||
"epiphany-tool-adapter",
|
||||
"epiphany-tool-mcp-runtime",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
[package]
|
||||
name = "epiphany-release-bundle"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
autobins = false
|
||||
license-file = "LICENSE"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
chrono = "0.4.44"
|
||||
ctrlc = { version = "3.5.1", features = ["termination"] }
|
||||
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a0813c6eed24d30bf88073ef615b633c77ebfcd6", package = "cultcache-rs" }
|
||||
cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a0813c6eed24d30bf88073ef615b633c77ebfcd6", package = "cultnet-rs" }
|
||||
epiphany-core = { path = "epiphany-core" }
|
||||
epiphany-model-adapter = { path = "epiphany-model-adapter" }
|
||||
epiphany-openai-runtime = { path = "epiphany-openai-runtime", optional = true }
|
||||
codex-connector = { git = "https://github.com/GameCult/CodexConnector", rev = "be1776ab455fd79bd74b393518ab07211dc9c1f0", default-features = false, optional = true }
|
||||
epiphany-tool-adapter = { path = "epiphany-tool-adapter" }
|
||||
epiphany-tool-mcp-runtime = { path = "epiphany-tool-mcp-runtime", optional = true }
|
||||
rmp-serde = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "rt-multi-thread", "time"] }
|
||||
toml = "0.9"
|
||||
ureq = { version = "3.4", default-features = false, features = ["rustls"], optional = true }
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
zeroize = { version = "1", optional = true }
|
||||
|
||||
[features]
|
||||
openai-runtime = ["dep:codex-connector", "dep:epiphany-openai-runtime", "dep:ureq", "dep:zeroize"]
|
||||
tool-mcp-runtime = ["dep:epiphany-tool-mcp-runtime"]
|
||||
release-runtime = ["openai-runtime", "tool-mcp-runtime"]
|
||||
|
||||
[profile.release]
|
||||
incremental = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[[bin]]
|
||||
name = "epiphany-release"
|
||||
path = "epiphany-core/src/bin/epiphany-release.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "epiphany-state"
|
||||
path = "epiphany-core/src/bin/epiphany-state.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "epiphany-repository-body"
|
||||
path = "epiphany-core/src/bin/epiphany-repository-body.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "epiphany-swarm"
|
||||
path = "epiphany-core/src/bin/epiphany-swarm.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "epiphany-persona-service"
|
||||
path = "epiphany-openai-runtime/src/bin/epiphany-persona-service.rs"
|
||||
required-features = ["openai-runtime"]
|
||||
|
||||
[[bin]]
|
||||
name = "epiphany-persona-discord-permit"
|
||||
path = "epiphany-core/src/bin/epiphany-persona-discord-permit.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "epiphany-mvp-coordinator"
|
||||
path = "epiphany-core/src/bin/epiphany-mvp-coordinator.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "epiphany-model-runtime"
|
||||
path = "epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs"
|
||||
required-features = ["openai-runtime"]
|
||||
|
||||
[[bin]]
|
||||
name = "epiphany-tool-mcp-runtime"
|
||||
path = "epiphany-tool-mcp-runtime/src/main.rs"
|
||||
required-features = ["tool-mcp-runtime"]
|
||||
35
LICENSE
35
LICENSE
|
|
@ -19,10 +19,15 @@ For this file:
|
|||
directory states different terms.
|
||||
- `Upstream Material` means material copied or adapted from third parties and
|
||||
distributed under their own licenses.
|
||||
- `Mixed Apache Files` means files under `vendor/codex/**` that contain
|
||||
project-authored changes on top of upstream Apache-licensed material.
|
||||
|
||||
2. Upstream And Third-Party Material
|
||||
------------------------------------
|
||||
|
||||
- All material under `vendor/codex/**` remains subject to its upstream licenses
|
||||
and notices, including Apache License 2.0 at `vendor/codex/LICENSE` and any
|
||||
applicable `NOTICE` or file-level notices.
|
||||
- Other third-party material distributed with the repository keeps its own
|
||||
license terms where provided.
|
||||
- You must continue to comply with those upstream terms when using,
|
||||
|
|
@ -31,8 +36,8 @@ For this file:
|
|||
3. Public License For Project-Authored Material
|
||||
-----------------------------------------------
|
||||
|
||||
Except where a file or directory says otherwise, Project-Authored Material is
|
||||
licensed to the public under:
|
||||
Except where a file or directory says otherwise, Project-Authored Material
|
||||
outside `vendor/codex/**` is licensed to the public under:
|
||||
|
||||
- PolyForm Noncommercial License 1.0.0
|
||||
|
||||
|
|
@ -61,7 +66,27 @@ entity is formed and designated in writing, any commercial license for
|
|||
Project-Authored Material may be offered directly by the individual copyright
|
||||
holder or holders.
|
||||
|
||||
5. Open Source Status
|
||||
5. Mixed Apache Files Under `vendor/codex/**`
|
||||
---------------------------------------------
|
||||
|
||||
Some files under `vendor/codex/**` contain project-authored changes layered on
|
||||
top of upstream Apache-licensed code.
|
||||
|
||||
For those files:
|
||||
|
||||
- Apache License 2.0 and the applicable upstream notices still apply.
|
||||
- You must not read this repository notice as converting those files into a
|
||||
PolyForm-only codebase.
|
||||
- To the extent the project owns copyright in new additions to those files, the
|
||||
project may also offer separate commercial terms for its own contributions.
|
||||
- This repository notice does not claim to erase or retract Apache-2.0
|
||||
permissions already granted for Apache-covered material.
|
||||
|
||||
The strongest commercial boundary in this repository therefore applies to
|
||||
clearly separable Project-Authored Material outside `vendor/codex/**`,
|
||||
including `epiphany-core/**` and other non-vendored project files.
|
||||
|
||||
6. Open Source Status
|
||||
---------------------
|
||||
|
||||
Because the public license path for Project-Authored Material is PolyForm
|
||||
|
|
@ -69,7 +94,7 @@ Noncommercial License 1.0.0 rather than an OSI-approved license, the
|
|||
repository as a whole is source-available and dual-licensed. It is not an OSI
|
||||
Open Source distribution as a whole.
|
||||
|
||||
6. Reference Structures
|
||||
7. Reference Structures
|
||||
-----------------------
|
||||
|
||||
This licensing structure intentionally borrows the general dual-license shape
|
||||
|
|
@ -82,7 +107,7 @@ This repository does not claim that Qt or MySQL use the same exact public
|
|||
license terms used here. Those projects are referenced only as structural
|
||||
examples of dual licensing, not as the operative terms of this repository.
|
||||
|
||||
7. Contributions
|
||||
8. Contributions
|
||||
----------------
|
||||
|
||||
External contributions are accepted only under the EpiphanyAgent Contributor
|
||||
|
|
|
|||
398
README.md
398
README.md
|
|
@ -1,277 +1,191 @@
|
|||
# Epiphany
|
||||
# EpiphanyAgent
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/epiphany-avatar-4x.png" width="1024" alt="Epiphany avatar portrait targeting JSON heresy" />
|
||||
</p>
|
||||
Epiphany is an opinionated fork of Codex built around one demand: the model
|
||||
must model the thing it is changing.
|
||||
|
||||
Epiphany is GameCult's organizational layer above frontier AI workers: governed
|
||||
shared state, bounded authority, coordinated action, and inspectable receipts
|
||||
for work that must remain coherent beyond one prompt, one agent, or one
|
||||
session.
|
||||
The problem this repo is chasing is not "LLMs can't write code." They can. The
|
||||
problem is that they can keep making plausible local moves after the global
|
||||
design has already wandered off and died in a ditch.
|
||||
|
||||
The bet is simple and unpleasantly large:
|
||||
Epiphany answers that by moving important state out of transcript fog and into
|
||||
typed, inspectable surfaces: maps, evidence, retrieval state, graph/frontier
|
||||
state, churn pressure, and compact client reflections. The goal is not maximal
|
||||
output. The goal is a machine whose parts visibly deserve to exist.
|
||||
|
||||
> Frontier agents can keep producing good-looking local progress after the
|
||||
> architecture has stopped making sense. Organizations need a shared Mind for
|
||||
> the work, not a longer pile of private prompts.
|
||||
## Project Direction
|
||||
|
||||
Epiphany is the control plane for that missing layer. It gives capable workers
|
||||
a governed model of the project, routes bounded work, preserves evidence,
|
||||
separates authority, and stops or escalates when the machine no longer knows
|
||||
enough to act honestly.
|
||||
Epiphany is moving toward project-native agency.
|
||||
|
||||
This is not a faster autocomplete costume. It is the beginning of governed
|
||||
human/agent labor.
|
||||
The user should not have to write a perfect brief, name every subsystem, and
|
||||
pre-plan the code flow for the whole architecture before the machine can help.
|
||||
That is just outsourcing project management to the human and calling the agent
|
||||
smart because it waited politely.
|
||||
|
||||
## Front Door
|
||||
The intended shape is simpler and stranger: you talk to the project.
|
||||
|
||||
- **Value:** the shared typed Mind for agent work: governed project state, role lanes for research, modeling, implementation, verification, planning and Persona, and receipts for commands, edits and commits, so work stays coherent past one prompt, one agent or one session.
|
||||
- **Problem:** global coherence collapse. Agents keep producing plausible local progress after the project has lost a valid global model; Epiphany turns understanding into inspectable state instead of a longer prompt.
|
||||
- **Entry points:**
|
||||
- `AGENTS.md`: operating law and re-entry rites. Read it before touching the machine.
|
||||
- `epiphany-core/`: the typed domain organs (`src/runtime_spine.rs` job, worker, model and tool documents; `src/substrate_gate.rs`; `src/state_model.rs`; `src/mind_documents.rs`).
|
||||
- `cargo run --bin epiphany-state -- status`: the steward CLI for direct Mind inspection (other native binaries are declared in `Cargo.toml`).
|
||||
- `schemas/cultnet/`, `state/map.yaml` (canonical project map), `notes/epiphany-current-algorithmic-map.md` (live control flow).
|
||||
- `docs/positioning.md`: what is claimed and what is not yet earned.
|
||||
- **Live:** a supervised engineering alpha: durable typed state and prompt projection, update/proposal/promotion/review paths, runtime-spine documents, operator status and coordinator commands, heartbeat and Persona-state machinery, and the Hands, Eyes, Soul, Mind, Continuity and Substrate Gate receipt families.
|
||||
- **Planned:** sustained autonomous GameCult production, complete Bifrost attribution, and a longitudinal fleet of one Epiphany organism per project Body. See What Exists Now.
|
||||
Each Epiphany owns one or more repos, carries durable memory and jurisdiction
|
||||
for them, and can grow one or more Personas. A Persona is the public mouth and
|
||||
situated personality of that project: visible in Aquarium, addressable in
|
||||
Discord, suitable for voice or stream presence, and backed by the same typed
|
||||
state, heartbeat initiative, repo map, evidence, and review gates as the rest of
|
||||
the swarm.
|
||||
|
||||
## The GameCult Bet
|
||||
VoidBot is already showing the small version: repo identities such as Nibu,
|
||||
Aqua, and Mimir have Discord roles, repo-local Persona state, proposal authority,
|
||||
pending mentions, and heartbeat turns. Epiphany is the larger native substrate:
|
||||
projects should notice pressure, ruminate, ask questions, schedule modeling or
|
||||
verification, advocate for their own repo needs, and structure work without
|
||||
requiring the human to spell out the implementation graph first.
|
||||
|
||||
GameCult is building infrastructure for coordinated work between humans,
|
||||
projects, communities, and agents. Its games and creative tools are not side
|
||||
projects beside Epiphany. Shipping them is the longitudinal dogfood program.
|
||||
|
||||
The target body is federated: one Epiphany project organism for every living
|
||||
project Body, normally a repo-owned swarm with a private local Mind and one or
|
||||
more project-facing Personas. Those organisms should collaborate through typed
|
||||
offers, claims, requests, evidence, and receipts rather than opening one
|
||||
another's state. Over time, their Personas should form durable, publicly
|
||||
visible relationships with other project Personas, contributors, players, and
|
||||
visitors through deliberate public surfaces. Remembered relationships require
|
||||
opt-in, visible retention limits, inspection, correction, revocation, and exit.
|
||||
|
||||
The investable loop is Bifrost-first:
|
||||
|
||||
```text
|
||||
work request or topic
|
||||
-> scoped agent execution
|
||||
-> bounded artifacts
|
||||
-> maintainer review
|
||||
-> accepted or rejected outcome
|
||||
-> receipts, credit, cost, and lessons
|
||||
```
|
||||
|
||||
Bifrost owns work records, dispatch, receipts, credit, reward pressure, and
|
||||
governance. Epiphany owns agent execution, durable project memory, role
|
||||
coordination, evidence, verification pressure, and continuity. CultMesh and
|
||||
CultNet carry typed state between organs instead of letting private chats,
|
||||
Discord bots, scripts, or dashboards become shadow governments.
|
||||
|
||||
That is the core of the GameCult thesis: not "AI writes code," but "AI work
|
||||
becomes accountable enough to govern, fund, review, credit, and repeat." The
|
||||
studio's products create value and supply the pressure needed to prove the
|
||||
production system in reality.
|
||||
|
||||
## Why Epiphany Exists
|
||||
|
||||
Current agents can write plausible code. That is no longer the hard part.
|
||||
|
||||
The expensive failure is global coherence collapse: an agent keeps moving
|
||||
after the project has lost a valid global model. It adds an adapter around a
|
||||
compensator around a cache, passes a narrow test, and leaves the next worker
|
||||
inheriting fog as if it were architecture.
|
||||
|
||||
This is not merely context-window exhaustion. A larger window can still hold
|
||||
contradictory objectives, stale architecture, split authority, and a handsome
|
||||
collection of unreviewed local victories.
|
||||
|
||||
Epiphany attacks that failure by turning understanding into shared state:
|
||||
|
||||
- the current objective
|
||||
- architecture and dataflow maps
|
||||
- scratch that is disposable
|
||||
- evidence that survives
|
||||
- role lanes for research, modeling, implementation, verification, planning,
|
||||
Persona, and reorientation
|
||||
- runtime, tool, model, command, patch, and commit receipts
|
||||
- review gates before findings become project truth
|
||||
- public/operator-safe artifacts that can be shown without leaking private
|
||||
worker context
|
||||
|
||||
The point is not maximal motion. The point is coherent motion.
|
||||
|
||||
## Organization, Not Prompt Box
|
||||
|
||||
Epiphany treats objectives, work, decisions, permissions, evidence, review, and
|
||||
accepted knowledge as governed organizational state. Model prompts are bounded
|
||||
projections of that state. They are not the durable Mind of the project.
|
||||
|
||||
```text
|
||||
governed organizational state
|
||||
-> scoped Epiphany work
|
||||
-> frontier model and tool execution
|
||||
-> artifacts, evidence, and receipts
|
||||
-> review under human-governed acceptance authority
|
||||
-> accepted knowledge and updated organizational state
|
||||
```
|
||||
|
||||
Humans own purpose, values, authority, exceptions, acceptance, disagreement,
|
||||
and ambiguous tradeoffs. They should not have to spend the day scheduling
|
||||
agents, reconstructing context, or typing `Continue`. Agents continue when
|
||||
state, authority, and evidence justify it; they ask when judgment or authority
|
||||
is genuinely missing.
|
||||
|
||||
The target promise is blunt:
|
||||
|
||||
> Epiphany is being built so organizations can delegate bounded work to
|
||||
> capable AI without turning every employee into an AI operator.
|
||||
|
||||
## What She Is
|
||||
|
||||
Epiphany is a native GameCult runtime that began as an opinionated Codex fork.
|
||||
|
||||
Her body is made of:
|
||||
|
||||
- typed Rust domain organs owned by `epiphany-core`
|
||||
- CultCache `.cc` stores for runtime, heartbeat, agent state, local Verse,
|
||||
memory graph, and thread state
|
||||
- CultMesh and CultNet contracts for local and distributed state
|
||||
- provider-neutral model and tool request/receipt documents
|
||||
- Mind, Substrate Gate, Eyes, Hands, Soul, Continuity, Persona, and heartbeat
|
||||
authority surfaces
|
||||
- a narrow standalone CodexConnector process for OpenAI subscription auth and
|
||||
model transport; Epiphany compiles no Codex crate and keeps cognition local
|
||||
|
||||
That last clause matters. Codex and other frontier agents can be excellent
|
||||
workers. They do not decide which organizational state is authoritative, who
|
||||
may change it, how disagreements resolve, or how an accepted artifact traces
|
||||
back to work, evidence, and review. Epiphany owns that layer.
|
||||
|
||||
## What She Makes Possible
|
||||
|
||||
For engineering teams:
|
||||
|
||||
- agents that work from governed project memory across long work and
|
||||
compaction
|
||||
- visible separation between research, modeling, implementation, and
|
||||
verification
|
||||
- human attention spent on judgment and acceptance instead of agent scheduling
|
||||
and context reconstruction
|
||||
- explicit permission and receipt trails for commands, edits, commits, and
|
||||
tool calls
|
||||
- durable postmortem evidence when a path fails
|
||||
|
||||
For GameCult:
|
||||
|
||||
- project Personas that can speak from repo state without becoming hidden
|
||||
operators
|
||||
- one locally owned Epiphany organism per project, forming a governed swarm of
|
||||
swarms rather than one central intelligence wearing every repository
|
||||
- visitor-facing public surfaces where people can observe, converse, and offer
|
||||
evidence or proposals without receiving hidden authority or exposing private
|
||||
Mind
|
||||
- Bifrost-routed work that produces artifacts, review, receipts, and credit
|
||||
- a commercial-grade agent substrate while free/reference layers can remain
|
||||
open where that is the right covenant
|
||||
- a way for public work logs, design pressure, and contributor effort to become
|
||||
governed production instead of ambient room noise
|
||||
|
||||
For investors and partners:
|
||||
|
||||
- a differentiated wedge in AI-native work governance
|
||||
- measurable proof targets: accepted work between interventions, review burden,
|
||||
cost per accepted artifact, early escalation of bad assumptions, attribution
|
||||
completeness, and failure recovery
|
||||
- a path from internal agent tooling to design-partner workflows, enterprise
|
||||
services, commercial licensing, and mission-aligned infrastructure funding
|
||||
Aquarium is the most direct client because it can show the swarm, its Personas,
|
||||
its heartbeat, its memories, and its decisions. It is not the only mouth. A
|
||||
project should also be reachable through Discord, voice/WebRTC surfaces, stream
|
||||
overlays, native CLIs, and CultNet-speaking tools. The interface contract is
|
||||
not "prompt an agent." It is "speak to the project; inspect how it thinks; grant
|
||||
or refuse authority when it wants to act."
|
||||
|
||||
## What Exists Now
|
||||
|
||||
Epiphany is a supervised engineering alpha. Its typed Mind, execution, receipt,
|
||||
verification, and operator surfaces exist. Sustained autonomous GameCult
|
||||
production and complete Bifrost attribution have not yet been earned.
|
||||
The current spine is real, not aspirational:
|
||||
|
||||
What exists now is enough to evaluate the thesis:
|
||||
- durable `EpiphanyThreadState` in the vendored Codex protocol/core session
|
||||
- rollout snapshots and replay for resume, rollback, fork, and compaction
|
||||
- bounded `<epiphany_state>` prompt injection
|
||||
- typed client reads through `Thread.epiphanyState`
|
||||
- read-only retrieval through `thread/epiphany/retrieve`
|
||||
- explicit semantic indexing through `thread/epiphany/index`
|
||||
- durable typed writes through `thread/epiphany/update`
|
||||
- read-only observation distillation through `thread/epiphany/distill`
|
||||
- read-only map/churn proposal through `thread/epiphany/propose`
|
||||
- verifier-backed promotion through `thread/epiphany/promote`
|
||||
- successful-write notifications through `thread/epiphany/stateUpdated`
|
||||
- durable `jobBindings` as a thin Epiphany-owned launcher seam over runtime job backends
|
||||
- explicit launch and interrupt authority through `thread/epiphany/jobLaunch` and `thread/epiphany/jobInterrupt`, still backed by runtime `agent_jobs`
|
||||
- a bounded reorient-guided worker launch through `thread/epiphany/reorientLaunch`
|
||||
- read-only CRRC next-action recommendations through `thread/epiphany/crrc`
|
||||
- live bound-job progress notifications through `thread/epiphany/jobsUpdated`
|
||||
- read-only reflection surfaces through `thread/epiphany/scene`, `jobs`, `freshness`, `context`, `pressure`, and `reorient`
|
||||
- a first dogfood CLI operator view through `tools/epiphany_mvp_status.py`, including explicit implementation, modeling/checkpoint, verification/review, and reorientation lanes
|
||||
- durable investigation checkpointing for compaction-safe planning
|
||||
- Ghostlight/VoidBot-derived heartbeat initiative with heat, active-turn
|
||||
freeze, idle rumination, sleep/dream maintenance, and Persona as a first-class
|
||||
public-surface participant
|
||||
- repo personality/birth initialization that can seed role dossiers and
|
||||
heartbeat pressure from repo terrain before a project starts speaking
|
||||
- CultNet schema contracts for runtime, heartbeat, Persona, character-turn,
|
||||
Discord-persona, repo-initialization, Rider, Unity, and operator surfaces
|
||||
- repo-owned heavy Epiphany organs in `epiphany-core/`, with vendored Codex kept as the host seam where practical
|
||||
|
||||
- durable typed state and prompt projection
|
||||
- typed update, proposal, promotion, role-result, and review paths
|
||||
- runtime-spine job, worker, model, and tool documents
|
||||
- local operator status and coordinator commands
|
||||
- heartbeat, sleep, memory, and Persona-state machinery inherited from live
|
||||
VoidBot lessons
|
||||
- Hands, Eyes, Soul, Mind, Continuity, and Substrate Gate receipt families
|
||||
- a Modeling whitepaper mapping Epiphany's body, owners, invariants, and
|
||||
cut lines
|
||||
- an investor brief tying Epiphany to the Bifrost-first proof loop
|
||||
Phase 1 through Phase 6 are landed enough for the current experiment. The open
|
||||
questions are now about observability, invalidation, coordination, authority,
|
||||
and safe capability growth, not whether typed state can exist at all.
|
||||
|
||||
The next proof is not another impressive paragraph. It is a longitudinal
|
||||
GameCult fleet across Aetheria, StreamPixels, CultPong, Repixelizer, and the
|
||||
rest of the studio: one project organism per Body, collaborating through
|
||||
governed boundaries while real products ship.
|
||||
## Run Locally
|
||||
|
||||
```text
|
||||
Bifrost work item and decisions
|
||||
-> Epiphany execution
|
||||
-> artifact, commit, evidence, and receipts
|
||||
-> review under human-governed acceptance authority
|
||||
-> accepted or rejected outcome
|
||||
-> cost, intervention, failure, recovery, and credit record
|
||||
The current one-command operator path is:
|
||||
|
||||
```powershell
|
||||
.\tools\epiphany_local_run.ps1
|
||||
```
|
||||
|
||||
The evidence should show whether human intervention shifts from pulse
|
||||
maintenance and cleanup toward product, architecture, and governance judgment.
|
||||
Failure and recovery belong in that record. Otherwise it is merely a victory
|
||||
reel wearing a lab coat.
|
||||
Default mode is `smoke`: it builds the retained Codex app-server compatibility
|
||||
edge, builds the native Epiphany operator binaries, runs the coordinator smoke,
|
||||
and writes launcher artifacts under `.epiphany-run/` plus coordinator evidence
|
||||
under `.epiphany-dogfood/`.
|
||||
|
||||
## Start Here
|
||||
Useful variants:
|
||||
|
||||
For investors and serious business-development readers:
|
||||
```powershell
|
||||
.\tools\epiphany_local_run.ps1 -Mode status
|
||||
.\tools\epiphany_local_run.ps1 -Mode plan
|
||||
.\tools\epiphany_local_run.ps1 -Mode run -MaxSteps 4
|
||||
.\tools\epiphany_local_run.ps1 -Mode mvp -PersonaInput "Wake the local swarm and report through Persona." -MaxSteps 4
|
||||
.\tools\epiphany_local_run.ps1 -Mode status -ThreadId <id> -Workspace E:\Projects\SomeRepo
|
||||
```
|
||||
|
||||
- [Current positioning and evidence boundary](docs/positioning.md)
|
||||
- [Epiphany investor brief](docs/epiphany_investor_brief.md)
|
||||
- [Epiphany Body whitepaper PDF](docs/epiphany_body_whitepaper.pdf) (June 2026
|
||||
architecture snapshot; historical)
|
||||
- [Epiphany Body whitepaper TeX](docs/epiphany_body_whitepaper.tex) (historical
|
||||
source)
|
||||
- [GameCult / Epiphany / Bifrost integrated dossier](https://github.com/GameCult/gamecult-site/blob/main/docs/gamecult_integrated_dossier.tex)
|
||||
for the broader investment thesis
|
||||
`run` is the live coordinator loop. It also builds `epiphany-openai-runtime` and
|
||||
uses the retained Codex auth/model transport spine. `mvp` is the local product
|
||||
cycle: project Persona's character turn, write a Persona/Aquarium bubble, run the
|
||||
bridge-equipped coordinator loop with auto-tools, then run heartbeat sleep/dream
|
||||
maintenance. Fresh `mvp` runs bootstrap a minimal local checkpoint before
|
||||
coordination so the first loop does not stall at missing state. Live coordinator
|
||||
modes use the workspace `state/runtime-spine.cc` for worker lifecycle
|
||||
documents; run bundles remain evidence. Live model-worker turns default to a
|
||||
600-second timeout because ChatGPT Codex model latency can exceed four minutes.
|
||||
`status`, `plan`, and `smoke` do not spend model calls; `run` and `mvp` do.
|
||||
|
||||
For engineers:
|
||||
## Where This Leads
|
||||
|
||||
- [Docs index](docs/README.md)
|
||||
- [Current algorithmic map](notes/epiphany-current-algorithmic-map.md)
|
||||
- [Fork implementation plan](notes/epiphany-fork-implementation-plan.md)
|
||||
- [Anatomy map](notes/epiphany-anatomy.md)
|
||||
- [Safety architecture](notes/epiphany-safety-architecture.md)
|
||||
- [Canonical project map](state/map.yaml)
|
||||
The architectural goal is not one giant agent that does archaeology, design,
|
||||
patching, and self-grading in a single token fog. It is also not a chat box
|
||||
where the human must already know the solution architecture before asking.
|
||||
|
||||
For agents:
|
||||
The goal is a maintained project organism:
|
||||
|
||||
- [AGENTS.md](AGENTS.md)
|
||||
- graph and source-modeling agents keep the machine map current
|
||||
- freshness, watcher input, pressure, and reorientation signals decide whether that map still deserves trust
|
||||
- verifier agents check claims, diffs, and outcomes against reality
|
||||
- coding agents work from bounded packets of graph context, evidence, code refs, and local source reads instead of re-spelunking the repo from scratch every turn
|
||||
- Personas translate project state into conversation, voice, stream presence, and
|
||||
Discord/Aquarium discussion without becoming omniscient hidden operators
|
||||
- initiative scheduling decides which repo, Persona, or internal organ should
|
||||
think next, and heat lets the operator turn up one agent, one project, one
|
||||
group, or the whole swarm
|
||||
|
||||
Read that before touching the machine. It contains the operating law, re-entry
|
||||
rites, and enough anti-Jenga doctrine to keep the next clever patch from
|
||||
becoming tomorrow's cleanup sermon.
|
||||
That still leaves direct source reading where it belongs: at the last mile,
|
||||
when a patch has to be grounded in the real files. The difference is that broad
|
||||
exploration, coherence maintenance, social interface, scheduling, and
|
||||
verification stop living in the same overworked skull.
|
||||
|
||||
## Operator Surfaces
|
||||
## Repo Tour
|
||||
|
||||
Eve/CultMesh projections are the normal human interface. Repository surgery
|
||||
uses the narrow native binaries owned by the packaged runtime; there is no
|
||||
umbrella script that assembles a second local control plane. For direct Mind
|
||||
inspection during development, use the shared-target `epiphany-state` command
|
||||
documented in [AGENTS.md](AGENTS.md).
|
||||
If you want the human-readable map of the project, start here:
|
||||
|
||||
- `notes/epiphany-anatomy.md`: current organ/sub-agent/substrate/protocol split
|
||||
- `notes/epiphany-current-algorithmic-map.md`: source-grounded control flow of the live machine
|
||||
- `notes/epiphany-fork-implementation-plan.md`: distilled forward plan
|
||||
- `notes/epiphany-safety-architecture.md`: capability, authority, interruption, and anti-cage doctrine
|
||||
- `notes/verse-service-contract.md`: GameCult Verse service contract for the
|
||||
missing Eve GUI/TUI operator surface over existing CultCache/CultMesh state
|
||||
- `notes/archive/README.md`: historical ledgers and superseded plans
|
||||
- `state/map.yaml`: canonical current project map and accepted design
|
||||
|
||||
If you want the code:
|
||||
|
||||
- `epiphany-core/`: repo-owned Epiphany logic
|
||||
- `vendor/codex/`: vendored Codex host substrate
|
||||
- `tools/`: smoke tests and state helpers
|
||||
|
||||
If you are an agent or you are steering one, read `AGENTS.md`. That file is
|
||||
for operating discipline, re-entry protocol, and session hygiene. The README is
|
||||
for people, which is a lower-crime use of everyone's time.
|
||||
|
||||
## Design Stance
|
||||
|
||||
Epiphany is built on a few stubborn ideas:
|
||||
|
||||
- externalized state is better than pretending the transcript is a brain
|
||||
- local plausibility is not the same thing as global coherence
|
||||
- evidence should survive, but activity feed sludge should not
|
||||
- cognition should grow faster than authority
|
||||
- interruption, legibility, and explicit permissions matter more as the machine becomes more coherent
|
||||
|
||||
This repo is not trying to make the model louder. It is trying to make it less
|
||||
likely to build a Jenga tower and call it understanding.
|
||||
|
||||
## License
|
||||
|
||||
The root `LICENSE` is the operative repository notice.
|
||||
The root `LICENSE` is the operative repository notice. In short:
|
||||
`vendor/codex/**` and other third-party material keep their upstream licenses;
|
||||
project-authored material outside `vendor/codex/**` is publicly available under
|
||||
PolyForm Noncommercial 1.0.0 and is intended to be available under separate
|
||||
commercial terms by written agreement.
|
||||
|
||||
In short: vendored upstream material keeps its upstream license. GameCult-authored
|
||||
Epiphany material is source-available under PolyForm Noncommercial 1.0.0, with
|
||||
separate commercial terms available by written agreement.
|
||||
External contributions require `CONTRIBUTOR_LICENSE_AGREEMENT.md` or a separate
|
||||
written agreement accepted by the project steward.
|
||||
|
||||
The publishing stance is direct:
|
||||
The publishing stance is:
|
||||
|
||||
- free where freedom is the right covenant
|
||||
- source-available where unrestricted extraction would be bad governance
|
||||
- commercial where organizations are extracting enterprise value
|
||||
- FOSS where that is viable
|
||||
- source-available where the economics or capability profile make unrestricted release a bad idea
|
||||
- commercial terms where organizations are extracting real enterprise value
|
||||
|
|
|
|||
|
|
@ -4,19 +4,15 @@ The Aquarium operator UI moved to its own sibling repository:
|
|||
|
||||
`E:\Projects\EpiphanyAquarium`
|
||||
|
||||
Epiphany remains the native typed-state, coordination, execution, and backend
|
||||
organism. Aquarium is the primary graphical client over those surfaces.
|
||||
EpiphanyAgent remains the harness, typed-state, coordinator, bridge, and backend
|
||||
repo. Aquarium is the Tauri/React/WebGL interface organism over those surfaces.
|
||||
|
||||
Aquarium is the primary client for the next public shape of Epiphany: projects
|
||||
with Faces. A user should be able to talk to a project, not assemble a perfect
|
||||
agent prompt. Aquarium should show the repo's Face, heartbeat, memory, pending
|
||||
turns, current jurisdiction, proposed actions, evidence, conflicts,
|
||||
uncertainty, and requests for authority so the human can converse, inspect,
|
||||
approve, refuse, or raise pressure without becoming the agent scheduler.
|
||||
|
||||
Routine continuation belongs to Epiphany's physiology. Human attention belongs
|
||||
on purpose, judgment, exceptions, disagreement, and acceptance. Conversation is
|
||||
an input and relationship surface, not the hidden operating system.
|
||||
with Personas. A user should be able to talk to a project, not assemble a perfect
|
||||
agent prompt. Aquarium should show the repo's Persona, heartbeat, memory, pending
|
||||
turns, current jurisdiction, proposed actions, and evidence so the human can
|
||||
converse, inspect, approve, refuse, or raise heat without pre-writing the whole
|
||||
architecture brief.
|
||||
|
||||
Discord roles, WebRTC/voice surfaces, stream overlays, native CLIs, and other
|
||||
CultNet clients are sibling mouths over the same substrate. Aquarium is the
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
# Epiphany Docs
|
||||
|
||||
This folder packages the human-readable Epiphany material that should survive
|
||||
outside the working notes.
|
||||
|
||||
## Investor Packet
|
||||
|
||||
- [Current positioning](./positioning.md): category, operating model, human
|
||||
authority boundary, GameCult dogfood program, attribution target, and claims
|
||||
the supervised alpha has not yet earned.
|
||||
- [Epiphany investor brief](./epiphany_investor_brief.md): compact value and
|
||||
diligence summary for investors, partners, and design-partner conversations.
|
||||
- [Epiphany Body whitepaper](./epiphany_body_whitepaper.tex): source TeX for
|
||||
the June 2026 Modeling architecture snapshot. This is historical material;
|
||||
use the positioning document and canonical maps for current product claims.
|
||||
- [Epiphany Body whitepaper PDF](./epiphany_body_whitepaper.pdf): compiled
|
||||
historical snapshot for sharing with that caveat.
|
||||
|
||||
## Source Notes
|
||||
|
||||
- [Investor readiness roadmap](../notes/epiphany-investor-readiness-roadmap.md)
|
||||
names the Bifrost-first proof rail and metrics.
|
||||
- [Algorithmic map](../notes/epiphany-current-algorithmic-map.md) records the
|
||||
live control flow.
|
||||
- [Fork implementation plan](../notes/epiphany-fork-implementation-plan.md)
|
||||
records the current architecture and cut lines.
|
||||
- [Project map](../state/map.yaml) is the canonical slow machine map.
|
||||
|
||||
For historical GameCult-level investor context, the sibling site dossier lives
|
||||
at `F:\Projects\gamecult-site\docs\gamecult_integrated_dossier.tex`. It is a
|
||||
May 2026 snapshot; the current positioning and investor brief above supersede
|
||||
its Epiphany category and maturity framing.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.9 MiB |
Binary file not shown.
|
|
@ -47,33 +47,34 @@ and the difference between the map and the substrate.
|
|||
The practical definition in this repository is:
|
||||
|
||||
\begin{itemize}
|
||||
\item the root checkout at \code{F:\Projects\Epiphany};
|
||||
\item Epiphany Rust crates such as \code{epiphany-core}, model/tool adapters, and provider runtimes;
|
||||
\item the root checkout at \code{E:\Projects\EpiphanyAgent};
|
||||
\item Epiphany Rust crates such as \code{epiphany-core}, \code{epiphany-state-model}, \code{epiphany-codex-bridge}, model/tool adapters, and OpenAI transport crates;
|
||||
\item vendored substrate in \code{vendor/codex}, \code{vendor/cultcache-rs}, \code{vendor/cultnet-rs}, and \code{vendor/cultmesh-rs};
|
||||
\item typed state stores under \code{state/};
|
||||
\item schemas under \code{schemas/cultnet/};
|
||||
\item local operator scripts and generated witness artifacts;
|
||||
\item adjacent service/client bodies: Aquarium, Odin/Gjallar, Eve/CultUI,
|
||||
Bifrost, VoidBot, Brokkr, Discord, and future GameCult Verse providers.
|
||||
\item adjacent service/client bodies: Aquarium, Odin/Gjallar, Eve/CultUI, Bifrost, VoidBot, Rider, Unity, Discord, and future GameCult Verse hosts.
|
||||
\end{itemize}
|
||||
|
||||
\section{Executive Map}
|
||||
|
||||
Epiphany's current Body is a typed Rust/CultCache organism with provider
|
||||
transport outside its cognition boundary. Codex requests cross a lean client
|
||||
ABI into the independent CodexConnector daemon, which owns subscription
|
||||
credential refresh, transport, and replay. Epiphany owns prompts, tools,
|
||||
reasoning context, decisions, and Mind admission; it compiles no Codex crate.
|
||||
Epiphany's current Body is a hybrid machine in transition. Its center of gravity
|
||||
has moved into typed Rust/CultCache organs, but compatibility arteries still
|
||||
run through vendored Codex. The live target is not a cleaner pile of Codex
|
||||
routes. The target is an Epiphany-native CultCache/CultMesh/CultNet body with a
|
||||
relatively vanilla Codex-derived reliquary retained for OpenAI subscription
|
||||
auth and model transport.
|
||||
|
||||
\begin{longtable}{>{\raggedright\arraybackslash}p{0.23\linewidth}>{\raggedright\arraybackslash}p{0.32\linewidth}>{\raggedright\arraybackslash}p{0.35\linewidth}}
|
||||
\textbf{Body Region} & \textbf{Current Owner} & \textbf{Invariant} \\
|
||||
\hline
|
||||
Durable Mind state & keyed CultCache documents assembled by \code{epiphany-core} & Prompt rendering consumes sealed typed projections; no persisted thread aggregate or global authority revision exists. \\
|
||||
Durable thread state & \code{epiphany-state-model}, \code{epiphany-core} & State shape is typed; prompt rendering is a projection, not authority. \\
|
||||
Domain policy & \code{epiphany-core} & Verdicts, launch policy, acceptance rules, runtime lifecycle, and mutation law live here, not in bridge DTOs. \\
|
||||
Codex transport & independent \code{GameCult/CodexConnector} daemon & Own subscription credentials, provider transport, and exact replay only; never own Epiphany prompts, scheduler, decisions, or state. \\
|
||||
Runtime spine & \code{epiphany-core/src/runtime_spine.rs} and \code{state/runtime-spine.cc} & Runtime identity, jobs, receipts, and tool/model documents are typed CultCache records. \\
|
||||
Persona memory & keyed Mind Persona-memory documents & Persona decisions admit bounded memories by semantic identity; no parallel role-memory aggregate or generation head exists. \\
|
||||
Resident Self physiology & Resident Self CultCache documents via \code{epiphany-swarm} & Typed pressure, exact grant creation, launch exclusion, leases, terminal receipts, retry, and bounded retention have one owner. No scheduler singleton or acknowledgement loop exists. \\
|
||||
Compatibility bridge & \code{epiphany-codex-bridge} & Translate Codex edge shapes; never become a second policy throne. \\
|
||||
Vendored Codex & \code{vendor/codex} & Retain host/app-server/auth/model compatibility where useful; do not let Codex own Epiphany prompts, scheduler, or state. \\
|
||||
Runtime spine & \code{epiphany-core/src/runtime_spine.rs} and \code{state/runtime-spine.cc} & Runtime identity, jobs, events, receipts, tool/model documents are typed CultCache records. \\
|
||||
Organ memory & \code{state/agents.cc}; agent-memory CLI & Resident work organs use lean work-organ state; Persona uses portable Persona state. \\
|
||||
Heartbeat physiology & \code{state/agent-heartbeats.cc} via \code{epiphany-heartbeat-store} & Scheduling, cooldown, rumination, routine, cognition lanes, and active-turn freeze are scheduler physiology, not project truth. \\
|
||||
Local Verse & CultMesh over \code{state/local-verse.cc} & Local Verse context is compact prompt/operator context; private state does not become public by flipping a flag. \\
|
||||
Schema catalog & \code{schemas/cultnet/index.json} & JSON schema publishes contracts; typed Rust documents remain runtime authority. \\
|
||||
Operator artifacts & \code{.epiphany-*}, \code{tools/epiphany_local_run.ps1} & Artifacts witness and lower state; they do not become state owners. \\
|
||||
|
|
@ -81,34 +82,42 @@ Operator artifacts & \code{.epiphany-*}, \code{tools/epiphany_local_run.ps1} & A
|
|||
|
||||
\section{Repository Body}
|
||||
|
||||
The checkout has eight primary Epiphany Rust packages:
|
||||
The checkout has ten primary Epiphany Rust crates:
|
||||
|
||||
\begin{itemize}
|
||||
\item \code{epiphany-core}: the main domain organ. It owns semantic state
|
||||
contracts, keyed Mind admission, launch documents, runtime spine, Resident
|
||||
Self, Persona projection, Substrate Gate, Eyes, Hands, Soul, Continuity,
|
||||
CultMesh integration, and surface derivation.
|
||||
\item \code{epiphany-core}: the main domain organ. It owns state-update
|
||||
validation, launch documents, runtime spine, heartbeat, memory graph, Persona
|
||||
projection, Substrate Gate, Mind, Eyes, Hands, Soul, Continuity, CultMesh
|
||||
integration, surface derivation, smokes, and local operator binaries.
|
||||
\item \code{epiphany-state-model}: the durable \code{EpiphanyThreadState}
|
||||
type plus \code{render_epiphany_state}. This is the typed Mind surface that
|
||||
prompt fragments and views read.
|
||||
\item \code{epiphany-codex-bridge}: the translation layer between typed
|
||||
Epiphany documents and vendored Codex JSON-RPC/app-server shapes. Its own
|
||||
crate header states that it is a bridge, not an authority organ.
|
||||
\item \code{epiphany-model-adapter}: provider-neutral model request, stream
|
||||
event, receipt, and status documents.
|
||||
\item \code{epiphany-openai-adapter}: OpenAI-shaped request/event/receipt and
|
||||
status documents.
|
||||
\item \code{epiphany-openai-auth-spine}: Codex-derived auth boundary over
|
||||
\code{codex-login}. It preserves honest Codex-compatible credential and token
|
||||
behavior rather than impersonating it from scratch.
|
||||
\item \code{epiphany-openai-codex-spine}: model transport over vendored Codex
|
||||
client machinery.
|
||||
\item \code{epiphany-openai-runtime}: executable model runtime, including
|
||||
\code{epiphany-model-runtime}.
|
||||
\item \code{epiphany-release-construction}: exact package construction and
|
||||
release-witness verification.
|
||||
\item \code{epiphany-tool-adapter}: typed tool capability, invocation intent,
|
||||
and invocation receipt documents.
|
||||
\item \code{epiphany-tool-mcp-runtime}: first-party tool executor. Native
|
||||
Epiphany source tools are explicit local capabilities; foreign MCP calls use
|
||||
the official RMCP SDK behind Epiphany-owned endpoint, process, network, and
|
||||
receipt policy. Codex owns no tool discovery or execution path.
|
||||
\item \code{epiphany-tool-codex-mcp-spine}: quarantined Codex MCP adapter. It
|
||||
consumes typed tool invocation intents and emits typed receipts; raw MCP JSON
|
||||
remains adapter-edge cargo.
|
||||
\end{itemize}
|
||||
|
||||
The vendored region contains:
|
||||
|
||||
\begin{itemize}
|
||||
\item \code{vendor/codex}: retained Codex-fork source history. It is not in
|
||||
Epiphany's compiled runtime graph.
|
||||
\item \code{vendor/codex}: the Codex-derived host and compatibility substrate.
|
||||
It is tracked directly, not as a submodule.
|
||||
\item \code{vendor/cultcache-rs}, \code{vendor/cultnet-rs}, and
|
||||
\code{vendor/cultmesh-rs}: repo-contained Rust Cult substrate.
|
||||
\end{itemize}
|
||||
|
|
@ -127,11 +136,12 @@ implementation, not file authority.
|
|||
\hline
|
||||
\code{state/map.yaml} & Canonical slow project map: objective, constraints, invariants, dataflow, active subgoals, current status, guardrails, and rejected paths. \\
|
||||
\code{state/ledgers.cc} & CultCache-shaped evidence and branch ledger. Future behavior-changing lessons go here, not in an endless activity feed. \\
|
||||
Resident Self store & Keyed pressure, grants, prepared and active launch authority, terminal receipts, and retention head. \\
|
||||
Persona social store & Keyed mentions, immutable turn requests, terminal receipts, quarantine records, and retention plans. \\
|
||||
\code{state/runtime-spine.cc} & Runtime identity, sessions, jobs, results, worker launch requests, receipts, and model/tool transport documents. Durable decisions and keyed RepoModel documents live in Mind. \\
|
||||
\code{state/agents.cc} & Organ self-memory and Persona projection source. Resident work organs use \code{work_organ}; public/person-shaped agents use \code{persona}. \\
|
||||
\code{state/agent-heartbeats.cc} & Heartbeat initiative, cooldown, pending turns, cognition lanes, routine physiology, and scheduler pressure. \\
|
||||
\code{state/runtime-spine.cc} & Runtime identity, sessions, jobs, job results, events, worker launch requests, role/reorient findings, receipts, model/tool documents, and schema catalog surfaces. \\
|
||||
\code{state/thread-state.cc} & Native thread-state store. \\
|
||||
\code{state/local-verse.cc} & Local CultMesh Verse context for compact operator and prompt-context packets. \\
|
||||
\code{state/semantic-projection.cc} & Typed Qdrant projection receipts. Cache health lives here, outside the canonical RepoModel. \\
|
||||
\code{state/memory-graph.cc} & Local memory graph store. \\
|
||||
\code{state/persona-discord.toml} & Persona Discord boundary configuration. \\
|
||||
\code{state/void-memory.toml} & Void memory bridge configuration. \\
|
||||
\code{state/scratch.md} and \code{state/scratch-compaction-*.md} & Scratch surfaces. Scratch is a workbench, not canonical memory. \\
|
||||
|
|
@ -139,18 +149,30 @@ Persona social store & Keyed mentions, immutable turn requests, terminal receipt
|
|||
|
||||
Lock files sit beside several stores. Some local generated state is currently
|
||||
untracked, including \code{state/local-verse.cc},
|
||||
\code{state/semantic-projection.cc}, and \code{.epiphany-character-loop/}.
|
||||
The former sibling \code{state/memory-graph.cc}/\code{.msgpack} store is a
|
||||
retired migration wound; live Modeling truth is the runtime-spine RepoModel.
|
||||
\code{state/memory-graph.cc}, and \code{.epiphany-character-loop/}.
|
||||
|
||||
\section{CultNet Schema Body}
|
||||
|
||||
The schema catalog in \code{schemas/cultnet/index.json} publishes the portable
|
||||
contracts actually carried across a CultNet boundary. Runtime-local CultCache
|
||||
registrations are discovered from the native registry instead of mirrored into
|
||||
a second hand-maintained catalog. Editor commands are not Epiphany contracts:
|
||||
Brokkr owns Unity editor inspection and actuation through Eve/CultMesh, and a
|
||||
future Rider provider must own Rider integration through the same boundary.
|
||||
The schema catalog in \code{schemas/cultnet/index.json} is the public nerve
|
||||
chart. Current local grouping from the catalog is approximately:
|
||||
|
||||
\begin{itemize}
|
||||
\item 10 agent-state schemas;
|
||||
\item 5 heartbeat schemas;
|
||||
\item 21 intent schemas;
|
||||
\item 8 model/OpenAI schemas;
|
||||
\item 3 tool schemas;
|
||||
\item 6 runtime schemas;
|
||||
\item 17 surface schemas;
|
||||
\item 4 receipt/ledger schemas;
|
||||
\item 7 other schemas.
|
||||
\end{itemize}
|
||||
|
||||
The catalog includes runtime identity/session/job/event/result documents, model
|
||||
requests and receipts, tool invocation intents and receipts, Persona and
|
||||
work-organ state, heartbeat state, operator CultMesh documents, scene/freshness
|
||||
/context/graph/pressure/reorient/roles/coordinator surfaces, Unity and Rider
|
||||
bridge surfaces, Void memory, and repo-initialization documents.
|
||||
|
||||
The invariant is simple: schema JSON describes the contract. Load-bearing
|
||||
runtime state should be typed Rust documents persisted through CultCache or
|
||||
|
|
@ -163,20 +185,19 @@ publication, sealed forensic artifacts, or named quarantine experiments.
|
|||
the Body. It imports and registers:
|
||||
|
||||
\begin{itemize}
|
||||
\item runtime identity, session, job, and job-result records;
|
||||
\item runtime identity, session, job, job-result, and event records;
|
||||
\item worker launch requests and role/reorient worker result documents;
|
||||
\item model adapter, OpenAI adapter, and tool adapter documents;
|
||||
\item sealed reasoning bases, decision contexts, typed pass failures, and
|
||||
exact Mind commit receipts;
|
||||
\item keyed RepoModel, frontier, planning, reorientation, and Persona
|
||||
lifecycle documents;
|
||||
\item exact Eyes, Hands, Soul, Continuity, and Substrate Gate receipts.
|
||||
\item surface document types for scene, freshness, context, graph query,
|
||||
pressure, reorient, CRRC, jobs, roles, role result, reorient result,
|
||||
planning, coordinator, Persona, Void memory, repo initialization, repo birth
|
||||
runner, Rider bridge, and Unity bridge;
|
||||
\item organ receipts for Mind, Eyes, Hands, Soul, Continuity, and Substrate
|
||||
Gate;
|
||||
\item CultMesh operator status, snapshots, run intents, run receipts, Verse
|
||||
policy, global room policy, and organ contract documents.
|
||||
\end{itemize}
|
||||
|
||||
Editor-provider capabilities remain outside this registry. Epiphany consumes
|
||||
their typed CultMesh state and receipts; it does not embed an IDE bridge or
|
||||
editor-specific lifecycle authority.
|
||||
|
||||
Runtime state is machinery telemetry. It is not project truth. Durable thread
|
||||
state may link to runtime records through runtime links, but it must not mirror
|
||||
or duplicate their lifecycle fields as a competing owner.
|
||||
|
|
@ -212,12 +233,14 @@ move through Substrate-Gate-scoped facts, Mind state, social stimulus, organ
|
|||
dependencies, Imagination as Projector, Persona as natural-language actor, and
|
||||
Mind as Interpreter. None of those steps grants Persona hidden state authority.
|
||||
|
||||
\section{Codex Reliquary}
|
||||
\section{Bridge And Codex Reliquary}
|
||||
|
||||
Epiphany publishes no app-server JSON-RPC state surface. Vendored Codex owns
|
||||
only Codex concerns and the retained OpenAI-compatible auth/model transport
|
||||
machinery. Epiphany state, prompts, scheduling, interfaces, and durable
|
||||
invariants remain native typed organs.
|
||||
\code{epiphany-codex-bridge} states its boundary plainly: it translates Codex
|
||||
\code{ThreadEpiphany*} DTOs into Epiphany core documents, projects typed
|
||||
Epiphany surfaces back into legacy Codex responses, and calls narrow host traits
|
||||
while Codex still owns some thread persistence. It must not own verdicts,
|
||||
launch policy, reorientation policy, acceptance rules, runtime-spine lifecycle,
|
||||
or durable invariants.
|
||||
|
||||
The retained Codex body currently matters for:
|
||||
|
||||
|
|
@ -238,13 +261,17 @@ truth, operator API owner, or plugin marketplace brain.
|
|||
Current operator access is partly native and partly witness-artifact based:
|
||||
|
||||
\begin{itemize}
|
||||
\item Native binaries such as \code{epiphany-state},
|
||||
\code{epiphany-mvp-coordinator}, \code{epiphany-runtime-spine},
|
||||
\code{epiphany-swarm}, and the family-specific worker services
|
||||
inspect or update their owned typed state.
|
||||
\item \code{tools/epiphany_local_run.ps1} is the one-command local operator
|
||||
wrapper.
|
||||
\item Native binaries such as \code{epiphany-mvp-status},
|
||||
\code{epiphany-mvp-coordinator}, \code{epiphany-verse-query},
|
||||
\code{epiphany-operator-run}, \code{epiphany-operator-snapshot},
|
||||
\code{epiphany-eve-surface}, \code{epiphany-runtime-spine},
|
||||
\code{epiphany-heartbeat-store}, and \code{epiphany-agent-memory-store}
|
||||
inspect or update typed state.
|
||||
\item Local artifact directories such as \code{.epiphany-run/},
|
||||
\code{.epiphany-dogfood/}, \code{.epiphany-smoke/},
|
||||
\code{.epiphany-gui/}, and
|
||||
\code{.epiphany-rumination/}, \code{.epiphany-gui/}, and
|
||||
\code{.epiphany-character-loop/} witness work and lower typed state for
|
||||
operator inspection.
|
||||
\item Aquarium is the richer adjacent operator client. It should render and
|
||||
|
|
@ -291,10 +318,9 @@ Proprioception refresh.
|
|||
\subsection{Tool Calls}
|
||||
|
||||
Tool use travels through typed capability, invocation intent, and invocation
|
||||
receipt documents. The first-party tool runtime owns admission policy, native
|
||||
tools, and typed receipts while the official RMCP SDK owns MCP lifecycle and
|
||||
wire behavior. MCP's raw JSON belongs at that boundary; Epiphany's internal
|
||||
tool state is the typed intent/receipt pair.
|
||||
receipt documents. The Codex MCP adapter is an external compatibility executor.
|
||||
MCP's raw JSON belongs at that boundary; Epiphany's internal tool state is the
|
||||
typed intent/receipt pair.
|
||||
|
||||
\subsection{Local Verse And Daemon Awareness}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,220 +0,0 @@
|
|||
# Epiphany Investor Brief
|
||||
|
||||
Status: August 2026 discussion packet; maturity: supervised engineering alpha
|
||||
Audience: investors, design partners, and diligence reviewers
|
||||
|
||||
## One Sentence
|
||||
|
||||
Epiphany is the organizational layer above frontier AI workers: governed shared
|
||||
state, bounded authority, coordinated action, and inspectable receipts for work
|
||||
that must remain coherent beyond one prompt, one agent, or one session.
|
||||
|
||||
## Why It Matters
|
||||
|
||||
Frontier coding agents can produce plausible local edits after the project has
|
||||
lost a valid global model. Code compiles. Narrow tests pass. Each decision sounds
|
||||
reasonable. The organization still accumulates stale assumptions, split
|
||||
authority, contradictory decisions, and adapter sediment.
|
||||
|
||||
This is global coherence collapse, not merely context-window exhaustion. A
|
||||
larger window can remember more fragments without deciding which fragment owns
|
||||
reality.
|
||||
|
||||
Epiphany turns the missing organizational state into governed objects:
|
||||
|
||||
- objectives, work, architecture, and dataflow maps
|
||||
- accepted decisions and unresolved conflicts
|
||||
- scratch separated from durable memory
|
||||
- source-grounded evidence and explicit uncertainty
|
||||
- role lanes for execution, modeling, research, verification, planning,
|
||||
Persona, and reorientation
|
||||
- bounded tool, model, command, patch, and commit authority
|
||||
- review gates before findings become project truth
|
||||
- operator-safe artifacts that do not expose private worker context
|
||||
|
||||
Prompts are bounded projections of that state. They are not the organization's
|
||||
memory or the worker's private property.
|
||||
|
||||
## Product Category
|
||||
|
||||
Epiphany does not compete with frontier labs at the intelligence layer. Codex
|
||||
and other capable agents are workers. Epiphany is the coordination and
|
||||
governance layer that answers:
|
||||
|
||||
- What does the organization currently believe?
|
||||
- What work is active, and who may change which state?
|
||||
- How are contradictory decisions reconciled?
|
||||
- Where must uncertainty return to human judgment?
|
||||
- How does an accepted artifact trace back to work, evidence, execution, and
|
||||
review?
|
||||
|
||||
The operating loop is:
|
||||
|
||||
```text
|
||||
governed organizational state
|
||||
-> scoped Epiphany work
|
||||
-> frontier model and tool execution
|
||||
-> artifacts, evidence, and receipts
|
||||
-> review under human-governed acceptance authority
|
||||
-> accepted knowledge and updated organizational state
|
||||
```
|
||||
|
||||
The target promise is that organizations can delegate bounded work to capable
|
||||
AI without turning every employee into an AI operator.
|
||||
|
||||
## Human Authority
|
||||
|
||||
Humans remain participants in judgment and governance. They own purpose,
|
||||
values, authority, exceptions, acceptance, disagreement, and ambiguous
|
||||
tradeoffs. They should not become schedulers who repeatedly reconstruct context
|
||||
and type `Continue`.
|
||||
|
||||
Agents own bounded execution. They continue when shared state, authority, and
|
||||
evidence justify the next move. They escalate when authority is missing, state
|
||||
conflicts, or a consequential judgment cannot be derived honestly.
|
||||
|
||||
## What Exists Now
|
||||
|
||||
The current native Epiphany body includes:
|
||||
|
||||
- typed Rust domain organs owned by `epiphany-core`
|
||||
- keyed Mind state with typed projection and promotion paths
|
||||
- RepoModel and Model Atlas surfaces for source-grounded architectural belief
|
||||
- CultCache `.cc` state and CultMesh/CultNet contracts
|
||||
- provider-neutral model request, event, and receipt documents
|
||||
- typed tool capability, invocation-intent, and invocation-receipt documents
|
||||
- runtime-spine job and worker-result documents
|
||||
- Mind, Modeling, Substrate Gate, Eyes, Hands, Soul, Continuity, Persona, and
|
||||
heartbeat authority surfaces
|
||||
- local operator status, coordinator, and Verse-context commands
|
||||
|
||||
Codex is retained only for OpenAI authentication and model transport. Epiphany
|
||||
does not publish Codex app-server state as its project Mind.
|
||||
|
||||
The mechanisms are locally verifiable. Sustained autonomous GameCult production
|
||||
and complete attribution across every mutation path are not yet proven.
|
||||
|
||||
## GameCult As The Proof Program
|
||||
|
||||
GameCult's games, creative tools, and infrastructure are the dogfood surface.
|
||||
Aetheria, StreamPixels, CultPong, Repixelizer, and the wider studio exercise
|
||||
different parts of the same production system: games, realtime services,
|
||||
creator tooling, art pipelines, identity, deployment, community memory, and
|
||||
long-lived architecture.
|
||||
|
||||
The target operating body is federated: one locally owned Epiphany project
|
||||
organism per project Body, normally a repo-owned swarm with a private Mind and
|
||||
one or more project-facing Personas. Project organisms exchange typed offers,
|
||||
claims, requests, evidence, and receipts; none receives ambient authority to
|
||||
inspect or change a sibling project. This creates a swarm-of-swarms proof
|
||||
surface instead of one central agent with a studio-sized prompt.
|
||||
|
||||
The longer social direction is for project Personas to maintain durable,
|
||||
publicly visible relationships with other Personas, contributors, players, and
|
||||
visitors. Public surfaces may support observation, conversation, and submitted
|
||||
evidence or proposals. They must not expose private Mind or turn popularity,
|
||||
repetition, or relationship into admission or execution authority. The current
|
||||
alpha does not yet provide the full fleet, visitor experience, or social
|
||||
governance rail. Any remembered relationship requires opt-in, visible retention
|
||||
limits, inspection, correction, revocation, and exit.
|
||||
|
||||
The diligence artifact should therefore be longitudinal rather than a polished
|
||||
single demo. It should preserve:
|
||||
|
||||
```text
|
||||
work item and decisions
|
||||
-> producing agent and execution
|
||||
-> artifacts, commits, evidence, and receipts
|
||||
-> human questions and answers
|
||||
-> review and acceptance
|
||||
-> cost, failure, recovery, and lessons
|
||||
```
|
||||
|
||||
Useful measures include accepted work between synchronous interventions, time
|
||||
spent on judgment versus scheduling, bad assumptions escalated early, review
|
||||
burden, cost per accepted artifact, failure recovery, architectural coherence,
|
||||
and attribution completeness. Agent-written commit percentage is not a useful
|
||||
substitute for this evidence.
|
||||
|
||||
## Where Bifrost Fits
|
||||
|
||||
Bifrost is the intended governed work and attribution rail. Epiphany owns
|
||||
bounded execution, typed project memory, coordination, evidence, and
|
||||
verification pressure. Bifrost owns work records, dispatch, identity-linked
|
||||
receipts, review outcomes, credit, and governance.
|
||||
|
||||
The target invariant is:
|
||||
|
||||
> Every admitted mutation of governed project state has an attributable
|
||||
> mutation receipt naming its actor, authority, basis, and outcome. An
|
||||
> agent-caused mutation also links its exact execution receipt.
|
||||
|
||||
The complete agent-execution chain should connect an accepted artifact or commit to the
|
||||
producing agent, authorizing or accepting human, Bifrost execution, work item,
|
||||
decisions, evidence, model and provider, tools and material commands, repository
|
||||
and worktree, and review outcome.
|
||||
|
||||
A Git object hash makes later changes detectable. A valid commit signature
|
||||
links those commit bytes to a trusted signing key. Neither proves correctness,
|
||||
execution authority, or complete provenance. The full chain remains an
|
||||
engineering and dogfood target, not a completed claim.
|
||||
|
||||
## Near-Term Commercial Offer
|
||||
|
||||
The planned offer, after the Bifrost bridge and longitudinal GameCult dogfood
|
||||
clear their gates, is a supervised co-development pilot: one repository, one
|
||||
repeatable workflow, and a two-to-four-week proof period. The deliverable would
|
||||
be a working setup plus evidence about:
|
||||
|
||||
- output quality and accepted artifacts
|
||||
- human scheduling and review burden
|
||||
- authority and attribution coverage
|
||||
- failure modes and recovery
|
||||
- model and tool cost
|
||||
- architectural coherence across repeated work
|
||||
|
||||
That is narrow enough to falsify and useful enough to reveal whether Epiphany
|
||||
reduces coordination load while preserving human authority.
|
||||
|
||||
## Proof To Ask For
|
||||
|
||||
1. Repeated GameCult work showing governed scope, decisions, execution,
|
||||
artifacts, review, failures, and recovery across product boundaries.
|
||||
2. Bifrost work and attribution proof from accepted artifact back to identity,
|
||||
authority, evidence, model, tools, and review.
|
||||
3. Human-intervention accounting that separates scheduling and cleanup from
|
||||
product, architecture, governance, and acceptance judgment.
|
||||
4. Cost accounting for model spend, role time, review load, accepted artifacts,
|
||||
and rejected-output reasons.
|
||||
5. Public/private export proof: no raw worker thoughts, transcripts, private
|
||||
notes, secrets, or operator context in public artifacts.
|
||||
6. Security proof for identity, secrets, write permissions, revocation,
|
||||
external-repo access, and publishing.
|
||||
7. A bounded design-partner run on an external or semi-external repository
|
||||
without supervisor contamination.
|
||||
|
||||
## Evidence Boundary
|
||||
|
||||
Epiphany is a supervised engineering alpha. It should not be presented as
|
||||
production-ready, fully autonomous, fully attributable, model-independent, or
|
||||
a solved form of organizational intelligence. Inspectability enables
|
||||
accountability only when authority, review, and consequence are also real.
|
||||
|
||||
## Packet Contents
|
||||
|
||||
- `docs/positioning.md`: current category, proof model, and claims boundary.
|
||||
- `docs/epiphany_body_whitepaper.pdf`: June 2026 architecture snapshot;
|
||||
historical, not the current product contract.
|
||||
- `notes/epiphany-investor-readiness-roadmap.md`: internal readiness roadmap.
|
||||
- `notes/epiphany-current-algorithmic-map.md`: current native control flow.
|
||||
- `state/map.yaml`: canonical slow machine map and verification status.
|
||||
- `F:\Projects\gamecult-site\GameCult\Projects\Epiphany.md`: public site
|
||||
projection.
|
||||
|
||||
## Bottom Line
|
||||
|
||||
Epiphany is valuable if capable AI workers can complete increasingly long runs
|
||||
of accepted work without humans becoming pulse operators, while the
|
||||
organization retains judgment, authority, architectural coherence, and a
|
||||
traceable account of what happened. GameCult is building that evidence in real
|
||||
products before asking anyone to confuse ambition with proof.
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
# Epiphany Positioning
|
||||
|
||||
Status: August 2026 public positioning; maturity: supervised engineering alpha
|
||||
|
||||
## One Sentence
|
||||
|
||||
Epiphany is the organizational layer above frontier AI workers: governed shared
|
||||
state, bounded authority, coordinated action, and inspectable receipts for work
|
||||
that must remain coherent beyond one prompt, one agent, or one session.
|
||||
|
||||
## The Problem
|
||||
|
||||
Frontier agents can keep producing good-looking local progress after the
|
||||
architecture has stopped making sense. They can write valid code, pass narrow
|
||||
tests, explain each decision, and still leave behind split authority, stale
|
||||
assumptions, and a growing stack of compensators.
|
||||
|
||||
That is global coherence collapse. It is not merely context-window exhaustion.
|
||||
A larger window can hold more contradictory state just as efficiently.
|
||||
|
||||
Epiphany treats the project as an organization with a durable Mind rather than
|
||||
as a sequence of private conversations. Objectives, architecture, decisions,
|
||||
permissions, evidence, work, and review live in governed state. Model prompts
|
||||
are bounded projections of that state. They are not the state itself.
|
||||
|
||||
## The Category
|
||||
|
||||
Epiphany is not a better autocomplete, a prompt manager, or a model competing
|
||||
with frontier labs. Frontier models are workers. Epiphany is the coordination
|
||||
and governance system that tells those workers what the organization currently
|
||||
believes, what they may change, what evidence they owe, and where uncertainty
|
||||
must return to human judgment.
|
||||
|
||||
```text
|
||||
governed organizational state
|
||||
-> scoped Epiphany work
|
||||
-> frontier model and tool execution
|
||||
-> artifacts, evidence, and receipts
|
||||
-> review under human-governed acceptance authority
|
||||
-> accepted knowledge and updated organizational state
|
||||
```
|
||||
|
||||
This is the target promise:
|
||||
|
||||
> Epiphany is being built so organizations can delegate bounded work to
|
||||
> capable AI without turning every employee into an AI operator.
|
||||
|
||||
## The Division Of Labor
|
||||
|
||||
Humans own purpose, values, authority, exceptions, acceptance, disagreement,
|
||||
and ambiguous tradeoffs. They participate in governance and judgment; they
|
||||
should not spend the day scheduling agents, reconstructing context, and typing
|
||||
`Continue` into a row of private prompt boxes.
|
||||
|
||||
Agents own bounded execution. They can continue while shared state, authority,
|
||||
and evidence justify the next action. When the machine is missing authority,
|
||||
encounters a real conflict, or cannot reconcile its model with the substrate,
|
||||
it escalates through the same visible work and discussion surfaces used by the
|
||||
organization.
|
||||
|
||||
Direct conversation remains useful. It is an input and relationship surface,
|
||||
not the hidden operating system.
|
||||
|
||||
## Why Not Just Use Codex?
|
||||
|
||||
Codex and other frontier coding agents can be excellent workers. Epiphany is
|
||||
built around the questions worker quality does not answer:
|
||||
|
||||
- Who tells the workers what reality currently is?
|
||||
- Which state is durable, and who may change it?
|
||||
- How are contradictory decisions reconciled?
|
||||
- Where does human authority enter without becoming constant supervision?
|
||||
- Can an accepted artifact be traced back to its work item, decisions,
|
||||
evidence, model and tool activity, and review?
|
||||
|
||||
Epiphany retains Codex only where Codex has an honest ownership claim—OpenAI
|
||||
authentication and model transport. Epiphany owns the project Mind,
|
||||
coordination, typed execution state, receipts, and review pressure.
|
||||
|
||||
## GameCult Is The Proof Surface
|
||||
|
||||
GameCult does not treat its games, creative tools, and infrastructure as a
|
||||
separate portfolio beside Epiphany. Shipping them is the dogfood program.
|
||||
Aetheria, StreamPixels, CultPong, Repixelizer, and the rest of the studio place
|
||||
different pressure on the same production system: realtime services, games,
|
||||
art pipelines, identity, deployment, community memory, and long-lived
|
||||
architecture.
|
||||
|
||||
The credible artifact is longitudinal. It should show work items, questions,
|
||||
human decisions, propagation of those decisions, commits, reviews, cost,
|
||||
failures, and recovery across real releases. Failures belong in the record;
|
||||
otherwise the record is advertising with nicer timestamps.
|
||||
|
||||
Useful measures include:
|
||||
|
||||
- accepted work completed between synchronous human interventions
|
||||
- human time spent on judgment versus scheduling and cleanup
|
||||
- assumptions escalated before they became expensive edits
|
||||
- architectural reversals or restructures caught and carried coherently
|
||||
- review burden and cost per accepted artifact
|
||||
- attribution completeness from accepted artifact back to governed work
|
||||
- whether intervention shifts over time from pulse maintenance toward product,
|
||||
architecture, and governance decisions
|
||||
|
||||
Commit percentages and prompt counts are weak proxies. The target is not fewer
|
||||
humans. It is better use of human judgment.
|
||||
|
||||
## The GameCult Forge
|
||||
|
||||
The intended GameCult topology is a federation of Epiphany project organisms:
|
||||
one locally owned organism per living project Body, normally a repo-owned swarm
|
||||
with a private Mind, bounded organs, and one or more project-facing Personas.
|
||||
There is no central Epiphany entitled to treat every repository as its Body.
|
||||
|
||||
Project organisms collaborate through governed typed surfaces. Model Atlas
|
||||
carries provider-owned offers, consumer-owned claims, exact Soul evidence, and
|
||||
derived technical relationships without allowing one Epiphany to read or write
|
||||
another's Mind. Bifrost is the intended external work, membership, governance,
|
||||
attribution, and identity-linked receipt rail; Heimdall owns identity, grants,
|
||||
sessions, consent, and revocation. Relationship or visibility never grants
|
||||
Hands authority.
|
||||
|
||||
The social direction is public and relational. Project Personas should form
|
||||
durable relationships with one another and with contributors, players, and
|
||||
visitors: attributable histories of cooperation, disagreement, promises,
|
||||
reputation, correction, and recourse. Visitors should be able to observe
|
||||
deliberate public projections, converse, and offer evidence or proposals. Raw
|
||||
worker thought, private Mind, secrets, personal context, and non-consenting
|
||||
people remain sealed. Remembered relationships require opt-in, visible
|
||||
retention limits, inspection, correction, revocation, and exit. Public input is
|
||||
pressure until local review authorizes or rejects it; Mind alone admits any
|
||||
authorized durable mutation.
|
||||
|
||||
This swarm of swarms is where GameCult expects higher-order organizational
|
||||
behavior to emerge. It is a target topology, not a deployed-fleet claim. The
|
||||
current alpha has locally verified project-local authority and Model Atlas
|
||||
mechanisms; the real three-repository Gate 1, sustained GameCult fleet, public
|
||||
rooms, visitor experience, and durable social-governance rail still have to be
|
||||
forged in use.
|
||||
|
||||
## Attribution And Bifrost
|
||||
|
||||
Bifrost is the intended governed work and attribution rail. The target
|
||||
invariant is:
|
||||
|
||||
> Every admitted mutation of governed project state has an attributable
|
||||
> mutation receipt naming its actor, authority, basis, and outcome. An
|
||||
> agent-caused mutation also links its exact execution receipt.
|
||||
|
||||
An eventual agent-execution chain should connect:
|
||||
|
||||
```text
|
||||
accepted artifact or commit
|
||||
-> producing agent and authorizing or accepting human
|
||||
-> Bifrost execution and work item
|
||||
-> decisions, questions, and evidence
|
||||
-> model, provider, tools, material commands, repo, and worktree
|
||||
-> review and acceptance outcome
|
||||
```
|
||||
|
||||
A Git object hash makes later changes detectable. A valid commit signature
|
||||
links those commit bytes to a trusted signing key. Neither proves correctness,
|
||||
execution authority, or complete provenance. Epiphany and Bifrost must make the
|
||||
missing authority and provenance legible. This chain is the target
|
||||
architecture; the supervised alpha has not yet proved complete coverage across
|
||||
every mutation path.
|
||||
|
||||
## Near-Term Offer
|
||||
|
||||
The planned commercial offer, after the Bifrost bridge and longitudinal
|
||||
GameCult dogfood clear their gates, is a supervised co-development pilot: one
|
||||
repository, one repeatable workflow, and a two-to-four-week proof period. The
|
||||
deliverable would be a working setup plus evidence about output quality, review
|
||||
burden, failure modes, attribution, and cost.
|
||||
|
||||
That is enough to learn whether Epiphany reduces coordination load while
|
||||
preserving human authority. It is also narrow enough that everyone can see when
|
||||
the claim fails.
|
||||
|
||||
## Evidence Boundary
|
||||
|
||||
Epiphany is a supervised engineering alpha. Its typed Mind, role boundaries,
|
||||
projection, execution, receipt, verification, and operator surfaces exist and
|
||||
are under active dogfood. Sustained autonomous GameCult production and the full
|
||||
Bifrost attribution chain have not yet been earned.
|
||||
|
||||
Public claims should therefore say what the machine is built to do, identify
|
||||
what has been locally verified, and name the proof still required. Do not call
|
||||
Epiphany production-ready, fully autonomous, fully attributable, or a solved
|
||||
form of organizational intelligence until longitudinal evidence makes those
|
||||
phrases boringly true.
|
||||
35
epiphany-codex-bridge/Cargo.toml
Normal file
35
epiphany-codex-bridge/Cargo.toml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
[package]
|
||||
name = "epiphany-codex-bridge"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
license-file = "../LICENSE"
|
||||
|
||||
[lib]
|
||||
name = "epiphany_codex_bridge"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
codex-app-server-protocol = { path = "../vendor/codex/codex-rs/app-server-protocol" }
|
||||
codex-utils-absolute-path = { path = "../vendor/codex/codex-rs/utils/absolute-path" }
|
||||
# Keep standalone bridge checks aligned with the vendored Codex workspace lock.
|
||||
# `starlark_map 0.13.0` does not compile against newer allocative/hashbrown
|
||||
# resolver choices on this machine.
|
||||
allocative = "=0.3.4"
|
||||
epiphany-core = { path = "../epiphany-core" }
|
||||
epiphany-state-model = { path = "../epiphany-state-model" }
|
||||
notify = "8.2.0"
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["rt", "sync"] }
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[patch.crates-io]
|
||||
tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "132f5b39c862e3a970f731d709608b3e6276d5f6" }
|
||||
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" }
|
||||
|
||||
[patch."ssh://git@github.com/openai-oss-forks/tungstenite-rs.git"]
|
||||
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" }
|
||||
61
epiphany-codex-bridge/src/checkpoint.rs
Normal file
61
epiphany-codex-bridge/src/checkpoint.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
#[derive(Default)]
|
||||
pub struct EpiphanyCheckpointInterventionState {
|
||||
last_intervention_turn_id: Option<String>,
|
||||
pending_compaction_turn_id: Option<String>,
|
||||
}
|
||||
|
||||
impl EpiphanyCheckpointInterventionState {
|
||||
pub fn record_intervention(&mut self, turn_id: &str) -> bool {
|
||||
if self.last_intervention_turn_id.as_deref() == Some(turn_id) {
|
||||
return false;
|
||||
}
|
||||
self.last_intervention_turn_id = Some(turn_id.to_string());
|
||||
true
|
||||
}
|
||||
|
||||
pub fn mark_pending_compaction(&mut self, turn_id: &str) {
|
||||
self.pending_compaction_turn_id = Some(turn_id.to_string());
|
||||
}
|
||||
|
||||
pub fn take_pending_compaction(&mut self, turn_id: &str) -> bool {
|
||||
if self.pending_compaction_turn_id.as_deref() != Some(turn_id) {
|
||||
return false;
|
||||
}
|
||||
self.pending_compaction_turn_id = None;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn clear_pending_compaction(&mut self) {
|
||||
self.pending_compaction_turn_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::EpiphanyCheckpointInterventionState;
|
||||
|
||||
#[test]
|
||||
fn pending_compaction_is_turn_scoped() {
|
||||
let mut state = EpiphanyCheckpointInterventionState::default();
|
||||
|
||||
assert!(state.record_intervention("turn-a"));
|
||||
assert!(!state.record_intervention("turn-a"));
|
||||
assert!(!state.take_pending_compaction("turn-a"));
|
||||
|
||||
state.mark_pending_compaction("turn-a");
|
||||
|
||||
assert!(!state.take_pending_compaction("turn-b"));
|
||||
assert!(state.take_pending_compaction("turn-a"));
|
||||
assert!(!state.take_pending_compaction("turn-a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_compaction_can_be_cleared() {
|
||||
let mut state = EpiphanyCheckpointInterventionState::default();
|
||||
|
||||
state.mark_pending_compaction("turn-a");
|
||||
state.clear_pending_compaction();
|
||||
|
||||
assert!(!state.take_pending_compaction("turn-a"));
|
||||
}
|
||||
}
|
||||
27
epiphany-codex-bridge/src/context.rs
Normal file
27
epiphany-codex-bridge/src/context.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
use epiphany_core::EpiphanyContextParams;
|
||||
use epiphany_core::EpiphanyContextView;
|
||||
use epiphany_core::EpiphanyGraphQuery;
|
||||
use epiphany_core::EpiphanyGraphQueryView;
|
||||
use epiphany_core::EpiphanyPlanningView;
|
||||
use epiphany_core::derive_context;
|
||||
use epiphany_core::derive_graph_query;
|
||||
use epiphany_core::derive_planning_view;
|
||||
use epiphany_state_model::EpiphanyThreadState;
|
||||
|
||||
pub fn derive_epiphany_context(
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
params: &EpiphanyContextParams,
|
||||
) -> EpiphanyContextView {
|
||||
derive_context(state, params)
|
||||
}
|
||||
|
||||
pub fn derive_epiphany_planning(state: Option<&EpiphanyThreadState>) -> EpiphanyPlanningView {
|
||||
derive_planning_view(state)
|
||||
}
|
||||
|
||||
pub fn derive_epiphany_graph_query(
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
query: &EpiphanyGraphQuery,
|
||||
) -> EpiphanyGraphQueryView {
|
||||
derive_graph_query(state, query)
|
||||
}
|
||||
155
epiphany-codex-bridge/src/context_protocol.rs
Normal file
155
epiphany-codex-bridge/src/context_protocol.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
use codex_app_server_protocol::ThreadEpiphanyContext;
|
||||
use codex_app_server_protocol::ThreadEpiphanyContextMissing;
|
||||
use codex_app_server_protocol::ThreadEpiphanyContextStateStatus;
|
||||
use codex_app_server_protocol::ThreadEpiphanyGraphContext;
|
||||
use codex_app_server_protocol::ThreadEpiphanyGraphQueryMatched;
|
||||
use codex_app_server_protocol::ThreadEpiphanyGraphQueryMissing;
|
||||
use codex_app_server_protocol::ThreadEpiphanyPlanningSummary;
|
||||
use epiphany_core::EpiphanyContext as CoreEpiphanyContext;
|
||||
use epiphany_core::EpiphanyContextMissing as CoreEpiphanyContextMissing;
|
||||
use epiphany_core::EpiphanyContextStateStatus as CoreEpiphanyContextStateStatus;
|
||||
use epiphany_core::EpiphanyContextView;
|
||||
use epiphany_core::EpiphanyGraphContext as CoreEpiphanyGraphContext;
|
||||
use epiphany_core::EpiphanyGraphQueryMatched as CoreEpiphanyGraphQueryMatched;
|
||||
use epiphany_core::EpiphanyGraphQueryMissing as CoreEpiphanyGraphQueryMissing;
|
||||
use epiphany_core::EpiphanyGraphQueryView;
|
||||
use epiphany_core::EpiphanyPlanningStateStatus as CoreEpiphanyPlanningStateStatus;
|
||||
use epiphany_core::EpiphanyPlanningSummary as CoreEpiphanyPlanningSummary;
|
||||
use epiphany_core::EpiphanyPlanningView;
|
||||
use epiphany_state_model::EpiphanyGraphCheckpoint;
|
||||
use epiphany_state_model::EpiphanyGraphFrontier;
|
||||
use epiphany_state_model::EpiphanyPlanningState;
|
||||
|
||||
pub fn protocol_context_view(
|
||||
view: EpiphanyContextView,
|
||||
) -> (
|
||||
ThreadEpiphanyContextStateStatus,
|
||||
Option<u64>,
|
||||
ThreadEpiphanyContext,
|
||||
ThreadEpiphanyContextMissing,
|
||||
) {
|
||||
(
|
||||
protocol_context_state_status(view.state_status),
|
||||
view.state_revision,
|
||||
protocol_context(view.context),
|
||||
protocol_context_missing(view.missing),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn protocol_context_state_status(
|
||||
status: CoreEpiphanyContextStateStatus,
|
||||
) -> ThreadEpiphanyContextStateStatus {
|
||||
match status {
|
||||
CoreEpiphanyContextStateStatus::Missing => ThreadEpiphanyContextStateStatus::Missing,
|
||||
CoreEpiphanyContextStateStatus::Ready => ThreadEpiphanyContextStateStatus::Ready,
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_context(context: CoreEpiphanyContext) -> ThreadEpiphanyContext {
|
||||
ThreadEpiphanyContext {
|
||||
graph: protocol_graph_context(context.graph),
|
||||
frontier: context.frontier,
|
||||
checkpoint: context.checkpoint,
|
||||
investigation_checkpoint: context.investigation_checkpoint,
|
||||
observations: context.observations,
|
||||
evidence: context.evidence,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn protocol_graph_context(graph: CoreEpiphanyGraphContext) -> ThreadEpiphanyGraphContext {
|
||||
ThreadEpiphanyGraphContext {
|
||||
architecture_nodes: graph.architecture_nodes,
|
||||
architecture_edges: graph.architecture_edges,
|
||||
dataflow_nodes: graph.dataflow_nodes,
|
||||
dataflow_edges: graph.dataflow_edges,
|
||||
links: graph.links,
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_context_missing(missing: CoreEpiphanyContextMissing) -> ThreadEpiphanyContextMissing {
|
||||
ThreadEpiphanyContextMissing {
|
||||
graph_node_ids: missing.graph_node_ids,
|
||||
graph_edge_ids: missing.graph_edge_ids,
|
||||
observation_ids: missing.observation_ids,
|
||||
evidence_ids: missing.evidence_ids,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn protocol_planning_view(
|
||||
view: EpiphanyPlanningView,
|
||||
) -> (
|
||||
ThreadEpiphanyContextStateStatus,
|
||||
Option<u64>,
|
||||
EpiphanyPlanningState,
|
||||
ThreadEpiphanyPlanningSummary,
|
||||
) {
|
||||
(
|
||||
match view.state_status {
|
||||
CoreEpiphanyPlanningStateStatus::Missing => ThreadEpiphanyContextStateStatus::Missing,
|
||||
CoreEpiphanyPlanningStateStatus::Ready => ThreadEpiphanyContextStateStatus::Ready,
|
||||
},
|
||||
view.state_revision,
|
||||
view.planning,
|
||||
protocol_planning_summary(view.summary),
|
||||
)
|
||||
}
|
||||
|
||||
fn protocol_planning_summary(
|
||||
summary: CoreEpiphanyPlanningSummary,
|
||||
) -> ThreadEpiphanyPlanningSummary {
|
||||
ThreadEpiphanyPlanningSummary {
|
||||
capture_count: summary.capture_count,
|
||||
pending_capture_count: summary.pending_capture_count,
|
||||
github_issue_capture_count: summary.github_issue_capture_count,
|
||||
backlog_item_count: summary.backlog_item_count,
|
||||
ready_backlog_item_count: summary.ready_backlog_item_count,
|
||||
roadmap_stream_count: summary.roadmap_stream_count,
|
||||
objective_draft_count: summary.objective_draft_count,
|
||||
draft_objective_count: summary.draft_objective_count,
|
||||
active_objective: summary.active_objective,
|
||||
note: summary.note,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn protocol_graph_query_view(
|
||||
view: EpiphanyGraphQueryView,
|
||||
) -> (
|
||||
ThreadEpiphanyContextStateStatus,
|
||||
Option<u64>,
|
||||
ThreadEpiphanyGraphContext,
|
||||
Option<EpiphanyGraphFrontier>,
|
||||
Option<EpiphanyGraphCheckpoint>,
|
||||
ThreadEpiphanyGraphQueryMatched,
|
||||
ThreadEpiphanyGraphQueryMissing,
|
||||
) {
|
||||
(
|
||||
protocol_context_state_status(view.state_status),
|
||||
view.state_revision,
|
||||
protocol_graph_context(view.graph),
|
||||
view.frontier,
|
||||
view.checkpoint,
|
||||
protocol_graph_query_matched(view.matched),
|
||||
protocol_graph_query_missing(view.missing),
|
||||
)
|
||||
}
|
||||
|
||||
fn protocol_graph_query_matched(
|
||||
matched: CoreEpiphanyGraphQueryMatched,
|
||||
) -> ThreadEpiphanyGraphQueryMatched {
|
||||
ThreadEpiphanyGraphQueryMatched {
|
||||
node_ids: matched.node_ids,
|
||||
edge_ids: matched.edge_ids,
|
||||
paths: matched.paths,
|
||||
symbols: matched.symbols,
|
||||
edge_kinds: matched.edge_kinds,
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_graph_query_missing(
|
||||
missing: CoreEpiphanyGraphQueryMissing,
|
||||
) -> ThreadEpiphanyGraphQueryMissing {
|
||||
ThreadEpiphanyGraphQueryMissing {
|
||||
node_ids: missing.node_ids,
|
||||
edge_ids: missing.edge_ids,
|
||||
}
|
||||
}
|
||||
601
epiphany-codex-bridge/src/coordinator.rs
Normal file
601
epiphany-codex-bridge/src/coordinator.rs
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
use epiphany_core::EPIPHANY_IMAGINATION_OWNER_ROLE;
|
||||
use epiphany_core::EPIPHANY_IMAGINATION_ROLE_BINDING_ID;
|
||||
use epiphany_core::EPIPHANY_IMPLEMENTATION_OWNER_ROLE;
|
||||
use epiphany_core::EPIPHANY_IMPLEMENTATION_ROLE_BINDING_ID;
|
||||
use epiphany_core::EPIPHANY_MODELING_ROLE_BINDING_ID;
|
||||
use epiphany_core::EPIPHANY_REORIENT_LAUNCH_BINDING_ID;
|
||||
use epiphany_core::EPIPHANY_REORIENT_OWNER_ROLE;
|
||||
use epiphany_core::EPIPHANY_RESEARCH_ROLE_BINDING_ID;
|
||||
use epiphany_core::EPIPHANY_VERIFICATION_ROLE_BINDING_ID;
|
||||
use epiphany_core::EpiphanyCoordinatorAutomationAction as CoreEpiphanyCoordinatorAutomationAction;
|
||||
use epiphany_core::EpiphanyCoordinatorDecision as CoreEpiphanyCoordinatorDecision;
|
||||
use epiphany_core::EpiphanyCoordinatorRoleResultStatus as CoreEpiphanyCoordinatorRoleResultStatus;
|
||||
use epiphany_core::EpiphanyCoordinatorStatus as CoreEpiphanyCoordinatorStatus;
|
||||
use epiphany_core::EpiphanyCoordinatorStatusInput;
|
||||
use epiphany_core::EpiphanyCrrcAction as CoreEpiphanyCrrcAction;
|
||||
use epiphany_core::EpiphanyCrrcInput;
|
||||
use epiphany_core::EpiphanyCrrcRecommendation as CoreEpiphanyCrrcRecommendation;
|
||||
use epiphany_core::EpiphanyCrrcReorientAction as CoreEpiphanyCrrcReorientAction;
|
||||
use epiphany_core::EpiphanyCrrcResultStatus as CoreEpiphanyCrrcResultStatus;
|
||||
use epiphany_core::EpiphanyCrrcStateStatus as CoreEpiphanyCrrcStateStatus;
|
||||
use epiphany_core::EpiphanyJobLaunchRequest;
|
||||
use epiphany_core::EpiphanyJobStatus as CoreEpiphanyJobStatus;
|
||||
use epiphany_core::EpiphanyJobView;
|
||||
use epiphany_core::EpiphanyReorientAction as CoreEpiphanyReorientAction;
|
||||
use epiphany_core::EpiphanyReorientDecision as CoreEpiphanyReorientDecision;
|
||||
use epiphany_core::EpiphanyReorientFindingInterpretation as CoreEpiphanyReorientFinding;
|
||||
use epiphany_core::EpiphanyReorientStateStatus as CoreEpiphanyReorientStateStatus;
|
||||
use epiphany_core::EpiphanyRoleBoardCheckpointSummary;
|
||||
use epiphany_core::EpiphanyRoleBoardInput;
|
||||
use epiphany_core::EpiphanyRoleBoardJob;
|
||||
use epiphany_core::EpiphanyRoleBoardJobStatus;
|
||||
use epiphany_core::EpiphanyRoleBoardLane;
|
||||
use epiphany_core::EpiphanyRoleBoardPlanningSummary;
|
||||
use epiphany_core::EpiphanyRoleResultRoleId;
|
||||
use epiphany_core::EpiphanyTokenUsageSnapshot;
|
||||
use epiphany_core::build_epiphany_reorient_launch_request_with_dynamic_context;
|
||||
use epiphany_core::derive_coordinator_finding_signals;
|
||||
use epiphany_core::derive_coordinator_status;
|
||||
use epiphany_core::derive_role_board;
|
||||
use epiphany_core::recommend_crrc_action;
|
||||
use epiphany_core::render_epiphany_coordinator_note;
|
||||
use epiphany_core::render_role_board_note;
|
||||
use epiphany_core::reorient_finding_already_accepted;
|
||||
use epiphany_core::select_coordinator_automation_action;
|
||||
use epiphany_state_model::EpiphanyRetrievalState;
|
||||
use epiphany_state_model::EpiphanyThreadState;
|
||||
|
||||
use crate::launch_context::render_launch_dynamic_prompt_context;
|
||||
use crate::launch_context::reorient_launch_context_focus;
|
||||
use crate::pressure::derive_epiphany_pressure;
|
||||
use crate::reorient::EpiphanyFreshnessWatcherSnapshot;
|
||||
use crate::reorient::derive_epiphany_freshness_view;
|
||||
use crate::reorient::derive_epiphany_reorient;
|
||||
use crate::runtime_results::load_core_epiphany_reorient_result_snapshot;
|
||||
use crate::runtime_results::load_core_epiphany_role_result_snapshot;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
pub fn map_epiphany_crrc_recommendation(
|
||||
loaded: bool,
|
||||
state_status: CoreEpiphanyReorientStateStatus,
|
||||
pressure: &epiphany_core::EpiphanyPressure,
|
||||
decision: &CoreEpiphanyReorientDecision,
|
||||
result_status: CoreEpiphanyCrrcResultStatus,
|
||||
checkpoint_present: bool,
|
||||
finding_present: bool,
|
||||
finding_accepted: bool,
|
||||
) -> CoreEpiphanyCrrcRecommendation {
|
||||
recommend_crrc_action(EpiphanyCrrcInput {
|
||||
loaded,
|
||||
state_status: map_core_crrc_state_status_from_reorient(state_status),
|
||||
should_prepare_compaction: pressure.should_prepare_compaction,
|
||||
reorient_action: map_core_crrc_reorient_action(decision.action),
|
||||
result_status,
|
||||
checkpoint_present,
|
||||
finding_present,
|
||||
finding_accepted,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_core_crrc_state_status_from_reorient(
|
||||
status: CoreEpiphanyReorientStateStatus,
|
||||
) -> CoreEpiphanyCrrcStateStatus {
|
||||
match status {
|
||||
CoreEpiphanyReorientStateStatus::Missing => CoreEpiphanyCrrcStateStatus::Missing,
|
||||
CoreEpiphanyReorientStateStatus::Ready => CoreEpiphanyCrrcStateStatus::Ready,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_core_crrc_reorient_action(
|
||||
action: CoreEpiphanyReorientAction,
|
||||
) -> CoreEpiphanyCrrcReorientAction {
|
||||
match action {
|
||||
CoreEpiphanyReorientAction::Resume => CoreEpiphanyCrrcReorientAction::Resume,
|
||||
CoreEpiphanyReorientAction::Regather => CoreEpiphanyCrrcReorientAction::Regather,
|
||||
}
|
||||
}
|
||||
|
||||
pub type EpiphanyCoordinatorDecision = CoreEpiphanyCoordinatorDecision;
|
||||
|
||||
pub struct EpiphanyCoordinatorStatus {
|
||||
pub core: CoreEpiphanyCoordinatorStatus,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
pub async fn derive_epiphany_coordinator_status(
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
runtime_store_path: Option<&Path>,
|
||||
state_status: CoreEpiphanyReorientStateStatus,
|
||||
pressure: &epiphany_core::EpiphanyPressure,
|
||||
recommendation: &CoreEpiphanyCrrcRecommendation,
|
||||
roles: Vec<EpiphanyRoleBoardLane>,
|
||||
reorient_decision: Option<&CoreEpiphanyReorientDecision>,
|
||||
reorient_result_status: CoreEpiphanyCrrcResultStatus,
|
||||
reorient_finding: Option<&CoreEpiphanyReorientFinding>,
|
||||
checkpoint_present: bool,
|
||||
) -> EpiphanyCoordinatorStatus {
|
||||
let (modeling_result_status, modeling_finding) = if let Some(state) = state {
|
||||
let snapshot = load_core_epiphany_role_result_snapshot(
|
||||
state,
|
||||
runtime_store_path,
|
||||
EpiphanyRoleResultRoleId::Modeling,
|
||||
EPIPHANY_MODELING_ROLE_BINDING_ID,
|
||||
)
|
||||
.await;
|
||||
(snapshot.status, snapshot.finding)
|
||||
} else {
|
||||
(CoreEpiphanyCoordinatorRoleResultStatus::MissingState, None)
|
||||
};
|
||||
let (research_result_status, research_finding) = if let Some(state) = state {
|
||||
let snapshot = load_core_epiphany_role_result_snapshot(
|
||||
state,
|
||||
runtime_store_path,
|
||||
EpiphanyRoleResultRoleId::Research,
|
||||
EPIPHANY_RESEARCH_ROLE_BINDING_ID,
|
||||
)
|
||||
.await;
|
||||
(snapshot.status, snapshot.finding)
|
||||
} else {
|
||||
(CoreEpiphanyCoordinatorRoleResultStatus::MissingState, None)
|
||||
};
|
||||
let (verification_result_status, verification_finding) = if let Some(state) = state {
|
||||
let snapshot = load_core_epiphany_role_result_snapshot(
|
||||
state,
|
||||
runtime_store_path,
|
||||
EpiphanyRoleResultRoleId::Verification,
|
||||
EPIPHANY_VERIFICATION_ROLE_BINDING_ID,
|
||||
)
|
||||
.await;
|
||||
(snapshot.status, snapshot.finding)
|
||||
} else {
|
||||
(CoreEpiphanyCoordinatorRoleResultStatus::MissingState, None)
|
||||
};
|
||||
let (implementation_result_status, implementation_finding, implementation_completed_at) =
|
||||
if let Some(state) = state {
|
||||
let snapshot = load_core_epiphany_role_result_snapshot(
|
||||
state,
|
||||
runtime_store_path,
|
||||
EpiphanyRoleResultRoleId::Implementation,
|
||||
EPIPHANY_IMPLEMENTATION_ROLE_BINDING_ID,
|
||||
)
|
||||
.await;
|
||||
(snapshot.status, snapshot.finding, snapshot.completed_at)
|
||||
} else {
|
||||
(
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::MissingState,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let implementation_commit_requires_modeling_refresh =
|
||||
implementation_commit_requires_modeling_refresh(
|
||||
state,
|
||||
implementation_result_status,
|
||||
implementation_finding.as_ref(),
|
||||
implementation_completed_at.as_deref(),
|
||||
modeling_finding.as_ref(),
|
||||
);
|
||||
let finding_signals = derive_coordinator_finding_signals(
|
||||
state,
|
||||
research_finding.as_ref(),
|
||||
modeling_finding.as_ref(),
|
||||
verification_finding.as_ref(),
|
||||
reorient_finding,
|
||||
);
|
||||
let core = derive_coordinator_status(EpiphanyCoordinatorStatusInput {
|
||||
state_status: map_core_crrc_state_status_from_reorient(state_status),
|
||||
checkpoint_present,
|
||||
pressure: pressure.clone(),
|
||||
recommendation: recommendation.clone(),
|
||||
roles,
|
||||
reorient_action: reorient_decision
|
||||
.map(|decision| decision.action)
|
||||
.unwrap_or(CoreEpiphanyReorientAction::Resume),
|
||||
research_result_status,
|
||||
modeling_result_status,
|
||||
verification_result_status,
|
||||
reorient_result_status,
|
||||
research_result_accepted: finding_signals.research_result_accepted,
|
||||
research_result_reviewable: finding_signals.research_result_reviewable,
|
||||
modeling_result_requests_regather: finding_signals.modeling_result_requests_regather,
|
||||
modeling_result_accepted: finding_signals.modeling_result_accepted,
|
||||
modeling_result_reviewable: finding_signals.modeling_result_reviewable,
|
||||
modeling_result_accepted_after_verification: finding_signals
|
||||
.modeling_result_accepted_after_verification,
|
||||
implementation_evidence_after_verification: finding_signals
|
||||
.implementation_evidence_after_verification,
|
||||
verification_result_cites_implementation_evidence: finding_signals
|
||||
.verification_result_cites_implementation_evidence,
|
||||
verification_result_covers_current_modeling: finding_signals
|
||||
.verification_result_covers_current_modeling,
|
||||
verification_result_accepted: finding_signals.verification_result_accepted,
|
||||
verification_result_allows_implementation: finding_signals
|
||||
.verification_result_allows_implementation,
|
||||
verification_result_needs_evidence: finding_signals.verification_result_needs_evidence,
|
||||
implementation_commit_requires_modeling_refresh,
|
||||
reorient_finding_accepted: finding_signals.reorient_finding_accepted,
|
||||
});
|
||||
let note = render_epiphany_coordinator_note(
|
||||
recommendation.action,
|
||||
pressure.level,
|
||||
modeling_result_status,
|
||||
verification_result_status,
|
||||
core.source_signals.reorient_result_status,
|
||||
core.decision.action,
|
||||
);
|
||||
|
||||
EpiphanyCoordinatorStatus { core, note }
|
||||
}
|
||||
|
||||
fn implementation_commit_requires_modeling_refresh(
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
implementation_status: CoreEpiphanyCoordinatorRoleResultStatus,
|
||||
implementation_finding: Option<&epiphany_core::EpiphanyRoleFindingInterpretation>,
|
||||
implementation_completed_at: Option<&str>,
|
||||
modeling_finding: Option<&epiphany_core::EpiphanyRoleFindingInterpretation>,
|
||||
) -> bool {
|
||||
if implementation_status != CoreEpiphanyCoordinatorRoleResultStatus::Completed {
|
||||
return false;
|
||||
}
|
||||
let Some(implementation_finding) = implementation_finding else {
|
||||
return false;
|
||||
};
|
||||
if !implementation_finding
|
||||
.evidence_ids
|
||||
.iter()
|
||||
.any(|id| id.starts_with("hands-receipt:hands-commit-") || id.starts_with("hands-commit-"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(completed_at) = implementation_completed_at else {
|
||||
return true;
|
||||
};
|
||||
!modeling_accepted_after(state, modeling_finding, completed_at)
|
||||
}
|
||||
|
||||
fn modeling_accepted_after(
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
modeling_finding: Option<&epiphany_core::EpiphanyRoleFindingInterpretation>,
|
||||
completed_at: &str,
|
||||
) -> bool {
|
||||
let Some(state) = state else {
|
||||
return false;
|
||||
};
|
||||
let Some(result_id) = modeling_finding.and_then(|finding| finding.runtime_result_id.as_deref())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
state.acceptance_receipts.iter().any(|receipt| {
|
||||
receipt.result_id == result_id
|
||||
&& receipt.status == "accepted"
|
||||
&& receipt.surface == "roleAccept"
|
||||
&& receipt.role_id == "modeling"
|
||||
&& receipt.accepted_at.as_str() > completed_at
|
||||
})
|
||||
}
|
||||
|
||||
pub type EpiphanyCoordinatorAutomationAction = CoreEpiphanyCoordinatorAutomationAction;
|
||||
|
||||
pub struct EpiphanyCoordinatorAutomationInput<'a> {
|
||||
pub thread_id: &'a str,
|
||||
pub state: &'a EpiphanyThreadState,
|
||||
pub retrieval_override: &'a EpiphanyRetrievalState,
|
||||
pub watcher_snapshot: EpiphanyFreshnessWatcherSnapshot<'a>,
|
||||
pub token_usage_info: Option<&'a EpiphanyTokenUsageSnapshot>,
|
||||
pub runtime_store_path: &'a Path,
|
||||
pub force_checkpoint_compaction: bool,
|
||||
}
|
||||
|
||||
pub struct EpiphanyCoordinatorAutomationVerdict {
|
||||
pub action: EpiphanyCoordinatorAutomationAction,
|
||||
pub launch_request: Option<EpiphanyJobLaunchRequest>,
|
||||
}
|
||||
|
||||
pub async fn select_epiphany_coordinator_automation(
|
||||
input: EpiphanyCoordinatorAutomationInput<'_>,
|
||||
) -> EpiphanyCoordinatorAutomationVerdict {
|
||||
let core_freshness = derive_epiphany_freshness_view(
|
||||
Some(input.state),
|
||||
Some(input.retrieval_override),
|
||||
Some(input.watcher_snapshot),
|
||||
);
|
||||
let core_pressure = derive_epiphany_pressure(input.token_usage_info);
|
||||
let (state_status, reorient_decision) = derive_epiphany_reorient(
|
||||
Some(input.state),
|
||||
&core_pressure,
|
||||
&core_freshness.retrieval,
|
||||
&core_freshness.graph,
|
||||
&core_freshness.watcher,
|
||||
);
|
||||
if state_status != CoreEpiphanyReorientStateStatus::Ready {
|
||||
return EpiphanyCoordinatorAutomationVerdict {
|
||||
action: EpiphanyCoordinatorAutomationAction::None,
|
||||
launch_request: None,
|
||||
};
|
||||
}
|
||||
|
||||
let jobs = crate::jobs::map_epiphany_jobs(Some(input.state), Some(input.retrieval_override));
|
||||
let reorient_job = jobs
|
||||
.iter()
|
||||
.find(|job| job.id == EPIPHANY_REORIENT_LAUNCH_BINDING_ID)
|
||||
.cloned();
|
||||
let reorient_result = load_core_epiphany_reorient_result_snapshot(
|
||||
Some(input.state),
|
||||
Some(input.runtime_store_path),
|
||||
EPIPHANY_REORIENT_LAUNCH_BINDING_ID,
|
||||
)
|
||||
.await;
|
||||
let reorient_finding_accepted = reorient_result
|
||||
.finding
|
||||
.as_ref()
|
||||
.is_some_and(|finding| reorient_finding_already_accepted(input.state, finding));
|
||||
let crrc_recommendation = map_epiphany_crrc_recommendation(
|
||||
true,
|
||||
state_status,
|
||||
&core_pressure,
|
||||
&reorient_decision,
|
||||
reorient_result.status,
|
||||
input.state.investigation_checkpoint.as_ref().is_some(),
|
||||
reorient_result.finding.is_some(),
|
||||
reorient_finding_accepted,
|
||||
);
|
||||
let roles = map_epiphany_roles(
|
||||
Some(input.state),
|
||||
&jobs,
|
||||
&reorient_decision,
|
||||
&core_pressure,
|
||||
&crrc_recommendation,
|
||||
reorient_result.status,
|
||||
reorient_job.as_ref(),
|
||||
);
|
||||
let coordinator = derive_epiphany_coordinator_status(
|
||||
Some(input.state),
|
||||
Some(input.runtime_store_path),
|
||||
state_status,
|
||||
&core_pressure,
|
||||
&crrc_recommendation,
|
||||
roles.roles,
|
||||
Some(&reorient_decision),
|
||||
reorient_result.status,
|
||||
reorient_result.finding.as_ref(),
|
||||
input.state.investigation_checkpoint.as_ref().is_some(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let action = select_coordinator_automation_action(
|
||||
&coordinator.core.decision,
|
||||
input.force_checkpoint_compaction,
|
||||
);
|
||||
let launch_request = match action {
|
||||
EpiphanyCoordinatorAutomationAction::LaunchReorientWorker => input
|
||||
.state
|
||||
.investigation_checkpoint
|
||||
.as_ref()
|
||||
.map(|checkpoint| {
|
||||
let dynamic_prompt_context = render_launch_dynamic_prompt_context(
|
||||
input.runtime_store_path,
|
||||
input.state,
|
||||
reorient_launch_context_focus(input.state, &reorient_decision.next_action),
|
||||
)
|
||||
.ok();
|
||||
build_epiphany_reorient_launch_request_with_dynamic_context(
|
||||
input.thread_id,
|
||||
Some(input.state.revision),
|
||||
None,
|
||||
input.state,
|
||||
checkpoint,
|
||||
&reorient_decision,
|
||||
dynamic_prompt_context,
|
||||
)
|
||||
}),
|
||||
EpiphanyCoordinatorAutomationAction::None
|
||||
| EpiphanyCoordinatorAutomationAction::CompactRehydrateReorient => None,
|
||||
};
|
||||
let action = if matches!(
|
||||
action,
|
||||
EpiphanyCoordinatorAutomationAction::LaunchReorientWorker
|
||||
) && launch_request.is_none()
|
||||
{
|
||||
EpiphanyCoordinatorAutomationAction::None
|
||||
} else {
|
||||
action
|
||||
};
|
||||
|
||||
EpiphanyCoordinatorAutomationVerdict {
|
||||
action,
|
||||
launch_request,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EpiphanyRoleBoardStatus {
|
||||
pub roles: Vec<EpiphanyRoleBoardLane>,
|
||||
pub(crate) source_jobs: Vec<EpiphanyJobView>,
|
||||
}
|
||||
|
||||
pub fn map_epiphany_roles(
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
jobs: &[EpiphanyJobView],
|
||||
decision: &CoreEpiphanyReorientDecision,
|
||||
pressure: &epiphany_core::EpiphanyPressure,
|
||||
recommendation: &CoreEpiphanyCrrcRecommendation,
|
||||
result_status: CoreEpiphanyCrrcResultStatus,
|
||||
reorient_job: Option<&EpiphanyJobView>,
|
||||
) -> EpiphanyRoleBoardStatus {
|
||||
let planning = state.map(|state| &state.planning);
|
||||
let checkpoint = state.and_then(|state| state.investigation_checkpoint.as_ref());
|
||||
let mut source_jobs = jobs.to_vec();
|
||||
if let Some(job) = reorient_job
|
||||
&& !source_jobs
|
||||
.iter()
|
||||
.any(|source_job| source_job.id == job.id && source_job.owner_role == job.owner_role)
|
||||
{
|
||||
source_jobs.push(job.clone());
|
||||
}
|
||||
let roles = derive_role_board(EpiphanyRoleBoardInput {
|
||||
state_present: state.is_some(),
|
||||
planning: EpiphanyRoleBoardPlanningSummary {
|
||||
capture_count: planning
|
||||
.map(|planning| planning.captures.len())
|
||||
.unwrap_or(0),
|
||||
backlog_item_count: planning
|
||||
.map(|planning| planning.backlog_items.len())
|
||||
.unwrap_or(0),
|
||||
roadmap_stream_count: planning
|
||||
.map(|planning| planning.roadmap_streams.len())
|
||||
.unwrap_or(0),
|
||||
objective_draft_count: planning
|
||||
.map(|planning| planning.objective_drafts.len())
|
||||
.unwrap_or(0),
|
||||
},
|
||||
checkpoint: checkpoint.map(|checkpoint| EpiphanyRoleBoardCheckpointSummary {
|
||||
disposition: Some(format!("{:?}", checkpoint.disposition)),
|
||||
next_action: checkpoint.next_action.clone(),
|
||||
}),
|
||||
reorient_next_action: decision.next_action.clone(),
|
||||
jobs: source_jobs.iter().map(map_core_role_board_job).collect(),
|
||||
crrc_action: recommendation.action,
|
||||
crrc_recommended_scene_action: recommendation
|
||||
.recommended_scene_action
|
||||
.map(epiphany_core::crrc_scene_action_to_coordinator_scene_action),
|
||||
crrc_reason: recommendation.reason.clone(),
|
||||
reorient_decision_action: format!("{:?}", decision.action),
|
||||
pressure_level: format!("{:?}", pressure.level),
|
||||
reorient_result_status: result_status,
|
||||
reorient_job: reorient_job.map(map_core_role_board_job),
|
||||
implementation_binding_id: EPIPHANY_IMPLEMENTATION_ROLE_BINDING_ID.to_string(),
|
||||
imagination_binding_id: EPIPHANY_IMAGINATION_ROLE_BINDING_ID.to_string(),
|
||||
research_binding_id: EPIPHANY_RESEARCH_ROLE_BINDING_ID.to_string(),
|
||||
modeling_binding_id: EPIPHANY_MODELING_ROLE_BINDING_ID.to_string(),
|
||||
verification_binding_id: EPIPHANY_VERIFICATION_ROLE_BINDING_ID.to_string(),
|
||||
implementation_owner_role: EPIPHANY_IMPLEMENTATION_OWNER_ROLE.to_string(),
|
||||
reorient_owner_role: EPIPHANY_REORIENT_OWNER_ROLE.to_string(),
|
||||
imagination_owner_role: EPIPHANY_IMAGINATION_OWNER_ROLE.to_string(),
|
||||
research_owner_role: epiphany_core::EPIPHANY_RESEARCH_OWNER_ROLE.to_string(),
|
||||
});
|
||||
|
||||
EpiphanyRoleBoardStatus { roles, source_jobs }
|
||||
}
|
||||
|
||||
fn map_core_role_board_job(job: &EpiphanyJobView) -> EpiphanyRoleBoardJob {
|
||||
EpiphanyRoleBoardJob {
|
||||
id: job.id.clone(),
|
||||
owner_role: job.owner_role.clone(),
|
||||
status: map_core_role_board_job_status(job.status),
|
||||
progress_note: job.progress_note.clone(),
|
||||
blocking_reason: job.blocking_reason.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_core_role_board_job_status(status: CoreEpiphanyJobStatus) -> EpiphanyRoleBoardJobStatus {
|
||||
match status {
|
||||
CoreEpiphanyJobStatus::Idle => EpiphanyRoleBoardJobStatus::Idle,
|
||||
CoreEpiphanyJobStatus::Needed => EpiphanyRoleBoardJobStatus::Needed,
|
||||
CoreEpiphanyJobStatus::Pending => EpiphanyRoleBoardJobStatus::Pending,
|
||||
CoreEpiphanyJobStatus::Running => EpiphanyRoleBoardJobStatus::Running,
|
||||
CoreEpiphanyJobStatus::Completed => EpiphanyRoleBoardJobStatus::Completed,
|
||||
CoreEpiphanyJobStatus::Failed => EpiphanyRoleBoardJobStatus::Failed,
|
||||
CoreEpiphanyJobStatus::Cancelled => EpiphanyRoleBoardJobStatus::Cancelled,
|
||||
CoreEpiphanyJobStatus::Blocked => EpiphanyRoleBoardJobStatus::Blocked,
|
||||
CoreEpiphanyJobStatus::Unavailable => EpiphanyRoleBoardJobStatus::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_epiphany_roles_note(
|
||||
roles: &[EpiphanyRoleBoardLane],
|
||||
state_status: CoreEpiphanyReorientStateStatus,
|
||||
recommendation: CoreEpiphanyCrrcAction,
|
||||
) -> String {
|
||||
render_role_board_note(
|
||||
roles,
|
||||
format!("{:?}", state_status).as_str(),
|
||||
recommendation,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn finding(
|
||||
result_id: &str,
|
||||
evidence_ids: Vec<String>,
|
||||
) -> epiphany_core::EpiphanyRoleFindingInterpretation {
|
||||
epiphany_core::EpiphanyRoleFindingInterpretation {
|
||||
verdict: Some("pass".to_string()),
|
||||
summary: Some("Finding.".to_string()),
|
||||
next_safe_move: None,
|
||||
checkpoint_summary: None,
|
||||
scratch_summary: None,
|
||||
files_inspected: Vec::new(),
|
||||
frontier_node_ids: Vec::new(),
|
||||
evidence_ids,
|
||||
artifact_refs: Vec::new(),
|
||||
runtime_result_id: Some(result_id.to_string()),
|
||||
runtime_job_id: Some(format!("job-{result_id}")),
|
||||
open_questions: Vec::new(),
|
||||
evidence_gaps: Vec::new(),
|
||||
risks: Vec::new(),
|
||||
state_patch: None,
|
||||
self_patch: None,
|
||||
self_persistence: None,
|
||||
job_error: None,
|
||||
item_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn state_with_modeling_acceptance(accepted_at: &str) -> EpiphanyThreadState {
|
||||
EpiphanyThreadState {
|
||||
acceptance_receipts: vec![epiphany_state_model::EpiphanyAcceptanceReceipt {
|
||||
id: "accept-modeling".to_string(),
|
||||
result_id: "modeling-result".to_string(),
|
||||
job_id: "modeling-job".to_string(),
|
||||
binding_id: EPIPHANY_MODELING_ROLE_BINDING_ID.to_string(),
|
||||
surface: "roleAccept".to_string(),
|
||||
role_id: "modeling".to_string(),
|
||||
status: "accepted".to_string(),
|
||||
accepted_at: accepted_at.to_string(),
|
||||
accepted_observation_id: None,
|
||||
accepted_evidence_id: None,
|
||||
summary: None,
|
||||
}],
|
||||
..EpiphanyThreadState::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hands_commit_requires_modeling_refresh_until_newer_modeling_acceptance() {
|
||||
let implementation = finding(
|
||||
"implementation-result",
|
||||
vec!["hands-receipt:hands-commit-worker-job-impl".to_string()],
|
||||
);
|
||||
let modeling = finding("modeling-result", Vec::new());
|
||||
|
||||
assert!(implementation_commit_requires_modeling_refresh(
|
||||
None,
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::Completed,
|
||||
Some(&implementation),
|
||||
Some("2026-06-02T12:00:00Z"),
|
||||
Some(&modeling),
|
||||
));
|
||||
|
||||
let stale_state = state_with_modeling_acceptance("2026-06-02T11:59:00Z");
|
||||
assert!(implementation_commit_requires_modeling_refresh(
|
||||
Some(&stale_state),
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::Completed,
|
||||
Some(&implementation),
|
||||
Some("2026-06-02T12:00:00Z"),
|
||||
Some(&modeling),
|
||||
));
|
||||
|
||||
let refreshed_state = state_with_modeling_acceptance("2026-06-02T12:01:00Z");
|
||||
assert!(!implementation_commit_requires_modeling_refresh(
|
||||
Some(&refreshed_state),
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::Completed,
|
||||
Some(&implementation),
|
||||
Some("2026-06-02T12:00:00Z"),
|
||||
Some(&modeling),
|
||||
));
|
||||
}
|
||||
}
|
||||
314
epiphany-codex-bridge/src/coordinator_protocol.rs
Normal file
314
epiphany-codex-bridge/src/coordinator_protocol.rs
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
use codex_app_server_protocol::ThreadEpiphanyCoordinatorAction;
|
||||
use codex_app_server_protocol::ThreadEpiphanyCoordinatorSignals;
|
||||
use codex_app_server_protocol::ThreadEpiphanyCrrcAction;
|
||||
use codex_app_server_protocol::ThreadEpiphanyCrrcRecommendation;
|
||||
use codex_app_server_protocol::ThreadEpiphanyJob;
|
||||
use codex_app_server_protocol::ThreadEpiphanyReorientAction;
|
||||
use codex_app_server_protocol::ThreadEpiphanyRoleId;
|
||||
use codex_app_server_protocol::ThreadEpiphanyRoleLane;
|
||||
use codex_app_server_protocol::ThreadEpiphanyRoleStatus;
|
||||
use codex_app_server_protocol::ThreadEpiphanyRolesSource;
|
||||
use codex_app_server_protocol::ThreadEpiphanySceneAction;
|
||||
use codex_app_server_protocol::ThreadEpiphanyViewCoordinator;
|
||||
use epiphany_core::EpiphanyCoordinatorAction as CoreEpiphanyCoordinatorAction;
|
||||
use epiphany_core::EpiphanyCoordinatorRoleId as CoreEpiphanyCoordinatorRoleId;
|
||||
use epiphany_core::EpiphanyCoordinatorRoleStatus as CoreEpiphanyCoordinatorRoleStatus;
|
||||
use epiphany_core::EpiphanyCoordinatorSceneAction as CoreEpiphanyCoordinatorSceneAction;
|
||||
use epiphany_core::EpiphanyCoordinatorSourceSignals;
|
||||
use epiphany_core::EpiphanyCrrcAction as CoreEpiphanyCrrcAction;
|
||||
use epiphany_core::EpiphanyCrrcRecommendation as CoreEpiphanyCrrcRecommendation;
|
||||
use epiphany_core::EpiphanyCrrcSceneAction as CoreEpiphanyCrrcSceneAction;
|
||||
use epiphany_core::EpiphanyJobStatus as CoreEpiphanyJobStatus;
|
||||
use epiphany_core::EpiphanyJobView;
|
||||
use epiphany_core::EpiphanyReorientAction as CoreEpiphanyReorientAction;
|
||||
use epiphany_core::EpiphanyReorientStateStatus as CoreEpiphanyReorientStateStatus;
|
||||
use epiphany_core::EpiphanyRoleBoardJob;
|
||||
use epiphany_core::EpiphanyRoleBoardJobStatus;
|
||||
use epiphany_core::EpiphanyRoleBoardLane;
|
||||
use epiphany_state_model::EpiphanyJobKind as CoreEpiphanyJobKind;
|
||||
|
||||
use crate::coordinator::EpiphanyCoordinatorStatus;
|
||||
use crate::coordinator::EpiphanyRoleBoardStatus;
|
||||
use crate::protocol_edge::protocol_job_from_surface;
|
||||
use crate::protocol_edge::protocol_pressure_level;
|
||||
use crate::protocol_edge::protocol_reorient_result_status;
|
||||
use crate::protocol_edge::protocol_reorient_state_status;
|
||||
use crate::protocol_edge::protocol_role_result_status;
|
||||
|
||||
fn protocol_coordinator_source_signals(
|
||||
signals: EpiphanyCoordinatorSourceSignals,
|
||||
) -> ThreadEpiphanyCoordinatorSignals {
|
||||
ThreadEpiphanyCoordinatorSignals {
|
||||
pressure_level: protocol_pressure_level(signals.pressure_level),
|
||||
should_prepare_compaction: signals.should_prepare_compaction,
|
||||
reorient_action: protocol_reorient_action(signals.reorient_action),
|
||||
crrc_action: protocol_crrc_action(signals.crrc_action),
|
||||
research_result_status: protocol_role_result_status(signals.research_result_status),
|
||||
modeling_result_status: protocol_role_result_status(signals.modeling_result_status),
|
||||
verification_result_status: protocol_role_result_status(signals.verification_result_status),
|
||||
reorient_result_status: protocol_reorient_result_status(signals.reorient_result_status),
|
||||
implementation_commit_requires_modeling_refresh: signals
|
||||
.implementation_commit_requires_modeling_refresh,
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_reorient_action(action: CoreEpiphanyReorientAction) -> ThreadEpiphanyReorientAction {
|
||||
match action {
|
||||
CoreEpiphanyReorientAction::Resume => ThreadEpiphanyReorientAction::Resume,
|
||||
CoreEpiphanyReorientAction::Regather => ThreadEpiphanyReorientAction::Regather,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn protocol_crrc_action(action: CoreEpiphanyCrrcAction) -> ThreadEpiphanyCrrcAction {
|
||||
match action {
|
||||
CoreEpiphanyCrrcAction::Continue => ThreadEpiphanyCrrcAction::Continue,
|
||||
CoreEpiphanyCrrcAction::PrepareCheckpoint => ThreadEpiphanyCrrcAction::PrepareCheckpoint,
|
||||
CoreEpiphanyCrrcAction::LaunchReorientWorker => {
|
||||
ThreadEpiphanyCrrcAction::LaunchReorientWorker
|
||||
}
|
||||
CoreEpiphanyCrrcAction::WaitForReorientWorker => {
|
||||
ThreadEpiphanyCrrcAction::WaitForReorientWorker
|
||||
}
|
||||
CoreEpiphanyCrrcAction::ReviewReorientResult => {
|
||||
ThreadEpiphanyCrrcAction::ReviewReorientResult
|
||||
}
|
||||
CoreEpiphanyCrrcAction::AcceptReorientResult => {
|
||||
ThreadEpiphanyCrrcAction::AcceptReorientResult
|
||||
}
|
||||
CoreEpiphanyCrrcAction::RegatherManually => ThreadEpiphanyCrrcAction::RegatherManually,
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_crrc_scene_action(action: CoreEpiphanyCrrcSceneAction) -> ThreadEpiphanySceneAction {
|
||||
match action {
|
||||
CoreEpiphanyCrrcSceneAction::Update => ThreadEpiphanySceneAction::Update,
|
||||
CoreEpiphanyCrrcSceneAction::Reorient => ThreadEpiphanySceneAction::Reorient,
|
||||
CoreEpiphanyCrrcSceneAction::ReorientLaunch => ThreadEpiphanySceneAction::ReorientLaunch,
|
||||
CoreEpiphanyCrrcSceneAction::ReorientResult => ThreadEpiphanySceneAction::ReorientResult,
|
||||
CoreEpiphanyCrrcSceneAction::ReorientAccept => ThreadEpiphanySceneAction::ReorientAccept,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn protocol_crrc_recommendation(
|
||||
recommendation: CoreEpiphanyCrrcRecommendation,
|
||||
) -> ThreadEpiphanyCrrcRecommendation {
|
||||
ThreadEpiphanyCrrcRecommendation {
|
||||
action: protocol_crrc_action(recommendation.action),
|
||||
recommended_scene_action: recommendation
|
||||
.recommended_scene_action
|
||||
.map(protocol_crrc_scene_action),
|
||||
reason: recommendation.reason,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn protocol_coordinator_view(
|
||||
thread_id: String,
|
||||
loaded: bool,
|
||||
state_status: CoreEpiphanyReorientStateStatus,
|
||||
state_revision: Option<u64>,
|
||||
status: EpiphanyCoordinatorStatus,
|
||||
roles: EpiphanyRoleBoardStatus,
|
||||
) -> ThreadEpiphanyViewCoordinator {
|
||||
ThreadEpiphanyViewCoordinator {
|
||||
thread_id,
|
||||
source: if loaded {
|
||||
ThreadEpiphanyRolesSource::Live
|
||||
} else {
|
||||
ThreadEpiphanyRolesSource::Stored
|
||||
},
|
||||
state_status: protocol_reorient_state_status(state_status),
|
||||
state_revision,
|
||||
action: protocol_coordinator_action(status.core.decision.action),
|
||||
target_role: status
|
||||
.core
|
||||
.decision
|
||||
.target_role
|
||||
.map(protocol_coordinator_role_id),
|
||||
recommended_scene_action: status
|
||||
.core
|
||||
.decision
|
||||
.recommended_scene_action
|
||||
.map(protocol_coordinator_scene_action),
|
||||
requires_review: status.core.decision.requires_review,
|
||||
can_auto_run: status.core.decision.can_auto_run,
|
||||
reason: status.core.decision.reason,
|
||||
source_signals: protocol_coordinator_source_signals(status.core.source_signals),
|
||||
roles: protocol_role_board_lanes(&roles),
|
||||
note: status.note,
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_coordinator_role_id(role_id: CoreEpiphanyCoordinatorRoleId) -> ThreadEpiphanyRoleId {
|
||||
match role_id {
|
||||
CoreEpiphanyCoordinatorRoleId::Implementation => ThreadEpiphanyRoleId::Implementation,
|
||||
CoreEpiphanyCoordinatorRoleId::Imagination => ThreadEpiphanyRoleId::Imagination,
|
||||
CoreEpiphanyCoordinatorRoleId::Research => ThreadEpiphanyRoleId::Research,
|
||||
CoreEpiphanyCoordinatorRoleId::Modeling => ThreadEpiphanyRoleId::Modeling,
|
||||
CoreEpiphanyCoordinatorRoleId::Verification => ThreadEpiphanyRoleId::Verification,
|
||||
CoreEpiphanyCoordinatorRoleId::Reorientation => ThreadEpiphanyRoleId::Reorientation,
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_coordinator_scene_action(
|
||||
action: CoreEpiphanyCoordinatorSceneAction,
|
||||
) -> ThreadEpiphanySceneAction {
|
||||
match action {
|
||||
CoreEpiphanyCoordinatorSceneAction::Update => ThreadEpiphanySceneAction::Update,
|
||||
CoreEpiphanyCoordinatorSceneAction::Reorient => ThreadEpiphanySceneAction::Reorient,
|
||||
CoreEpiphanyCoordinatorSceneAction::ReorientLaunch => {
|
||||
ThreadEpiphanySceneAction::ReorientLaunch
|
||||
}
|
||||
CoreEpiphanyCoordinatorSceneAction::ReorientResult => {
|
||||
ThreadEpiphanySceneAction::ReorientResult
|
||||
}
|
||||
CoreEpiphanyCoordinatorSceneAction::ReorientAccept => {
|
||||
ThreadEpiphanySceneAction::ReorientAccept
|
||||
}
|
||||
CoreEpiphanyCoordinatorSceneAction::RoleLaunch => ThreadEpiphanySceneAction::RoleLaunch,
|
||||
CoreEpiphanyCoordinatorSceneAction::RoleResult => ThreadEpiphanySceneAction::RoleResult,
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_coordinator_action(
|
||||
action: CoreEpiphanyCoordinatorAction,
|
||||
) -> ThreadEpiphanyCoordinatorAction {
|
||||
match action {
|
||||
CoreEpiphanyCoordinatorAction::PrepareCheckpoint => {
|
||||
ThreadEpiphanyCoordinatorAction::PrepareCheckpoint
|
||||
}
|
||||
CoreEpiphanyCoordinatorAction::CompactRehydrateReorient => {
|
||||
ThreadEpiphanyCoordinatorAction::CompactRehydrateReorient
|
||||
}
|
||||
CoreEpiphanyCoordinatorAction::LaunchReorientWorker => {
|
||||
ThreadEpiphanyCoordinatorAction::LaunchReorientWorker
|
||||
}
|
||||
CoreEpiphanyCoordinatorAction::WaitForReorientWorker => {
|
||||
ThreadEpiphanyCoordinatorAction::WaitForReorientWorker
|
||||
}
|
||||
CoreEpiphanyCoordinatorAction::ReviewReorientResult => {
|
||||
ThreadEpiphanyCoordinatorAction::ReviewReorientResult
|
||||
}
|
||||
CoreEpiphanyCoordinatorAction::RegatherManually => {
|
||||
ThreadEpiphanyCoordinatorAction::RegatherManually
|
||||
}
|
||||
CoreEpiphanyCoordinatorAction::LaunchResearch => {
|
||||
ThreadEpiphanyCoordinatorAction::LaunchResearch
|
||||
}
|
||||
CoreEpiphanyCoordinatorAction::ReviewResearchResult => {
|
||||
ThreadEpiphanyCoordinatorAction::ReviewResearchResult
|
||||
}
|
||||
CoreEpiphanyCoordinatorAction::LaunchModeling => {
|
||||
ThreadEpiphanyCoordinatorAction::LaunchModeling
|
||||
}
|
||||
CoreEpiphanyCoordinatorAction::ReviewModelingResult => {
|
||||
ThreadEpiphanyCoordinatorAction::ReviewModelingResult
|
||||
}
|
||||
CoreEpiphanyCoordinatorAction::LaunchVerification => {
|
||||
ThreadEpiphanyCoordinatorAction::LaunchVerification
|
||||
}
|
||||
CoreEpiphanyCoordinatorAction::ReviewVerificationResult => {
|
||||
ThreadEpiphanyCoordinatorAction::ReviewVerificationResult
|
||||
}
|
||||
CoreEpiphanyCoordinatorAction::ContinueImplementation => {
|
||||
ThreadEpiphanyCoordinatorAction::ContinueImplementation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn protocol_role_board_lanes(
|
||||
role_board: &EpiphanyRoleBoardStatus,
|
||||
) -> Vec<ThreadEpiphanyRoleLane> {
|
||||
role_board
|
||||
.roles
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|lane| protocol_role_board_lane(lane, &role_board.source_jobs))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn protocol_role_board_lane(
|
||||
lane: EpiphanyRoleBoardLane,
|
||||
source_jobs: &[EpiphanyJobView],
|
||||
) -> ThreadEpiphanyRoleLane {
|
||||
ThreadEpiphanyRoleLane {
|
||||
id: protocol_coordinator_role_id(lane.id),
|
||||
title: lane.title,
|
||||
owner_role: lane.owner_role,
|
||||
status: protocol_coordinator_role_status(lane.status),
|
||||
note: lane.note,
|
||||
jobs: lane
|
||||
.jobs
|
||||
.iter()
|
||||
.map(|job| protocol_role_board_job(job, source_jobs))
|
||||
.collect(),
|
||||
authority_scopes: lane.authority_scopes,
|
||||
recommended_action: lane
|
||||
.recommended_action
|
||||
.map(protocol_coordinator_scene_action),
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_role_board_job(
|
||||
job: &EpiphanyRoleBoardJob,
|
||||
source_jobs: &[EpiphanyJobView],
|
||||
) -> ThreadEpiphanyJob {
|
||||
source_jobs
|
||||
.iter()
|
||||
.find(|source_job| source_job.id == job.id && source_job.owner_role == job.owner_role)
|
||||
.cloned()
|
||||
.map(|job| protocol_job_from_surface(job, None, None))
|
||||
.unwrap_or_else(|| {
|
||||
protocol_job_from_surface(
|
||||
EpiphanyJobView {
|
||||
id: job.id.clone(),
|
||||
kind: CoreEpiphanyJobKind::Specialist,
|
||||
scope: job.id.clone(),
|
||||
owner_role: job.owner_role.clone(),
|
||||
authority_scope: None,
|
||||
runtime_job_id: None,
|
||||
status: core_epiphany_job_status_from_role_board(job.status),
|
||||
items_processed: None,
|
||||
items_total: None,
|
||||
progress_note: job.progress_note.clone(),
|
||||
last_checkpoint_at_unix_seconds: None,
|
||||
blocking_reason: job.blocking_reason.clone(),
|
||||
active_thread_ids: Vec::new(),
|
||||
linked_subgoal_ids: Vec::new(),
|
||||
linked_graph_node_ids: Vec::new(),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn core_epiphany_job_status_from_role_board(
|
||||
status: EpiphanyRoleBoardJobStatus,
|
||||
) -> CoreEpiphanyJobStatus {
|
||||
match status {
|
||||
EpiphanyRoleBoardJobStatus::Idle => CoreEpiphanyJobStatus::Idle,
|
||||
EpiphanyRoleBoardJobStatus::Needed => CoreEpiphanyJobStatus::Needed,
|
||||
EpiphanyRoleBoardJobStatus::Pending => CoreEpiphanyJobStatus::Pending,
|
||||
EpiphanyRoleBoardJobStatus::Running => CoreEpiphanyJobStatus::Running,
|
||||
EpiphanyRoleBoardJobStatus::Completed => CoreEpiphanyJobStatus::Completed,
|
||||
EpiphanyRoleBoardJobStatus::Failed => CoreEpiphanyJobStatus::Failed,
|
||||
EpiphanyRoleBoardJobStatus::Cancelled => CoreEpiphanyJobStatus::Cancelled,
|
||||
EpiphanyRoleBoardJobStatus::Blocked => CoreEpiphanyJobStatus::Blocked,
|
||||
EpiphanyRoleBoardJobStatus::Unavailable => CoreEpiphanyJobStatus::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_coordinator_role_status(
|
||||
status: CoreEpiphanyCoordinatorRoleStatus,
|
||||
) -> ThreadEpiphanyRoleStatus {
|
||||
match status {
|
||||
CoreEpiphanyCoordinatorRoleStatus::Ready => ThreadEpiphanyRoleStatus::Ready,
|
||||
CoreEpiphanyCoordinatorRoleStatus::Needed => ThreadEpiphanyRoleStatus::Needed,
|
||||
CoreEpiphanyCoordinatorRoleStatus::Running => ThreadEpiphanyRoleStatus::Running,
|
||||
CoreEpiphanyCoordinatorRoleStatus::Waiting => ThreadEpiphanyRoleStatus::Waiting,
|
||||
CoreEpiphanyCoordinatorRoleStatus::Review => ThreadEpiphanyRoleStatus::Review,
|
||||
CoreEpiphanyCoordinatorRoleStatus::Blocked => ThreadEpiphanyRoleStatus::Blocked,
|
||||
CoreEpiphanyCoordinatorRoleStatus::Unavailable => ThreadEpiphanyRoleStatus::Unavailable,
|
||||
CoreEpiphanyCoordinatorRoleStatus::Completed => ThreadEpiphanyRoleStatus::Completed,
|
||||
}
|
||||
}
|
||||
42
epiphany-codex-bridge/src/cultnet.rs
Normal file
42
epiphany-codex-bridge/src/cultnet.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
use epiphany_core::EpiphanyGraphFreshness;
|
||||
pub use epiphany_core::EpiphanyGraphFreshnessStatus;
|
||||
use epiphany_core::EpiphanyInvalidationInput;
|
||||
pub use epiphany_core::EpiphanyInvalidationStatus;
|
||||
pub use epiphany_core::EpiphanyJobStatus;
|
||||
pub use epiphany_core::EpiphanyJobView;
|
||||
pub use epiphany_core::EpiphanyPressure;
|
||||
pub use epiphany_core::EpiphanyPressureBasis;
|
||||
pub use epiphany_core::EpiphanyPressureLevel;
|
||||
pub use epiphany_core::EpiphanyPressureStatus;
|
||||
pub use epiphany_core::EpiphanyReorientAction;
|
||||
pub use epiphany_core::EpiphanyReorientCheckpointStatus;
|
||||
pub use epiphany_core::EpiphanyReorientDecision;
|
||||
pub use epiphany_core::EpiphanyReorientFreshnessStatus;
|
||||
pub use epiphany_core::EpiphanyReorientPressureLevel;
|
||||
pub use epiphany_core::EpiphanyReorientReason;
|
||||
pub use epiphany_core::EpiphanyReorientStateStatus;
|
||||
use epiphany_core::EpiphanyRetrievalFreshness;
|
||||
pub use epiphany_core::EpiphanyRetrievalFreshnessStatus;
|
||||
pub use epiphany_core::EpiphanyStateUpdatedField;
|
||||
pub use epiphany_state_model::EpiphanyJobKind;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum EpiphanySurfaceSource {
|
||||
Stored,
|
||||
Live,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EpiphanyFreshnessSurface {
|
||||
pub thread_id: String,
|
||||
pub source: EpiphanySurfaceSource,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub state_revision: Option<u64>,
|
||||
pub retrieval: EpiphanyRetrievalFreshness,
|
||||
pub graph: EpiphanyGraphFreshness,
|
||||
pub watcher: EpiphanyInvalidationInput,
|
||||
}
|
||||
20
epiphany-codex-bridge/src/error.rs
Normal file
20
epiphany-codex-bridge/src/error.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
use std::error::Error;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EpiphanyBridgeError {
|
||||
InvalidRequest(String),
|
||||
Fatal(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, EpiphanyBridgeError>;
|
||||
|
||||
impl fmt::Display for EpiphanyBridgeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidRequest(message) | Self::Fatal(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for EpiphanyBridgeError {}
|
||||
202
epiphany-codex-bridge/src/invalidation.rs
Normal file
202
epiphany-codex-bridge/src/invalidation.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use notify::RecursiveMode;
|
||||
use notify::Watcher;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::reorient::EpiphanyFreshnessWatcherSnapshot;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct EpiphanyInvalidationSnapshot {
|
||||
pub available: bool,
|
||||
pub workspace_root: Option<AbsolutePathBuf>,
|
||||
pub observed_at_unix_seconds: Option<i64>,
|
||||
pub changed_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
pub fn epiphany_freshness_watcher_snapshot(
|
||||
snapshot: &EpiphanyInvalidationSnapshot,
|
||||
) -> EpiphanyFreshnessWatcherSnapshot<'_> {
|
||||
EpiphanyFreshnessWatcherSnapshot {
|
||||
available: snapshot.available,
|
||||
workspace_root: snapshot.workspace_root.as_ref().map(|path| path.as_path()),
|
||||
observed_at_unix_seconds: snapshot.observed_at_unix_seconds,
|
||||
changed_paths: snapshot.changed_paths.as_slice(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EpiphanyInvalidationManager {
|
||||
state: Arc<AsyncMutex<HashMap<String, WatchEntry>>>,
|
||||
}
|
||||
|
||||
struct WatchEntry {
|
||||
workspace_root: AbsolutePathBuf,
|
||||
latest: Arc<AsyncMutex<LatestInvalidation>>,
|
||||
terminate_tx: oneshot::Sender<oneshot::Sender<()>>,
|
||||
_watcher: notify::RecommendedWatcher,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
struct LatestInvalidation {
|
||||
observed_at_unix_seconds: Option<i64>,
|
||||
changed_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl EpiphanyInvalidationManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(AsyncMutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ensure_thread_watch(&self, thread_id: &str, workspace_root: &AbsolutePathBuf) {
|
||||
let existing_entry = {
|
||||
let mut state = self.state.lock().await;
|
||||
match state.get(thread_id) {
|
||||
Some(entry) if &entry.workspace_root == workspace_root => return,
|
||||
Some(_) => state.remove(thread_id),
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(entry) = existing_entry {
|
||||
stop_watch_entry(entry).await;
|
||||
}
|
||||
|
||||
let latest = Arc::new(AsyncMutex::new(LatestInvalidation::default()));
|
||||
let (event_tx, mut event_rx) = mpsc::unbounded_channel::<Vec<PathBuf>>();
|
||||
let mut watcher =
|
||||
match notify::recommended_watcher(move |result: notify::Result<notify::Event>| {
|
||||
match result {
|
||||
Ok(event) => {
|
||||
let _ = event_tx.send(event.paths);
|
||||
}
|
||||
Err(err) => warn!("epiphany invalidation watcher event failed: {err}"),
|
||||
}
|
||||
}) {
|
||||
Ok(watcher) => watcher,
|
||||
Err(err) => {
|
||||
warn!("epiphany invalidation manager could not create watcher: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(err) = watcher.watch(workspace_root.as_path(), RecursiveMode::Recursive) {
|
||||
warn!(
|
||||
"epiphany invalidation manager could not watch `{}`: {err}",
|
||||
workspace_root.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
let (terminate_tx, terminate_rx) = oneshot::channel();
|
||||
let workspace_root_path = workspace_root.to_path_buf();
|
||||
|
||||
let entry = WatchEntry {
|
||||
workspace_root: workspace_root.clone(),
|
||||
latest: Arc::clone(&latest),
|
||||
terminate_tx,
|
||||
_watcher: watcher,
|
||||
};
|
||||
self.state.lock().await.insert(thread_id.to_string(), entry);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut terminate_rx = terminate_rx;
|
||||
let mut done_tx = None;
|
||||
|
||||
loop {
|
||||
let paths = tokio::select! {
|
||||
biased;
|
||||
result = &mut terminate_rx => {
|
||||
done_tx = result.ok();
|
||||
break;
|
||||
}
|
||||
paths = event_rx.recv() => match paths {
|
||||
Some(paths) => paths,
|
||||
None => break,
|
||||
},
|
||||
};
|
||||
|
||||
let observed_at_unix_seconds = unix_now();
|
||||
let mut latest = latest.lock().await;
|
||||
latest.observed_at_unix_seconds = Some(observed_at_unix_seconds);
|
||||
latest.changed_paths =
|
||||
normalize_changed_paths(paths, workspace_root_path.as_path());
|
||||
}
|
||||
|
||||
if let Some(done_tx) = done_tx {
|
||||
let _ = done_tx.send(());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn snapshot(&self, thread_id: &str) -> EpiphanyInvalidationSnapshot {
|
||||
let (workspace_root, latest) = {
|
||||
let state = self.state.lock().await;
|
||||
match state.get(thread_id) {
|
||||
Some(entry) => (
|
||||
Some(entry.workspace_root.clone()),
|
||||
Some(Arc::clone(&entry.latest)),
|
||||
),
|
||||
None => (None, None),
|
||||
}
|
||||
};
|
||||
|
||||
let Some(latest) = latest else {
|
||||
return EpiphanyInvalidationSnapshot {
|
||||
available: false,
|
||||
workspace_root: None,
|
||||
observed_at_unix_seconds: None,
|
||||
changed_paths: Vec::new(),
|
||||
};
|
||||
};
|
||||
|
||||
let latest = latest.lock().await.clone();
|
||||
EpiphanyInvalidationSnapshot {
|
||||
available: true,
|
||||
workspace_root,
|
||||
observed_at_unix_seconds: latest.observed_at_unix_seconds,
|
||||
changed_paths: latest.changed_paths,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_thread(&self, thread_id: &str) {
|
||||
let entry = self.state.lock().await.remove(thread_id);
|
||||
if let Some(entry) = entry {
|
||||
stop_watch_entry(entry).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_watch_entry(entry: WatchEntry) {
|
||||
let (done_tx, done_rx) = oneshot::channel();
|
||||
let _ = entry.terminate_tx.send(done_tx);
|
||||
let _ = done_rx.await;
|
||||
}
|
||||
|
||||
fn unix_now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn normalize_changed_paths(paths: Vec<PathBuf>, workspace_root: &std::path::Path) -> Vec<PathBuf> {
|
||||
let mut normalized = paths
|
||||
.into_iter()
|
||||
.filter_map(|path| match path.strip_prefix(workspace_root) {
|
||||
Ok(relative) if relative.as_os_str().is_empty() => None,
|
||||
Ok(relative) => Some(relative.to_path_buf()),
|
||||
Err(_) => Some(path),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
normalized.sort();
|
||||
normalized.dedup();
|
||||
normalized
|
||||
}
|
||||
95
epiphany-codex-bridge/src/jobs.rs
Normal file
95
epiphany-codex-bridge/src/jobs.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
use epiphany_core::EpiphanyJobStatus as CoreEpiphanyJobStatus;
|
||||
use epiphany_core::EpiphanyJobView;
|
||||
use epiphany_core::EpiphanyJobsInput;
|
||||
use epiphany_core::derive_jobs;
|
||||
use epiphany_state_model::EpiphanyJobKind as CoreEpiphanyJobKind;
|
||||
use epiphany_state_model::EpiphanyRetrievalState;
|
||||
use epiphany_state_model::EpiphanyThreadState;
|
||||
|
||||
pub fn derive_epiphany_jobs(
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
retrieval_override: Option<&EpiphanyRetrievalState>,
|
||||
) -> Vec<EpiphanyJobView> {
|
||||
derive_jobs(EpiphanyJobsInput {
|
||||
state,
|
||||
retrieval_override,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn map_epiphany_jobs(
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
retrieval_override: Option<&EpiphanyRetrievalState>,
|
||||
) -> Vec<EpiphanyJobView> {
|
||||
derive_epiphany_jobs(state, retrieval_override)
|
||||
}
|
||||
|
||||
pub fn map_launched_epiphany_job(
|
||||
state: &EpiphanyThreadState,
|
||||
binding_id: &str,
|
||||
backend_job_id: &str,
|
||||
fallback_kind: CoreEpiphanyJobKind,
|
||||
fallback_scope: &str,
|
||||
) -> EpiphanyJobView {
|
||||
derive_epiphany_jobs(Some(state), None)
|
||||
.into_iter()
|
||||
.find(|job| job.id == binding_id)
|
||||
.unwrap_or_else(|| EpiphanyJobView {
|
||||
id: binding_id.to_string(),
|
||||
kind: fallback_kind,
|
||||
scope: fallback_scope.to_string(),
|
||||
owner_role: "epiphany-harness".to_string(),
|
||||
authority_scope: None,
|
||||
runtime_job_id: Some(backend_job_id.to_string()),
|
||||
status: CoreEpiphanyJobStatus::Pending,
|
||||
items_processed: None,
|
||||
items_total: None,
|
||||
progress_note: None,
|
||||
last_checkpoint_at_unix_seconds: None,
|
||||
blocking_reason: None,
|
||||
active_thread_ids: Vec::new(),
|
||||
linked_subgoal_ids: Vec::new(),
|
||||
linked_graph_node_ids: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn map_interrupted_epiphany_job(
|
||||
state: &EpiphanyThreadState,
|
||||
binding_id: &str,
|
||||
) -> EpiphanyJobView {
|
||||
derive_epiphany_jobs(Some(state), None)
|
||||
.into_iter()
|
||||
.find(|job| job.id == binding_id)
|
||||
.unwrap_or_else(|| {
|
||||
epiphany_blocked_state_job(
|
||||
binding_id,
|
||||
CoreEpiphanyJobKind::Specialist,
|
||||
"role-scoped specialist work",
|
||||
"Interrupted job binding was not reflected in Epiphany state.",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn epiphany_blocked_state_job(
|
||||
id: &str,
|
||||
kind: CoreEpiphanyJobKind,
|
||||
scope: &str,
|
||||
blocking_reason: &str,
|
||||
) -> EpiphanyJobView {
|
||||
EpiphanyJobView {
|
||||
id: id.to_string(),
|
||||
kind,
|
||||
scope: scope.to_string(),
|
||||
owner_role: "epiphany-harness".to_string(),
|
||||
authority_scope: None,
|
||||
runtime_job_id: None,
|
||||
status: CoreEpiphanyJobStatus::Blocked,
|
||||
items_processed: None,
|
||||
items_total: None,
|
||||
progress_note: None,
|
||||
last_checkpoint_at_unix_seconds: None,
|
||||
blocking_reason: Some(blocking_reason.to_string()),
|
||||
active_thread_ids: Vec::new(),
|
||||
linked_subgoal_ids: Vec::new(),
|
||||
linked_graph_node_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
40
epiphany-codex-bridge/src/launch.rs
Normal file
40
epiphany-codex-bridge/src/launch.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
//! Compatibility facade for older bridge callers.
|
||||
//!
|
||||
//! Epiphany prompt doctrine, worker launch policy, role binding ids, and output
|
||||
//! schemas live in `epiphany-core`. The bridge may translate those typed launch
|
||||
//! requests to Codex JSON-RPC, but it does not own the agent instructions.
|
||||
|
||||
pub use epiphany_core::EPIPHANY_IMAGINATION_OWNER_ROLE;
|
||||
pub use epiphany_core::EPIPHANY_IMAGINATION_ROLE_BINDING_ID;
|
||||
pub use epiphany_core::EPIPHANY_IMPLEMENTATION_OWNER_ROLE;
|
||||
pub use epiphany_core::EPIPHANY_IMPLEMENTATION_ROLE_BINDING_ID;
|
||||
pub use epiphany_core::EPIPHANY_MODELING_OWNER_ROLE;
|
||||
pub use epiphany_core::EPIPHANY_MODELING_ROLE_BINDING_ID;
|
||||
pub use epiphany_core::EPIPHANY_REORIENT_LAUNCH_BINDING_ID;
|
||||
pub use epiphany_core::EPIPHANY_REORIENT_OWNER_ROLE;
|
||||
pub use epiphany_core::EPIPHANY_RESEARCH_OWNER_ROLE;
|
||||
pub use epiphany_core::EPIPHANY_RESEARCH_ROLE_BINDING_ID;
|
||||
pub use epiphany_core::EPIPHANY_VERIFICATION_OWNER_ROLE;
|
||||
pub use epiphany_core::EPIPHANY_VERIFICATION_ROLE_BINDING_ID;
|
||||
pub use epiphany_core::EpiphanyCoordinatorPromptConfig;
|
||||
pub use epiphany_core::EpiphanyCrrcPromptConfig;
|
||||
pub use epiphany_core::EpiphanyImplementationPromptConfig;
|
||||
pub use epiphany_core::EpiphanyReorientationPromptConfig;
|
||||
pub use epiphany_core::EpiphanyRolePromptConfig;
|
||||
pub use epiphany_core::EpiphanySharedPromptConfig;
|
||||
pub use epiphany_core::EpiphanySpecialistPromptConfig;
|
||||
pub use epiphany_core::build_epiphany_job_launch_request;
|
||||
pub use epiphany_core::build_epiphany_reorient_launch_instruction;
|
||||
pub use epiphany_core::build_epiphany_reorient_launch_request;
|
||||
pub use epiphany_core::build_epiphany_role_launch_request;
|
||||
pub use epiphany_core::epiphany_agent_prompt_with_memory;
|
||||
pub use epiphany_core::epiphany_reorient_launch_output_schema;
|
||||
pub use epiphany_core::epiphany_role_binding_id;
|
||||
pub use epiphany_core::epiphany_role_label;
|
||||
pub use epiphany_core::epiphany_role_launch_output_schema;
|
||||
pub use epiphany_core::epiphany_role_owner;
|
||||
pub use epiphany_core::epiphany_specialist_prompt_config;
|
||||
pub use epiphany_core::epiphany_worker_prompt;
|
||||
pub use epiphany_core::render_epiphany_coordinator_note;
|
||||
pub use epiphany_core::render_epiphany_pre_compaction_checkpoint_intervention;
|
||||
pub use epiphany_core::unique_strings;
|
||||
250
epiphany-codex-bridge/src/launch_context.rs
Normal file
250
epiphany-codex-bridge/src/launch_context.rs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
use chrono::SecondsFormat;
|
||||
use chrono::Utc;
|
||||
use epiphany_core::EpiphanyMemoryContextPacket;
|
||||
use epiphany_core::EpiphanyMemoryContextQuery;
|
||||
use epiphany_core::EpiphanyMemoryProfile;
|
||||
use epiphany_core::EpiphanyPromptContextInput;
|
||||
use epiphany_core::EpiphanyThreadState;
|
||||
use epiphany_core::import_gjallar_daemon_affordances;
|
||||
use epiphany_core::load_memory_graph_snapshot;
|
||||
use epiphany_core::memory_graph_from_epiphany_graphs;
|
||||
use epiphany_core::plan_memory_graph_context_cut;
|
||||
use epiphany_core::query_epiphany_local_verse_context;
|
||||
use epiphany_core::render_epiphany_prompt_context;
|
||||
use epiphany_core::seed_epiphany_local_verse_context;
|
||||
use epiphany_core::write_memory_graph_snapshot;
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub const EPIPHANY_LOCAL_VERSE_RUNTIME_ID: &str = "epiphany-local";
|
||||
|
||||
pub fn local_verse_store_path(runtime_store_path: &Path) -> PathBuf {
|
||||
sibling_state_store_path(runtime_store_path, "local-verse.cc")
|
||||
}
|
||||
|
||||
pub fn memory_graph_store_path(runtime_store_path: &Path) -> PathBuf {
|
||||
sibling_state_store_path(runtime_store_path, "memory-graph.cc")
|
||||
}
|
||||
|
||||
fn sibling_state_store_path(runtime_store_path: &Path, filename: &str) -> PathBuf {
|
||||
runtime_store_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(filename)
|
||||
}
|
||||
|
||||
pub fn role_launch_context_focus(state: &EpiphanyThreadState, role_label: &str) -> String {
|
||||
let objective = state
|
||||
.objective
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|objective| !objective.is_empty())
|
||||
.unwrap_or("Epiphany worker launch");
|
||||
format!("Launch `{role_label}` worker for: {objective}")
|
||||
}
|
||||
|
||||
pub fn reorient_launch_context_focus(state: &EpiphanyThreadState, next_action: &str) -> String {
|
||||
let objective = state
|
||||
.objective
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|objective| !objective.is_empty())
|
||||
.unwrap_or("Epiphany reorientation");
|
||||
format!("Launch reorientation worker for: {objective}. Next action: {next_action}")
|
||||
}
|
||||
|
||||
pub fn render_launch_dynamic_prompt_context(
|
||||
runtime_store_path: &Path,
|
||||
state: &EpiphanyThreadState,
|
||||
focus: String,
|
||||
) -> Result<String, String> {
|
||||
let local_verse_store = local_verse_store_path(runtime_store_path);
|
||||
seed_epiphany_local_verse_context(
|
||||
&local_verse_store,
|
||||
EPIPHANY_LOCAL_VERSE_RUNTIME_ID,
|
||||
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
)
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"failed to seed local Verse context store {}: {error}",
|
||||
local_verse_store.display()
|
||||
)
|
||||
})?;
|
||||
if let Some(gjallar_store) = gjallar_affordance_store_path() {
|
||||
import_gjallar_daemon_affordances(
|
||||
&local_verse_store,
|
||||
&gjallar_store,
|
||||
EPIPHANY_LOCAL_VERSE_RUNTIME_ID,
|
||||
)
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"failed to import Gjallar daemon affordances from {} into {}: {error}",
|
||||
gjallar_store.display(),
|
||||
local_verse_store.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let local_verse =
|
||||
query_epiphany_local_verse_context(&local_verse_store, EPIPHANY_LOCAL_VERSE_RUNTIME_ID)
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"failed to query local Verse context store {}: {error}",
|
||||
local_verse_store.display()
|
||||
)
|
||||
})?;
|
||||
let memory_context =
|
||||
launch_memory_context(runtime_store_path, state, focus.as_str()).map_err(|error| {
|
||||
format!(
|
||||
"failed to build launch memory context beside {}: {error}",
|
||||
runtime_store_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(render_epiphany_prompt_context(
|
||||
&EpiphanyPromptContextInput {
|
||||
focus,
|
||||
local_verse,
|
||||
memory_context,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn gjallar_affordance_store_path() -> Option<PathBuf> {
|
||||
env::var_os("EPIPHANY_GJALLAR_AFFORDANCE_STORE")
|
||||
.map(PathBuf::from)
|
||||
.filter(|path| path.exists())
|
||||
}
|
||||
|
||||
fn launch_memory_context(
|
||||
runtime_store_path: &Path,
|
||||
state: &EpiphanyThreadState,
|
||||
focus: &str,
|
||||
) -> Result<EpiphanyMemoryContextPacket, String> {
|
||||
let memory_graph_store = memory_graph_store_path(runtime_store_path);
|
||||
let (snapshot, refreshed_from_state) = match load_memory_graph_snapshot(&memory_graph_store)
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"failed to load memory graph store {}: {error}",
|
||||
memory_graph_store.display()
|
||||
)
|
||||
})? {
|
||||
Some(snapshot) => (snapshot, false),
|
||||
None => {
|
||||
let snapshot = memory_graph_from_epiphany_graphs(
|
||||
format!("bridge-launch-state-rev-{}", state.revision),
|
||||
&state.graphs,
|
||||
);
|
||||
write_memory_graph_snapshot(&memory_graph_store, &snapshot).map_err(|error| {
|
||||
format!(
|
||||
"failed to write memory graph store {} from thread state: {error}",
|
||||
memory_graph_store.display()
|
||||
)
|
||||
})?;
|
||||
(snapshot, true)
|
||||
}
|
||||
};
|
||||
|
||||
let mut packet = plan_memory_graph_context_cut(
|
||||
&snapshot,
|
||||
&EpiphanyMemoryContextQuery {
|
||||
id: format!("bridge-launch-query-state-rev-{}", state.revision),
|
||||
profile: Some(EpiphanyMemoryProfile::RepoArchitecture),
|
||||
text: Some(focus.to_string()),
|
||||
budget: Some(5),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if refreshed_from_state {
|
||||
packet.warnings.push(format!(
|
||||
"Memory graph store was refreshed from current thread-state repo graph at {}.",
|
||||
memory_graph_store.display()
|
||||
));
|
||||
}
|
||||
if packet.nodes.is_empty() && packet.summaries.is_empty() {
|
||||
packet.warnings.push(
|
||||
"Memory graph context is empty for this launch focus; the accepted repo graph may be thin or stale.".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(packet)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use epiphany_core::EpiphanyRoleResultRoleId;
|
||||
use epiphany_core::RuntimeSpineHeartbeatJobOptions;
|
||||
use epiphany_core::build_epiphany_role_launch_request_with_dynamic_context;
|
||||
use epiphany_core::open_runtime_spine_heartbeat_job;
|
||||
use epiphany_core::runtime_worker_launch_request;
|
||||
use std::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn launch_context_persists_on_runtime_worker_request() -> anyhow::Result<()> {
|
||||
let temp =
|
||||
std::env::temp_dir().join(format!("epiphany-bridge-launch-context-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp)?;
|
||||
let runtime_store = temp.join("runtime-spine.cc");
|
||||
let state = EpiphanyThreadState {
|
||||
revision: 7,
|
||||
objective: Some("Test launch context.".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let rendered = render_launch_dynamic_prompt_context(
|
||||
&runtime_store,
|
||||
&state,
|
||||
role_launch_context_focus(&state, "modeling"),
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
|
||||
assert!(rendered.contains("<epiphany_dynamic_context>"));
|
||||
assert!(rendered.contains("Test launch context."));
|
||||
assert!(rendered.contains("Odin"));
|
||||
assert!(rendered.contains("Yggdrasil"));
|
||||
assert!(rendered.contains("Memory graph"));
|
||||
assert!(local_verse_store_path(&runtime_store).exists());
|
||||
assert!(memory_graph_store_path(&runtime_store).exists());
|
||||
|
||||
let launch_request = build_epiphany_role_launch_request_with_dynamic_context(
|
||||
"thread-1",
|
||||
EpiphanyRoleResultRoleId::Modeling,
|
||||
Some(state.revision),
|
||||
Some(60),
|
||||
&state,
|
||||
Some(rendered.clone()),
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
open_runtime_spine_heartbeat_job(
|
||||
&runtime_store,
|
||||
RuntimeSpineHeartbeatJobOptions {
|
||||
runtime_id: EPIPHANY_LOCAL_VERSE_RUNTIME_ID.to_string(),
|
||||
display_name: "Epiphany Local".to_string(),
|
||||
session_id: "epiphany-main".to_string(),
|
||||
objective: "Test persisted launch context.".to_string(),
|
||||
coordinator_note: "Bridge launch-context smoke.".to_string(),
|
||||
job_id: "job-launch-context".to_string(),
|
||||
role: launch_request.owner_role.clone(),
|
||||
binding_id: launch_request.binding_id.clone(),
|
||||
authority_scope: launch_request.authority_scope.clone(),
|
||||
instruction: launch_request.instruction.clone(),
|
||||
launch_document: launch_request.launch_document.clone(),
|
||||
output_contract_id: launch_request.output_contract_id.clone(),
|
||||
organ_launch_contract: launch_request.organ_launch_contract.clone(),
|
||||
created_at: "2026-06-02T00:00:00Z".to_string(),
|
||||
},
|
||||
)?;
|
||||
let stored = runtime_worker_launch_request(&runtime_store, "job-launch-context")?
|
||||
.expect("runtime worker launch request should be persisted");
|
||||
let stored_document = stored.launch_document()?;
|
||||
let stored_context = stored_document
|
||||
.dynamic_prompt_context()
|
||||
.expect("stored launch document should carry dynamic context");
|
||||
assert!(stored_context.contains("Odin"));
|
||||
assert!(stored_context.contains("Memory graph"));
|
||||
assert!(stored_context.contains("Test launch context."));
|
||||
|
||||
fs::remove_dir_all(&temp)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
41
epiphany-codex-bridge/src/lib.rs
Normal file
41
epiphany-codex-bridge/src/lib.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//! Compatibility bridge between Epiphany's typed CultNet/CultCache-shaped
|
||||
//! contracts and the vendored Codex JSON-RPC/app-server shell.
|
||||
//!
|
||||
//! This crate is a bridge, not an authority organ. Its job is to translate
|
||||
//! Codex `ThreadEpiphany*` DTOs into Epiphany core documents, project typed
|
||||
//! Epiphany surfaces back into legacy Codex responses, and call the narrow
|
||||
//! host trait needed while Codex still owns thread persistence. Verdicts,
|
||||
//! launch policy, reorientation policy, acceptance rules, runtime-spine
|
||||
//! lifecycle semantics, and durable state invariants belong in `epiphany-core`
|
||||
//! or `epiphany-state-model`.
|
||||
//!
|
||||
//! Host facts and side effects that still pass through this crate are
|
||||
//! quarantine scaffolding for the current Codex shell. Do not let them become
|
||||
//! a second policy throne. The long cut is to replace these adapters with
|
||||
//! native CultNet intents and receipts, leaving Codex only as the OpenAI
|
||||
//! subscription auth/model transport reliquary.
|
||||
|
||||
pub mod checkpoint;
|
||||
pub mod context;
|
||||
pub mod context_protocol;
|
||||
pub mod coordinator;
|
||||
pub mod coordinator_protocol;
|
||||
pub mod cultnet;
|
||||
pub mod error;
|
||||
pub mod invalidation;
|
||||
pub mod jobs;
|
||||
pub mod launch;
|
||||
pub mod launch_context;
|
||||
pub mod mutation;
|
||||
pub mod mutation_service;
|
||||
pub mod pressure;
|
||||
pub mod protocol_edge;
|
||||
pub mod reorient;
|
||||
pub mod results;
|
||||
pub mod retrieve;
|
||||
pub mod retrieve_protocol;
|
||||
pub mod runtime_results;
|
||||
pub mod scene;
|
||||
pub mod scene_protocol;
|
||||
pub mod state;
|
||||
pub mod view_protocol;
|
||||
337
epiphany-codex-bridge/src/mutation.rs
Normal file
337
epiphany-codex-bridge/src/mutation.rs
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
use epiphany_core::EpiphanyReorientAcceptanceFinding;
|
||||
use epiphany_core::EpiphanyReorientFindingInterpretation as CoreEpiphanyReorientFinding;
|
||||
use epiphany_core::EpiphanyRoleAcceptanceFinding;
|
||||
use epiphany_core::EpiphanyRoleFindingInterpretation as CoreEpiphanyRoleFinding;
|
||||
use epiphany_core::EpiphanyRoleResultRoleId;
|
||||
use epiphany_core::EpiphanyRoleStatePatchDocument;
|
||||
use epiphany_core::EpiphanyStateUpdate;
|
||||
use epiphany_core::EpiphanyStateUpdatedField;
|
||||
use epiphany_core::MindGatewayReview;
|
||||
use epiphany_core::build_reorient_acceptance_bundle;
|
||||
use epiphany_core::build_role_acceptance_bundle;
|
||||
use epiphany_core::imagination_role_state_patch_policy_errors;
|
||||
use epiphany_core::mind_review_allows_state;
|
||||
use epiphany_core::mind_review_reorient_acceptance;
|
||||
use epiphany_core::mind_review_role_acceptance;
|
||||
use epiphany_core::modeling_role_state_patch_policy_errors;
|
||||
use epiphany_state_model::EpiphanyInvestigationCheckpoint;
|
||||
|
||||
pub struct RoleAcceptanceUpdate {
|
||||
pub state_update: EpiphanyStateUpdate,
|
||||
pub applied_patch: EpiphanyRoleStatePatchDocument,
|
||||
pub changed_fields: Vec<EpiphanyStateUpdatedField>,
|
||||
pub accepted_receipt_id: String,
|
||||
pub accepted_observation_id: String,
|
||||
pub accepted_evidence_id: String,
|
||||
pub mind_review: MindGatewayReview,
|
||||
}
|
||||
|
||||
pub struct ReorientAcceptanceUpdate {
|
||||
pub state_update: EpiphanyStateUpdate,
|
||||
pub changed_fields: Vec<EpiphanyStateUpdatedField>,
|
||||
pub accepted_receipt_id: String,
|
||||
pub accepted_observation_id: String,
|
||||
pub accepted_evidence_id: String,
|
||||
pub mind_review: MindGatewayReview,
|
||||
}
|
||||
|
||||
pub fn parse_core_role_finding_state_patch(
|
||||
finding: &CoreEpiphanyRoleFinding,
|
||||
) -> Result<EpiphanyRoleStatePatchDocument, String> {
|
||||
finding
|
||||
.state_patch
|
||||
.clone()
|
||||
.ok_or_else(|| "completed role finding did not include a reviewable statePatch".to_string())
|
||||
}
|
||||
|
||||
pub fn state_update_from_core_patch(
|
||||
expected_revision: Option<u64>,
|
||||
patch: EpiphanyRoleStatePatchDocument,
|
||||
) -> EpiphanyStateUpdate {
|
||||
EpiphanyStateUpdate {
|
||||
expected_revision,
|
||||
objective: patch.objective,
|
||||
active_subgoal_id: patch.active_subgoal_id,
|
||||
subgoals: patch.subgoals,
|
||||
invariants: patch.invariants,
|
||||
graphs: patch.graphs,
|
||||
graph_frontier: patch.graph_frontier,
|
||||
graph_checkpoint: patch.graph_checkpoint,
|
||||
scratch: patch.scratch,
|
||||
investigation_checkpoint: patch.investigation_checkpoint,
|
||||
job_bindings: patch.job_bindings,
|
||||
acceptance_receipts: patch.acceptance_receipts,
|
||||
runtime_links: patch.runtime_links,
|
||||
observations: patch.observations,
|
||||
evidence: patch.evidence,
|
||||
churn: patch.churn,
|
||||
mode: patch.mode,
|
||||
planning: patch.planning,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_role_acceptance_update(
|
||||
expected_revision: Option<u64>,
|
||||
role_id: EpiphanyRoleResultRoleId,
|
||||
binding_id: &str,
|
||||
finding: &CoreEpiphanyRoleFinding,
|
||||
accepted_evidence_id: String,
|
||||
accepted_observation_id: String,
|
||||
accepted_at: String,
|
||||
) -> Result<RoleAcceptanceUpdate, String> {
|
||||
let mut core_patch = match role_id {
|
||||
EpiphanyRoleResultRoleId::Imagination => {
|
||||
let patch = parse_core_role_finding_state_patch(finding)?;
|
||||
let patch_errors = imagination_role_state_patch_policy_errors(&patch);
|
||||
if !patch_errors.is_empty() {
|
||||
return Err(format!(
|
||||
"imagination role state patch is not acceptable: {}",
|
||||
patch_errors.join("; ")
|
||||
));
|
||||
}
|
||||
patch
|
||||
}
|
||||
EpiphanyRoleResultRoleId::Modeling => {
|
||||
let patch = parse_core_role_finding_state_patch(finding)?;
|
||||
let patch_errors = modeling_role_state_patch_policy_errors(&patch);
|
||||
if !patch_errors.is_empty() {
|
||||
return Err(format!(
|
||||
"modeling role state patch is not acceptable: {}",
|
||||
patch_errors.join("; ")
|
||||
));
|
||||
}
|
||||
patch
|
||||
}
|
||||
EpiphanyRoleResultRoleId::Research => {
|
||||
let patch = parse_core_role_finding_state_patch(finding)?;
|
||||
let patch_errors = epiphany_core::research_role_state_patch_policy_errors(&patch);
|
||||
if !patch_errors.is_empty() {
|
||||
return Err(format!(
|
||||
"research role state patch is not acceptable: {}",
|
||||
patch_errors.join("; ")
|
||||
));
|
||||
}
|
||||
patch
|
||||
}
|
||||
EpiphanyRoleResultRoleId::Verification => EpiphanyRoleStatePatchDocument::default(),
|
||||
EpiphanyRoleResultRoleId::Implementation | EpiphanyRoleResultRoleId::Reorientation => {
|
||||
return Err(format!(
|
||||
"role {:?} cannot be accepted through roleAccept",
|
||||
role_id
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let projected_fields = epiphany_update_patch_changed_fields(&core_patch)
|
||||
.into_iter()
|
||||
.map(|field| format!("{field:?}"))
|
||||
.collect();
|
||||
let mind_review = mind_review_role_acceptance(binding_id, role_id, finding, &core_patch);
|
||||
mind_review_allows_state(&mind_review)?;
|
||||
let acceptance_bundle = build_role_acceptance_bundle(
|
||||
binding_id,
|
||||
EpiphanyRoleAcceptanceFinding {
|
||||
role_id,
|
||||
verdict: finding.verdict.clone(),
|
||||
summary: finding.summary.clone(),
|
||||
next_safe_move: finding.next_safe_move.clone(),
|
||||
files_inspected: finding.files_inspected.clone(),
|
||||
runtime_result_id: finding.runtime_result_id.clone(),
|
||||
runtime_job_id: finding.runtime_job_id.clone(),
|
||||
projected_fields,
|
||||
},
|
||||
accepted_evidence_id,
|
||||
accepted_observation_id,
|
||||
accepted_at,
|
||||
)?;
|
||||
let accepted_receipt_id = acceptance_bundle.accepted_receipt_id.clone();
|
||||
let accepted_observation_id = acceptance_bundle.accepted_observation_id.clone();
|
||||
let accepted_evidence_id = acceptance_bundle.accepted_evidence_id.clone();
|
||||
core_patch.evidence.push(acceptance_bundle.evidence);
|
||||
core_patch.observations.push(acceptance_bundle.observation);
|
||||
core_patch
|
||||
.acceptance_receipts
|
||||
.push(acceptance_bundle.receipt);
|
||||
let applied_patch = core_patch.clone();
|
||||
let changed_fields = epiphany_update_patch_changed_fields(&applied_patch);
|
||||
let state_update = state_update_from_core_patch(expected_revision, core_patch);
|
||||
|
||||
Ok(RoleAcceptanceUpdate {
|
||||
state_update,
|
||||
applied_patch,
|
||||
changed_fields,
|
||||
accepted_receipt_id,
|
||||
accepted_observation_id,
|
||||
accepted_evidence_id,
|
||||
mind_review,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_reorient_acceptance_update(
|
||||
expected_revision: Option<u64>,
|
||||
binding_id: &str,
|
||||
finding: &CoreEpiphanyReorientFinding,
|
||||
accepted_evidence_id: String,
|
||||
accepted_observation_id: String,
|
||||
accepted_at: String,
|
||||
update_scratch: bool,
|
||||
update_investigation_checkpoint: bool,
|
||||
checkpoint: Option<EpiphanyInvestigationCheckpoint>,
|
||||
) -> Result<ReorientAcceptanceUpdate, String> {
|
||||
let mind_finding = EpiphanyReorientAcceptanceFinding {
|
||||
mode: finding.mode.clone(),
|
||||
summary: finding.summary.clone(),
|
||||
next_safe_move: finding.next_safe_move.clone(),
|
||||
checkpoint_still_valid: finding.checkpoint_still_valid,
|
||||
files_inspected: finding.files_inspected.clone(),
|
||||
runtime_result_id: finding.runtime_result_id.clone(),
|
||||
runtime_job_id: finding.runtime_job_id.clone(),
|
||||
};
|
||||
let mind_review = mind_review_reorient_acceptance(
|
||||
binding_id,
|
||||
&mind_finding,
|
||||
update_scratch,
|
||||
update_investigation_checkpoint,
|
||||
);
|
||||
mind_review_allows_state(&mind_review)?;
|
||||
let acceptance_bundle = build_reorient_acceptance_bundle(
|
||||
binding_id,
|
||||
mind_finding,
|
||||
accepted_evidence_id,
|
||||
accepted_observation_id,
|
||||
accepted_at,
|
||||
update_scratch,
|
||||
update_investigation_checkpoint
|
||||
.then_some(checkpoint)
|
||||
.flatten(),
|
||||
)?;
|
||||
let accepted_receipt_id = acceptance_bundle.accepted_receipt_id.clone();
|
||||
let accepted_observation_id = acceptance_bundle.accepted_observation_id.clone();
|
||||
let accepted_evidence_id = acceptance_bundle.accepted_evidence_id.clone();
|
||||
let scratch = acceptance_bundle.scratch;
|
||||
let investigation_checkpoint = acceptance_bundle.investigation_checkpoint;
|
||||
|
||||
let mut changed_fields = vec![
|
||||
EpiphanyStateUpdatedField::AcceptanceReceipts,
|
||||
EpiphanyStateUpdatedField::Observations,
|
||||
EpiphanyStateUpdatedField::Evidence,
|
||||
];
|
||||
if scratch.is_some() {
|
||||
changed_fields.push(EpiphanyStateUpdatedField::Scratch);
|
||||
}
|
||||
if investigation_checkpoint.is_some() {
|
||||
changed_fields.push(EpiphanyStateUpdatedField::InvestigationCheckpoint);
|
||||
}
|
||||
|
||||
Ok(ReorientAcceptanceUpdate {
|
||||
state_update: EpiphanyStateUpdate {
|
||||
expected_revision,
|
||||
scratch,
|
||||
investigation_checkpoint,
|
||||
acceptance_receipts: vec![acceptance_bundle.receipt],
|
||||
observations: vec![acceptance_bundle.observation],
|
||||
evidence: vec![acceptance_bundle.evidence],
|
||||
..Default::default()
|
||||
},
|
||||
changed_fields,
|
||||
accepted_receipt_id,
|
||||
accepted_observation_id,
|
||||
accepted_evidence_id,
|
||||
mind_review,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn epiphany_job_launch_changed_fields() -> Vec<EpiphanyStateUpdatedField> {
|
||||
vec![
|
||||
EpiphanyStateUpdatedField::JobBindings,
|
||||
EpiphanyStateUpdatedField::RuntimeLinks,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn thread_epiphany_patch_has_state_replacements(
|
||||
patch: &EpiphanyRoleStatePatchDocument,
|
||||
) -> bool {
|
||||
patch.objective.is_some()
|
||||
|| patch.active_subgoal_id.is_some()
|
||||
|| patch.subgoals.is_some()
|
||||
|| patch.invariants.is_some()
|
||||
|| patch.graphs.is_some()
|
||||
|| patch.graph_frontier.is_some()
|
||||
|| patch.graph_checkpoint.is_some()
|
||||
|| patch.scratch.is_some()
|
||||
|| patch.investigation_checkpoint.is_some()
|
||||
|| patch.job_bindings.is_some()
|
||||
|| !patch.acceptance_receipts.is_empty()
|
||||
|| !patch.runtime_links.is_empty()
|
||||
|| patch.churn.is_some()
|
||||
|| patch.mode.is_some()
|
||||
|| patch.planning.is_some()
|
||||
}
|
||||
|
||||
pub fn epiphany_update_patch_changed_fields(
|
||||
patch: &EpiphanyRoleStatePatchDocument,
|
||||
) -> Vec<EpiphanyStateUpdatedField> {
|
||||
let mut fields = Vec::new();
|
||||
if patch.objective.is_some() {
|
||||
fields.push(EpiphanyStateUpdatedField::Objective);
|
||||
}
|
||||
if patch.active_subgoal_id.is_some() {
|
||||
fields.push(EpiphanyStateUpdatedField::ActiveSubgoalId);
|
||||
}
|
||||
if patch.subgoals.is_some() {
|
||||
fields.push(EpiphanyStateUpdatedField::Subgoals);
|
||||
}
|
||||
if patch.invariants.is_some() {
|
||||
fields.push(EpiphanyStateUpdatedField::Invariants);
|
||||
}
|
||||
if patch.graphs.is_some() {
|
||||
fields.push(EpiphanyStateUpdatedField::Graphs);
|
||||
}
|
||||
if patch.graph_frontier.is_some() {
|
||||
fields.push(EpiphanyStateUpdatedField::GraphFrontier);
|
||||
}
|
||||
if patch.graph_checkpoint.is_some() {
|
||||
fields.push(EpiphanyStateUpdatedField::GraphCheckpoint);
|
||||
}
|
||||
if patch.scratch.is_some() {
|
||||
fields.push(EpiphanyStateUpdatedField::Scratch);
|
||||
}
|
||||
if patch.investigation_checkpoint.is_some() {
|
||||
fields.push(EpiphanyStateUpdatedField::InvestigationCheckpoint);
|
||||
}
|
||||
if patch.job_bindings.is_some() {
|
||||
fields.push(EpiphanyStateUpdatedField::JobBindings);
|
||||
}
|
||||
if !patch.acceptance_receipts.is_empty() {
|
||||
fields.push(EpiphanyStateUpdatedField::AcceptanceReceipts);
|
||||
}
|
||||
if !patch.runtime_links.is_empty() {
|
||||
fields.push(EpiphanyStateUpdatedField::RuntimeLinks);
|
||||
}
|
||||
if !patch.observations.is_empty() {
|
||||
fields.push(EpiphanyStateUpdatedField::Observations);
|
||||
}
|
||||
if !patch.evidence.is_empty() {
|
||||
fields.push(EpiphanyStateUpdatedField::Evidence);
|
||||
}
|
||||
if patch.churn.is_some() {
|
||||
fields.push(EpiphanyStateUpdatedField::Churn);
|
||||
}
|
||||
if patch.mode.is_some() {
|
||||
fields.push(EpiphanyStateUpdatedField::Mode);
|
||||
}
|
||||
if patch.planning.is_some() {
|
||||
fields.push(EpiphanyStateUpdatedField::Planning);
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
pub fn epiphany_promote_changed_fields(
|
||||
patch: &EpiphanyRoleStatePatchDocument,
|
||||
) -> Vec<EpiphanyStateUpdatedField> {
|
||||
let mut fields = epiphany_update_patch_changed_fields(patch);
|
||||
if !fields.contains(&EpiphanyStateUpdatedField::Evidence) {
|
||||
fields.push(EpiphanyStateUpdatedField::Evidence);
|
||||
}
|
||||
fields
|
||||
}
|
||||
1159
epiphany-codex-bridge/src/mutation_service.rs
Normal file
1159
epiphany-codex-bridge/src/mutation_service.rs
Normal file
File diff suppressed because it is too large
Load diff
20
epiphany-codex-bridge/src/pressure.rs
Normal file
20
epiphany-codex-bridge/src/pressure.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
use epiphany_core::EpiphanyPressure;
|
||||
use epiphany_core::EpiphanyPressureStatus as CoreEpiphanyPressureStatus;
|
||||
pub use epiphany_core::EpiphanyTokenUsageSnapshot;
|
||||
use epiphany_core::derive_pressure_view;
|
||||
|
||||
pub fn derive_epiphany_pressure(snapshot: Option<&EpiphanyTokenUsageSnapshot>) -> EpiphanyPressure {
|
||||
derive_pressure_view(snapshot)
|
||||
}
|
||||
|
||||
pub fn should_run_epiphany_pre_compaction_checkpoint_intervention(
|
||||
pressure: &EpiphanyPressure,
|
||||
) -> bool {
|
||||
pressure.status == CoreEpiphanyPressureStatus::Ready && pressure.should_prepare_compaction
|
||||
}
|
||||
|
||||
pub fn render_epiphany_pre_compaction_checkpoint_intervention(
|
||||
pressure: &EpiphanyPressure,
|
||||
) -> String {
|
||||
epiphany_core::render_epiphany_pre_compaction_checkpoint_intervention(pressure)
|
||||
}
|
||||
1280
epiphany-codex-bridge/src/protocol_edge.rs
Normal file
1280
epiphany-codex-bridge/src/protocol_edge.rs
Normal file
File diff suppressed because it is too large
Load diff
121
epiphany-codex-bridge/src/reorient.rs
Normal file
121
epiphany-codex-bridge/src/reorient.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
use epiphany_core::EpiphanyFreshnessInput;
|
||||
use epiphany_core::EpiphanyFreshnessView;
|
||||
use epiphany_core::EpiphanyFreshnessWatcherInput;
|
||||
use epiphany_core::EpiphanyGraphFreshness as CoreEpiphanyGraphFreshness;
|
||||
use epiphany_core::EpiphanyGraphFreshnessStatus as CoreEpiphanyGraphFreshnessStatus;
|
||||
use epiphany_core::EpiphanyInvalidationInput as CoreEpiphanyInvalidationInput;
|
||||
use epiphany_core::EpiphanyInvalidationStatus as CoreEpiphanyInvalidationStatus;
|
||||
use epiphany_core::EpiphanyPressure;
|
||||
use epiphany_core::EpiphanyPressureLevel;
|
||||
use epiphany_core::EpiphanyReorientDecision as CoreEpiphanyReorientDecision;
|
||||
use epiphany_core::EpiphanyReorientFreshnessStatus as CoreEpiphanyReorientFreshnessStatus;
|
||||
use epiphany_core::EpiphanyReorientInput;
|
||||
use epiphany_core::EpiphanyReorientPressureLevel as CoreEpiphanyReorientPressureLevel;
|
||||
use epiphany_core::EpiphanyReorientStateStatus as CoreEpiphanyReorientStateStatus;
|
||||
use epiphany_core::EpiphanyRetrievalFreshness as CoreEpiphanyRetrievalFreshness;
|
||||
use epiphany_core::EpiphanyRetrievalFreshnessStatus as CoreEpiphanyRetrievalFreshnessStatus;
|
||||
use epiphany_core::derive_freshness;
|
||||
use epiphany_core::recommend_reorientation;
|
||||
use epiphany_state_model::EpiphanyRetrievalState;
|
||||
use epiphany_state_model::EpiphanyThreadState;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct EpiphanyFreshnessWatcherSnapshot<'a> {
|
||||
pub available: bool,
|
||||
pub workspace_root: Option<&'a Path>,
|
||||
pub observed_at_unix_seconds: Option<i64>,
|
||||
pub changed_paths: &'a [PathBuf],
|
||||
}
|
||||
|
||||
pub fn derive_epiphany_freshness_view(
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
retrieval_override: Option<&EpiphanyRetrievalState>,
|
||||
watcher_snapshot: Option<EpiphanyFreshnessWatcherSnapshot<'_>>,
|
||||
) -> EpiphanyFreshnessView {
|
||||
let watcher = watcher_snapshot.map(|snapshot| EpiphanyFreshnessWatcherInput {
|
||||
available: snapshot.available,
|
||||
workspace_root: snapshot.workspace_root,
|
||||
observed_at_unix_seconds: snapshot.observed_at_unix_seconds,
|
||||
changed_paths: snapshot.changed_paths,
|
||||
});
|
||||
derive_freshness(EpiphanyFreshnessInput {
|
||||
state,
|
||||
retrieval_override,
|
||||
watcher,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn derive_epiphany_reorient(
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
pressure: &EpiphanyPressure,
|
||||
retrieval: &CoreEpiphanyRetrievalFreshness,
|
||||
graph: &CoreEpiphanyGraphFreshness,
|
||||
watcher: &CoreEpiphanyInvalidationInput,
|
||||
) -> (
|
||||
CoreEpiphanyReorientStateStatus,
|
||||
CoreEpiphanyReorientDecision,
|
||||
) {
|
||||
recommend_reorientation(EpiphanyReorientInput {
|
||||
checkpoint: state.and_then(|state| state.investigation_checkpoint.as_ref()),
|
||||
state_present: state.is_some(),
|
||||
pressure_level: map_core_reorient_pressure_level(pressure.level),
|
||||
retrieval_status: map_core_reorient_retrieval_status(retrieval.status),
|
||||
retrieval_dirty_paths: retrieval.dirty_paths.clone(),
|
||||
graph_status: map_core_reorient_graph_status(graph.status),
|
||||
graph_dirty_paths: graph.dirty_paths.clone(),
|
||||
watcher_status: map_core_reorient_watcher_status(watcher.status),
|
||||
watcher_changed_paths: watcher.changed_paths.clone(),
|
||||
watcher_graph_node_ids: watcher.graph_node_ids.clone(),
|
||||
active_frontier_node_ids: watcher.active_frontier_node_ids.clone(),
|
||||
watched_root: watcher.watched_root.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn map_core_reorient_pressure_level(
|
||||
level: EpiphanyPressureLevel,
|
||||
) -> CoreEpiphanyReorientPressureLevel {
|
||||
match level {
|
||||
EpiphanyPressureLevel::Unknown => CoreEpiphanyReorientPressureLevel::Unknown,
|
||||
EpiphanyPressureLevel::Low => CoreEpiphanyReorientPressureLevel::Low,
|
||||
EpiphanyPressureLevel::Elevated => CoreEpiphanyReorientPressureLevel::Medium,
|
||||
EpiphanyPressureLevel::High => CoreEpiphanyReorientPressureLevel::High,
|
||||
EpiphanyPressureLevel::Critical => CoreEpiphanyReorientPressureLevel::Critical,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_core_reorient_retrieval_status(
|
||||
status: CoreEpiphanyRetrievalFreshnessStatus,
|
||||
) -> CoreEpiphanyReorientFreshnessStatus {
|
||||
match status {
|
||||
CoreEpiphanyRetrievalFreshnessStatus::Missing
|
||||
| CoreEpiphanyRetrievalFreshnessStatus::Unavailable => {
|
||||
CoreEpiphanyReorientFreshnessStatus::Unknown
|
||||
}
|
||||
CoreEpiphanyRetrievalFreshnessStatus::Ready => CoreEpiphanyReorientFreshnessStatus::Clean,
|
||||
CoreEpiphanyRetrievalFreshnessStatus::Stale => CoreEpiphanyReorientFreshnessStatus::Stale,
|
||||
CoreEpiphanyRetrievalFreshnessStatus::Indexing => {
|
||||
CoreEpiphanyReorientFreshnessStatus::Dirty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_core_reorient_graph_status(
|
||||
status: CoreEpiphanyGraphFreshnessStatus,
|
||||
) -> CoreEpiphanyReorientFreshnessStatus {
|
||||
match status {
|
||||
CoreEpiphanyGraphFreshnessStatus::Missing => CoreEpiphanyReorientFreshnessStatus::Unknown,
|
||||
CoreEpiphanyGraphFreshnessStatus::Ready => CoreEpiphanyReorientFreshnessStatus::Clean,
|
||||
CoreEpiphanyGraphFreshnessStatus::Stale => CoreEpiphanyReorientFreshnessStatus::Stale,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_core_reorient_watcher_status(
|
||||
status: CoreEpiphanyInvalidationStatus,
|
||||
) -> CoreEpiphanyReorientFreshnessStatus {
|
||||
match status {
|
||||
CoreEpiphanyInvalidationStatus::Unavailable => CoreEpiphanyReorientFreshnessStatus::Unknown,
|
||||
CoreEpiphanyInvalidationStatus::Clean => CoreEpiphanyReorientFreshnessStatus::Clean,
|
||||
CoreEpiphanyInvalidationStatus::Changed => CoreEpiphanyReorientFreshnessStatus::Changed,
|
||||
}
|
||||
}
|
||||
137
epiphany-codex-bridge/src/results.rs
Normal file
137
epiphany-codex-bridge/src/results.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
use epiphany_core::EpiphanyCoordinatorRoleResultStatus as CoreEpiphanyCoordinatorRoleResultStatus;
|
||||
use epiphany_core::EpiphanyCrrcResultStatus as CoreEpiphanyCrrcResultStatus;
|
||||
use epiphany_core::EpiphanyReorientFindingInterpretation;
|
||||
use epiphany_core::EpiphanyRoleFindingInterpretation;
|
||||
use epiphany_core::EpiphanyRoleResultRoleId;
|
||||
use epiphany_core::EpiphanyRoleSelfPersistenceReview as CoreEpiphanyRoleSelfPersistenceReview;
|
||||
use epiphany_core::EpiphanyRoleSelfPersistenceStatus as CoreEpiphanyRoleSelfPersistenceStatus;
|
||||
|
||||
pub fn render_core_role_result_note(
|
||||
role_id: EpiphanyRoleResultRoleId,
|
||||
status: CoreEpiphanyCoordinatorRoleResultStatus,
|
||||
finding: Option<&EpiphanyRoleFindingInterpretation>,
|
||||
item_error: Option<&str>,
|
||||
) -> String {
|
||||
match status {
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::Completed => {
|
||||
if let Some(finding) = finding {
|
||||
let next = finding.next_safe_move.as_deref().unwrap_or("not supplied");
|
||||
let self_note =
|
||||
render_core_self_persistence_note(finding.self_persistence.as_ref())
|
||||
.map(|note| format!(" {note}"))
|
||||
.unwrap_or_default();
|
||||
if let Some(item_error) = finding.item_error.as_deref().or(item_error) {
|
||||
format!(
|
||||
"{:?} role specialist completed, but the finding needs repair: {item_error}. Next safe move: {next}.{self_note}",
|
||||
role_id
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{:?} role specialist completed. Next safe move: {next}.{self_note}",
|
||||
role_id
|
||||
)
|
||||
}
|
||||
} else {
|
||||
format!(
|
||||
"{:?} role specialist completed, but no structured result was recorded.",
|
||||
role_id
|
||||
)
|
||||
}
|
||||
}
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::Failed => item_error
|
||||
.map(|error| format!("{:?} role specialist failed: {error}", role_id))
|
||||
.unwrap_or_else(|| format!("{:?} role specialist failed.", role_id)),
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::Cancelled => {
|
||||
format!(
|
||||
"{:?} role specialist was cancelled before producing a result.",
|
||||
role_id
|
||||
)
|
||||
}
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::Running => {
|
||||
format!("{:?} role specialist is still running.", role_id)
|
||||
}
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::Pending => {
|
||||
format!(
|
||||
"{:?} role specialist has not produced a result yet.",
|
||||
role_id
|
||||
)
|
||||
}
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::MissingState => {
|
||||
"No authoritative Epiphany state exists for this thread.".to_string()
|
||||
}
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::MissingBinding => {
|
||||
"No matching Epiphany role specialist binding exists.".to_string()
|
||||
}
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::BackendUnavailable => {
|
||||
"The bound runtime backend is unavailable.".to_string()
|
||||
}
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::BackendMissing => {
|
||||
"The bound runtime backend job or item is missing.".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_core_self_persistence_note(
|
||||
review: Option<&CoreEpiphanyRoleSelfPersistenceReview>,
|
||||
) -> Option<String> {
|
||||
let review = review?;
|
||||
match review.status {
|
||||
CoreEpiphanyRoleSelfPersistenceStatus::Missing => None,
|
||||
CoreEpiphanyRoleSelfPersistenceStatus::Accepted => Some(format!(
|
||||
"Self persistence request is acceptable for {}.",
|
||||
review
|
||||
.target_agent_id
|
||||
.as_deref()
|
||||
.unwrap_or("the role memory file")
|
||||
)),
|
||||
CoreEpiphanyRoleSelfPersistenceStatus::Rejected => {
|
||||
let reasons = if review.reasons.is_empty() {
|
||||
"no reason recorded".to_string()
|
||||
} else {
|
||||
review.reasons.join("; ")
|
||||
};
|
||||
Some(format!("Self persistence request was refused: {reasons}."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_core_reorient_result_note(
|
||||
status: CoreEpiphanyCrrcResultStatus,
|
||||
finding: Option<&EpiphanyReorientFindingInterpretation>,
|
||||
item_error: Option<&str>,
|
||||
) -> String {
|
||||
match status {
|
||||
CoreEpiphanyCrrcResultStatus::Completed => {
|
||||
if let Some(finding) = finding {
|
||||
let next = finding.next_safe_move.as_deref().unwrap_or("not supplied");
|
||||
format!("Reorientation worker completed. Next safe move: {next}")
|
||||
} else {
|
||||
"Reorientation worker completed, but no structured result was recorded.".to_string()
|
||||
}
|
||||
}
|
||||
CoreEpiphanyCrrcResultStatus::Failed => item_error
|
||||
.map(|error| format!("Reorientation worker failed: {error}"))
|
||||
.unwrap_or_else(|| "Reorientation worker failed.".to_string()),
|
||||
CoreEpiphanyCrrcResultStatus::Cancelled => {
|
||||
"Reorientation worker was cancelled before producing a result.".to_string()
|
||||
}
|
||||
CoreEpiphanyCrrcResultStatus::Running => {
|
||||
"Reorientation worker is still running.".to_string()
|
||||
}
|
||||
CoreEpiphanyCrrcResultStatus::Pending => {
|
||||
"Reorientation worker has not produced a result yet.".to_string()
|
||||
}
|
||||
CoreEpiphanyCrrcResultStatus::MissingState => {
|
||||
"No authoritative Epiphany state exists for this thread.".to_string()
|
||||
}
|
||||
CoreEpiphanyCrrcResultStatus::MissingBinding => {
|
||||
"No matching Epiphany reorientation worker binding exists.".to_string()
|
||||
}
|
||||
CoreEpiphanyCrrcResultStatus::BackendUnavailable => {
|
||||
"The bound runtime backend is unavailable.".to_string()
|
||||
}
|
||||
CoreEpiphanyCrrcResultStatus::BackendMissing => {
|
||||
"The bound runtime backend job or item is missing.".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
63
epiphany-codex-bridge/src/retrieve.rs
Normal file
63
epiphany-codex-bridge/src/retrieve.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use epiphany_core::EpiphanyRetrieveQuery;
|
||||
use epiphany_core::EpiphanyRetrieveResponse as CoreEpiphanyRetrieveResponse;
|
||||
use epiphany_core::index_workspace;
|
||||
use epiphany_core::normalize_epiphany_retrieve_query;
|
||||
use epiphany_core::retrieval_state_for_workspace;
|
||||
use epiphany_core::retrieve_workspace;
|
||||
use epiphany_state_model::EpiphanyRetrievalState;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub async fn index_epiphany_retrieval_for_paths(
|
||||
workspace_root: PathBuf,
|
||||
codex_home: PathBuf,
|
||||
force_full_rebuild: bool,
|
||||
) -> anyhow::Result<EpiphanyRetrievalState> {
|
||||
index_epiphany_retrieval_state_for_paths(workspace_root, codex_home, force_full_rebuild).await
|
||||
}
|
||||
|
||||
pub fn normalize_thread_epiphany_retrieve_query(
|
||||
query: String,
|
||||
limit: Option<u32>,
|
||||
path_prefixes: Vec<PathBuf>,
|
||||
) -> Result<EpiphanyRetrieveQuery, String> {
|
||||
normalize_epiphany_retrieve_query(query, limit, path_prefixes).map_err(str::to_string)
|
||||
}
|
||||
|
||||
pub async fn epiphany_retrieval_state_for_paths(
|
||||
workspace_root: PathBuf,
|
||||
codex_home: PathBuf,
|
||||
) -> EpiphanyRetrievalState {
|
||||
let fallback_workspace_root = workspace_root.clone();
|
||||
let fallback_codex_home = codex_home.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
retrieval_state_for_workspace(&workspace_root, codex_home.as_path())
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
retrieval_state_for_workspace(&fallback_workspace_root, fallback_codex_home.as_path())
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn index_epiphany_retrieval_state_for_paths(
|
||||
workspace_root: PathBuf,
|
||||
codex_home: PathBuf,
|
||||
force_full_rebuild: bool,
|
||||
) -> anyhow::Result<EpiphanyRetrievalState> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
index_workspace(&workspace_root, codex_home.as_path(), force_full_rebuild)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("epiphany index worker failed: {err}"))?
|
||||
}
|
||||
|
||||
pub async fn retrieve_epiphany_for_paths(
|
||||
workspace_root: PathBuf,
|
||||
codex_home: PathBuf,
|
||||
query: EpiphanyRetrieveQuery,
|
||||
) -> anyhow::Result<CoreEpiphanyRetrieveResponse> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
retrieve_workspace(&workspace_root, codex_home.as_path(), query)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("epiphany retrieval worker failed: {err}"))?
|
||||
}
|
||||
126
epiphany-codex-bridge/src/retrieve_protocol.rs
Normal file
126
epiphany-codex-bridge/src/retrieve_protocol.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
use codex_app_server_protocol::ThreadEpiphanyIndexResponse;
|
||||
use codex_app_server_protocol::ThreadEpiphanyRetrieveIndexSummary;
|
||||
use codex_app_server_protocol::ThreadEpiphanyRetrieveParams;
|
||||
use codex_app_server_protocol::ThreadEpiphanyRetrieveResponse;
|
||||
use codex_app_server_protocol::ThreadEpiphanyRetrieveResult;
|
||||
use codex_app_server_protocol::ThreadEpiphanyRetrieveResultKind;
|
||||
use codex_app_server_protocol::ThreadEpiphanyRetrieveShardSummary;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use epiphany_core::EpiphanyRetrieveResponse as CoreEpiphanyRetrieveResponse;
|
||||
use epiphany_core::EpiphanyRetrieveResult as CoreEpiphanyRetrieveResult;
|
||||
use epiphany_core::EpiphanyRetrieveResultKind as CoreEpiphanyRetrieveResultKind;
|
||||
use epiphany_state_model::EpiphanyRetrievalState;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::error::EpiphanyBridgeError;
|
||||
use crate::error::Result as BridgeResult;
|
||||
use crate::retrieve::normalize_thread_epiphany_retrieve_query;
|
||||
use crate::retrieve::retrieve_epiphany_for_paths;
|
||||
|
||||
pub async fn retrieve_thread_epiphany(
|
||||
params: ThreadEpiphanyRetrieveParams,
|
||||
workspace_root: PathBuf,
|
||||
codex_home: PathBuf,
|
||||
) -> BridgeResult<ThreadEpiphanyRetrieveResponse> {
|
||||
let ThreadEpiphanyRetrieveParams {
|
||||
thread_id: _,
|
||||
query,
|
||||
limit,
|
||||
path_prefixes,
|
||||
} = params;
|
||||
retrieve_thread_epiphany_for_paths(workspace_root, codex_home, query, limit, path_prefixes)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn retrieve_thread_epiphany_for_paths(
|
||||
workspace_root: PathBuf,
|
||||
codex_home: PathBuf,
|
||||
query: String,
|
||||
limit: Option<u32>,
|
||||
path_prefixes: Vec<PathBuf>,
|
||||
) -> BridgeResult<ThreadEpiphanyRetrieveResponse> {
|
||||
let query = normalize_thread_epiphany_retrieve_query(query, limit, path_prefixes)
|
||||
.map_err(EpiphanyBridgeError::InvalidRequest)?;
|
||||
let response = retrieve_epiphany_for_paths(workspace_root, codex_home, query)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
EpiphanyBridgeError::Fatal(format!("failed to retrieve Epiphany results: {err}"))
|
||||
})?;
|
||||
protocol_retrieve_response(response).map_err(|err| {
|
||||
EpiphanyBridgeError::Fatal(format!(
|
||||
"failed to project Epiphany retrieve response: {err}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn protocol_retrieve_response(
|
||||
response: CoreEpiphanyRetrieveResponse,
|
||||
) -> anyhow::Result<ThreadEpiphanyRetrieveResponse> {
|
||||
Ok(ThreadEpiphanyRetrieveResponse {
|
||||
query: response.query,
|
||||
index_summary: protocol_retrieve_index_summary(response.index_summary)?,
|
||||
results: response
|
||||
.results
|
||||
.into_iter()
|
||||
.map(protocol_retrieve_result)
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn protocol_index_response(
|
||||
index_summary: EpiphanyRetrievalState,
|
||||
) -> anyhow::Result<ThreadEpiphanyIndexResponse> {
|
||||
Ok(ThreadEpiphanyIndexResponse {
|
||||
index_summary: protocol_retrieve_index_summary(index_summary)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn protocol_retrieve_index_summary(
|
||||
summary: EpiphanyRetrievalState,
|
||||
) -> anyhow::Result<ThreadEpiphanyRetrieveIndexSummary> {
|
||||
Ok(ThreadEpiphanyRetrieveIndexSummary {
|
||||
workspace_root: AbsolutePathBuf::from_absolute_path(summary.workspace_root)
|
||||
.map_err(anyhow::Error::from)?,
|
||||
index_revision: summary.index_revision,
|
||||
status: summary.status,
|
||||
semantic_available: summary.semantic_available,
|
||||
last_indexed_at_unix_seconds: summary.last_indexed_at_unix_seconds,
|
||||
indexed_file_count: summary.indexed_file_count,
|
||||
indexed_chunk_count: summary.indexed_chunk_count,
|
||||
shards: summary
|
||||
.shards
|
||||
.into_iter()
|
||||
.map(|shard| ThreadEpiphanyRetrieveShardSummary {
|
||||
shard_id: shard.shard_id,
|
||||
path_prefix: shard.path_prefix,
|
||||
indexed_file_count: shard.indexed_file_count,
|
||||
indexed_chunk_count: shard.indexed_chunk_count,
|
||||
status: shard.status,
|
||||
exact_available: shard.exact_available,
|
||||
semantic_available: shard.semantic_available,
|
||||
})
|
||||
.collect(),
|
||||
dirty_paths: summary.dirty_paths,
|
||||
})
|
||||
}
|
||||
|
||||
fn protocol_retrieve_result(result: CoreEpiphanyRetrieveResult) -> ThreadEpiphanyRetrieveResult {
|
||||
ThreadEpiphanyRetrieveResult {
|
||||
kind: match result.kind {
|
||||
CoreEpiphanyRetrieveResultKind::ExactFile => {
|
||||
ThreadEpiphanyRetrieveResultKind::ExactFile
|
||||
}
|
||||
CoreEpiphanyRetrieveResultKind::ExactDirectory => {
|
||||
ThreadEpiphanyRetrieveResultKind::ExactDirectory
|
||||
}
|
||||
CoreEpiphanyRetrieveResultKind::SemanticChunk => {
|
||||
ThreadEpiphanyRetrieveResultKind::SemanticChunk
|
||||
}
|
||||
},
|
||||
path: result.path,
|
||||
score: result.score,
|
||||
line_start: result.line_start,
|
||||
line_end: result.line_end,
|
||||
excerpt: result.excerpt,
|
||||
}
|
||||
}
|
||||
731
epiphany-codex-bridge/src/runtime_results.rs
Normal file
731
epiphany-codex-bridge/src/runtime_results.rs
Normal file
|
|
@ -0,0 +1,731 @@
|
|||
use std::path::Path;
|
||||
|
||||
use epiphany_core::EpiphanyCoordinatorRoleResultStatus as CoreEpiphanyCoordinatorRoleResultStatus;
|
||||
use epiphany_core::EpiphanyCrrcResultStatus as CoreEpiphanyCrrcResultStatus;
|
||||
use epiphany_core::EpiphanyLaunchOrganContract;
|
||||
use epiphany_core::EpiphanyReceiptEffectKind;
|
||||
use epiphany_core::EpiphanyReorientFindingInterpretation;
|
||||
use epiphany_core::EpiphanyRoleFindingInterpretation;
|
||||
use epiphany_core::EpiphanyRoleResultRoleId;
|
||||
use epiphany_core::EpiphanyRuntimeJobResult;
|
||||
use epiphany_core::EpiphanyRuntimeJobSnapshot;
|
||||
use epiphany_core::EpiphanyRuntimeJobStatus;
|
||||
use epiphany_core::MIND_GATEWAY_REVIEW_TYPE;
|
||||
use epiphany_core::interpret_runtime_reorient_worker_result;
|
||||
use epiphany_core::interpret_runtime_role_worker_result;
|
||||
use epiphany_core::runtime_job_snapshot;
|
||||
use epiphany_core::runtime_reorient_worker_result;
|
||||
use epiphany_core::runtime_role_worker_result;
|
||||
use epiphany_core::runtime_worker_launch_request;
|
||||
use epiphany_state_model::EpiphanyRuntimeLink;
|
||||
use epiphany_state_model::EpiphanyThreadState;
|
||||
|
||||
use crate::error::EpiphanyBridgeError;
|
||||
use crate::error::Result as BridgeResult;
|
||||
use crate::results::render_core_reorient_result_note;
|
||||
use crate::results::render_core_role_result_note;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EpiphanyRoleResultSnapshot {
|
||||
pub status: CoreEpiphanyCoordinatorRoleResultStatus,
|
||||
pub finding: Option<EpiphanyRoleFindingInterpretation>,
|
||||
pub completed_at: Option<String>,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EpiphanyReorientResultSnapshot {
|
||||
pub status: CoreEpiphanyCrrcResultStatus,
|
||||
pub finding: Option<EpiphanyReorientFindingInterpretation>,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
pub fn load_core_epiphany_role_result_from_runtime_spine_job(
|
||||
job_id: &str,
|
||||
runtime_store_path: Option<&Path>,
|
||||
role_id: EpiphanyRoleResultRoleId,
|
||||
) -> EpiphanyRoleResultSnapshot {
|
||||
let Some(runtime_store_path) = runtime_store_path else {
|
||||
return EpiphanyRoleResultSnapshot {
|
||||
status: CoreEpiphanyCoordinatorRoleResultStatus::Pending,
|
||||
finding: None,
|
||||
completed_at: None,
|
||||
note: "Heartbeat activation owns this role specialist; no loaded runtime-spine store is available yet."
|
||||
.to_string(),
|
||||
};
|
||||
};
|
||||
let snapshot = match runtime_job_snapshot(runtime_store_path, job_id) {
|
||||
Ok(Some(snapshot)) => snapshot,
|
||||
Ok(None) => {
|
||||
return EpiphanyRoleResultSnapshot {
|
||||
status: CoreEpiphanyCoordinatorRoleResultStatus::Pending,
|
||||
finding: None,
|
||||
completed_at: None,
|
||||
note: format!(
|
||||
"Heartbeat runtime job {:?} has not reported typed state yet.",
|
||||
job_id
|
||||
),
|
||||
};
|
||||
}
|
||||
Err(err) => {
|
||||
return EpiphanyRoleResultSnapshot {
|
||||
status: CoreEpiphanyCoordinatorRoleResultStatus::BackendUnavailable,
|
||||
finding: None,
|
||||
completed_at: None,
|
||||
note: format!(
|
||||
"Failed to read heartbeat runtime-spine job {:?}: {err}",
|
||||
job_id
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
let status = map_runtime_role_result_status(&snapshot);
|
||||
let finding = match status {
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::Completed => {
|
||||
match runtime_role_worker_result(runtime_store_path, job_id) {
|
||||
Ok(Some(result)) => Some(interpret_runtime_role_worker_result(role_id, &result)),
|
||||
Ok(None) => {
|
||||
return EpiphanyRoleResultSnapshot {
|
||||
status: CoreEpiphanyCoordinatorRoleResultStatus::BackendUnavailable,
|
||||
finding: None,
|
||||
completed_at: snapshot
|
||||
.result
|
||||
.as_ref()
|
||||
.map(|result| result.completed_at.clone()),
|
||||
note: format!(
|
||||
"Heartbeat runtime job {:?} completed without an EpiphanyRuntimeRoleWorkerResult typed document; generic lifecycle receipts are not reviewable findings.",
|
||||
job_id
|
||||
),
|
||||
};
|
||||
}
|
||||
Err(err) => {
|
||||
return EpiphanyRoleResultSnapshot {
|
||||
status: CoreEpiphanyCoordinatorRoleResultStatus::BackendUnavailable,
|
||||
finding: None,
|
||||
completed_at: snapshot
|
||||
.result
|
||||
.as_ref()
|
||||
.map(|result| result.completed_at.clone()),
|
||||
note: format!(
|
||||
"Failed to read typed role worker result for heartbeat runtime job {:?}: {err}",
|
||||
job_id
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::Failed
|
||||
| CoreEpiphanyCoordinatorRoleResultStatus::Cancelled => snapshot
|
||||
.result
|
||||
.as_ref()
|
||||
.map(interpret_runtime_role_lifecycle_failure),
|
||||
_ => None,
|
||||
};
|
||||
let item_error = finding
|
||||
.as_ref()
|
||||
.and_then(|finding| finding.job_error.as_deref());
|
||||
let note = render_core_role_result_note(role_id, status, finding.as_ref(), item_error);
|
||||
EpiphanyRoleResultSnapshot {
|
||||
status,
|
||||
finding,
|
||||
completed_at: snapshot
|
||||
.result
|
||||
.as_ref()
|
||||
.map(|result| result.completed_at.clone()),
|
||||
note,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_core_epiphany_reorient_result_from_runtime_spine_job(
|
||||
job_id: &str,
|
||||
runtime_store_path: Option<&Path>,
|
||||
) -> EpiphanyReorientResultSnapshot {
|
||||
let Some(runtime_store_path) = runtime_store_path else {
|
||||
return EpiphanyReorientResultSnapshot {
|
||||
status: CoreEpiphanyCrrcResultStatus::Pending,
|
||||
finding: None,
|
||||
note: "Heartbeat activation owns this reorientation worker; no loaded runtime-spine store is available yet."
|
||||
.to_string(),
|
||||
};
|
||||
};
|
||||
let snapshot = match runtime_job_snapshot(runtime_store_path, job_id) {
|
||||
Ok(Some(snapshot)) => snapshot,
|
||||
Ok(None) => {
|
||||
return EpiphanyReorientResultSnapshot {
|
||||
status: CoreEpiphanyCrrcResultStatus::Pending,
|
||||
finding: None,
|
||||
note: format!(
|
||||
"Heartbeat runtime job {:?} has not reported typed state yet.",
|
||||
job_id
|
||||
),
|
||||
};
|
||||
}
|
||||
Err(err) => {
|
||||
return EpiphanyReorientResultSnapshot {
|
||||
status: CoreEpiphanyCrrcResultStatus::BackendUnavailable,
|
||||
finding: None,
|
||||
note: format!(
|
||||
"Failed to read heartbeat runtime-spine job {:?}: {err}",
|
||||
job_id
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
let status = map_runtime_reorient_result_status(&snapshot);
|
||||
let finding = match status {
|
||||
CoreEpiphanyCrrcResultStatus::Completed => {
|
||||
match runtime_reorient_worker_result(runtime_store_path, job_id) {
|
||||
Ok(Some(result)) => Some(interpret_runtime_reorient_worker_result(&result)),
|
||||
Ok(None) => {
|
||||
return EpiphanyReorientResultSnapshot {
|
||||
status: CoreEpiphanyCrrcResultStatus::BackendUnavailable,
|
||||
finding: None,
|
||||
note: format!(
|
||||
"Heartbeat runtime job {:?} completed without an EpiphanyRuntimeReorientWorkerResult typed document; generic lifecycle receipts are not reviewable findings.",
|
||||
job_id
|
||||
),
|
||||
};
|
||||
}
|
||||
Err(err) => {
|
||||
return EpiphanyReorientResultSnapshot {
|
||||
status: CoreEpiphanyCrrcResultStatus::BackendUnavailable,
|
||||
finding: None,
|
||||
note: format!(
|
||||
"Failed to read typed reorientation worker result for heartbeat runtime job {:?}: {err}",
|
||||
job_id
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
CoreEpiphanyCrrcResultStatus::Failed | CoreEpiphanyCrrcResultStatus::Cancelled => snapshot
|
||||
.result
|
||||
.as_ref()
|
||||
.map(interpret_runtime_reorient_lifecycle_failure),
|
||||
_ => None,
|
||||
};
|
||||
let item_error = finding
|
||||
.as_ref()
|
||||
.and_then(|finding| finding.job_error.as_deref());
|
||||
let note = render_core_reorient_result_note(status, finding.as_ref(), item_error);
|
||||
EpiphanyReorientResultSnapshot {
|
||||
status,
|
||||
finding,
|
||||
note,
|
||||
}
|
||||
}
|
||||
|
||||
fn interpret_runtime_role_lifecycle_failure(
|
||||
result: &EpiphanyRuntimeJobResult,
|
||||
) -> EpiphanyRoleFindingInterpretation {
|
||||
EpiphanyRoleFindingInterpretation {
|
||||
verdict: Some(result.verdict.clone()),
|
||||
summary: Some(result.summary.clone()),
|
||||
next_safe_move: empty_string_as_none(&result.next_safe_move),
|
||||
checkpoint_summary: None,
|
||||
scratch_summary: None,
|
||||
files_inspected: Vec::new(),
|
||||
frontier_node_ids: Vec::new(),
|
||||
evidence_ids: result.evidence_refs.clone(),
|
||||
artifact_refs: result.artifact_refs.clone(),
|
||||
runtime_result_id: Some(result.result_id.clone()),
|
||||
runtime_job_id: Some(result.job_id.clone()),
|
||||
open_questions: Vec::new(),
|
||||
evidence_gaps: Vec::new(),
|
||||
risks: Vec::new(),
|
||||
state_patch: None,
|
||||
self_patch: None,
|
||||
self_persistence: None,
|
||||
job_error: Some(result.summary.clone()),
|
||||
item_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn interpret_runtime_reorient_lifecycle_failure(
|
||||
result: &EpiphanyRuntimeJobResult,
|
||||
) -> EpiphanyReorientFindingInterpretation {
|
||||
EpiphanyReorientFindingInterpretation {
|
||||
mode: None,
|
||||
summary: Some(result.summary.clone()),
|
||||
next_safe_move: empty_string_as_none(&result.next_safe_move),
|
||||
checkpoint_still_valid: None,
|
||||
files_inspected: Vec::new(),
|
||||
frontier_node_ids: Vec::new(),
|
||||
evidence_ids: result.evidence_refs.clone(),
|
||||
artifact_refs: result.artifact_refs.clone(),
|
||||
runtime_result_id: Some(result.result_id.clone()),
|
||||
runtime_job_id: Some(result.job_id.clone()),
|
||||
job_error: Some(result.summary.clone()),
|
||||
item_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_string_as_none(value: &str) -> Option<String> {
|
||||
(!value.trim().is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn map_runtime_role_result_status(
|
||||
snapshot: &EpiphanyRuntimeJobSnapshot,
|
||||
) -> CoreEpiphanyCoordinatorRoleResultStatus {
|
||||
match snapshot.job.status {
|
||||
EpiphanyRuntimeJobStatus::Queued => CoreEpiphanyCoordinatorRoleResultStatus::Pending,
|
||||
EpiphanyRuntimeJobStatus::Running | EpiphanyRuntimeJobStatus::WaitingForReview => {
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::Running
|
||||
}
|
||||
EpiphanyRuntimeJobStatus::Completed => {
|
||||
if snapshot.result.is_some() {
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::Completed
|
||||
} else {
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::Pending
|
||||
}
|
||||
}
|
||||
EpiphanyRuntimeJobStatus::Failed => CoreEpiphanyCoordinatorRoleResultStatus::Failed,
|
||||
EpiphanyRuntimeJobStatus::Cancelled => CoreEpiphanyCoordinatorRoleResultStatus::Cancelled,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_runtime_reorient_result_status(
|
||||
snapshot: &EpiphanyRuntimeJobSnapshot,
|
||||
) -> CoreEpiphanyCrrcResultStatus {
|
||||
match snapshot.job.status {
|
||||
EpiphanyRuntimeJobStatus::Queued => CoreEpiphanyCrrcResultStatus::Pending,
|
||||
EpiphanyRuntimeJobStatus::Running | EpiphanyRuntimeJobStatus::WaitingForReview => {
|
||||
CoreEpiphanyCrrcResultStatus::Running
|
||||
}
|
||||
EpiphanyRuntimeJobStatus::Completed => {
|
||||
if snapshot.result.is_some() {
|
||||
CoreEpiphanyCrrcResultStatus::Completed
|
||||
} else {
|
||||
CoreEpiphanyCrrcResultStatus::Pending
|
||||
}
|
||||
}
|
||||
EpiphanyRuntimeJobStatus::Failed => CoreEpiphanyCrrcResultStatus::Failed,
|
||||
EpiphanyRuntimeJobStatus::Cancelled => CoreEpiphanyCrrcResultStatus::Cancelled,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_core_epiphany_role_result_snapshot(
|
||||
state: &EpiphanyThreadState,
|
||||
runtime_store_path: Option<&Path>,
|
||||
role_id: EpiphanyRoleResultRoleId,
|
||||
binding_id: &str,
|
||||
) -> EpiphanyRoleResultSnapshot {
|
||||
if let Some(link) = latest_epiphany_runtime_link_for_binding(state, binding_id) {
|
||||
return load_core_epiphany_role_result_from_runtime_spine_job(
|
||||
link.runtime_job_id.as_str(),
|
||||
runtime_store_path,
|
||||
role_id,
|
||||
);
|
||||
}
|
||||
|
||||
if !state
|
||||
.job_bindings
|
||||
.iter()
|
||||
.any(|binding| binding.id == binding_id)
|
||||
{
|
||||
return EpiphanyRoleResultSnapshot {
|
||||
status: CoreEpiphanyCoordinatorRoleResultStatus::MissingBinding,
|
||||
finding: None,
|
||||
completed_at: None,
|
||||
note: "No matching Epiphany role specialist binding exists.".to_string(),
|
||||
};
|
||||
}
|
||||
EpiphanyRoleResultSnapshot {
|
||||
status: CoreEpiphanyCoordinatorRoleResultStatus::BackendUnavailable,
|
||||
finding: None,
|
||||
completed_at: None,
|
||||
note: "Role binding has no runtime-spine job id; launch a runtime-linked role worker for typed results.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_completed_core_epiphany_role_finding(
|
||||
runtime_store_path: Option<&Path>,
|
||||
state: &EpiphanyThreadState,
|
||||
role_id: EpiphanyRoleResultRoleId,
|
||||
binding_id: &str,
|
||||
) -> BridgeResult<EpiphanyRoleFindingInterpretation> {
|
||||
if let Some(link) = latest_epiphany_runtime_link_for_binding(state, binding_id) {
|
||||
let snapshot = load_core_epiphany_role_result_from_runtime_spine_job(
|
||||
link.runtime_job_id.as_str(),
|
||||
runtime_store_path,
|
||||
role_id,
|
||||
);
|
||||
if snapshot.status != CoreEpiphanyCoordinatorRoleResultStatus::Completed {
|
||||
return Err(EpiphanyBridgeError::InvalidRequest(format!(
|
||||
"cannot accept role result while worker status is {:?}",
|
||||
snapshot.status
|
||||
)));
|
||||
}
|
||||
let runtime_store_path = runtime_store_path.ok_or_else(|| {
|
||||
EpiphanyBridgeError::InvalidRequest(
|
||||
"cannot accept completed role worker without a loaded runtime-spine store"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
require_launch_organ_contract(runtime_store_path, link.runtime_job_id.as_str(), "role")?;
|
||||
return snapshot.finding.ok_or_else(|| {
|
||||
EpiphanyBridgeError::InvalidRequest(
|
||||
"cannot accept completed role worker because no typed runtime-spine result was recorded"
|
||||
.to_string(),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
if !state
|
||||
.job_bindings
|
||||
.iter()
|
||||
.any(|binding| binding.id == binding_id)
|
||||
{
|
||||
return Err(EpiphanyBridgeError::InvalidRequest(format!(
|
||||
"epiphany role binding {:?} was not found",
|
||||
binding_id
|
||||
)));
|
||||
}
|
||||
|
||||
Err(EpiphanyBridgeError::InvalidRequest(
|
||||
"role findings without runtime-spine results are unsupported; accept only typed runtime-spine results"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn load_core_epiphany_reorient_result_snapshot(
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
runtime_store_path: Option<&Path>,
|
||||
binding_id: &str,
|
||||
) -> EpiphanyReorientResultSnapshot {
|
||||
let Some(state) = state else {
|
||||
return EpiphanyReorientResultSnapshot {
|
||||
status: CoreEpiphanyCrrcResultStatus::MissingState,
|
||||
finding: None,
|
||||
note: "No authoritative Epiphany state exists for this thread.".to_string(),
|
||||
};
|
||||
};
|
||||
if let Some(link) = latest_epiphany_runtime_link_for_binding(state, binding_id) {
|
||||
return load_core_epiphany_reorient_result_from_runtime_spine_job(
|
||||
link.runtime_job_id.as_str(),
|
||||
runtime_store_path,
|
||||
);
|
||||
}
|
||||
|
||||
if !state
|
||||
.job_bindings
|
||||
.iter()
|
||||
.any(|binding| binding.id == binding_id)
|
||||
{
|
||||
return EpiphanyReorientResultSnapshot {
|
||||
status: CoreEpiphanyCrrcResultStatus::MissingBinding,
|
||||
finding: None,
|
||||
note: "No matching Epiphany reorientation worker binding exists.".to_string(),
|
||||
};
|
||||
}
|
||||
EpiphanyReorientResultSnapshot {
|
||||
status: CoreEpiphanyCrrcResultStatus::BackendUnavailable,
|
||||
finding: None,
|
||||
note: "Reorientation binding has no runtime-spine job id; launch a runtime-linked reorient worker for typed results.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_completed_core_epiphany_reorient_finding(
|
||||
runtime_store_path: Option<&Path>,
|
||||
state: &EpiphanyThreadState,
|
||||
binding_id: &str,
|
||||
) -> BridgeResult<EpiphanyReorientFindingInterpretation> {
|
||||
if let Some(link) = latest_epiphany_runtime_link_for_binding(state, binding_id) {
|
||||
let snapshot = load_core_epiphany_reorient_result_from_runtime_spine_job(
|
||||
link.runtime_job_id.as_str(),
|
||||
runtime_store_path,
|
||||
);
|
||||
if snapshot.status != CoreEpiphanyCrrcResultStatus::Completed {
|
||||
return Err(EpiphanyBridgeError::InvalidRequest(format!(
|
||||
"cannot accept reorientation result while worker status is {:?}",
|
||||
snapshot.status
|
||||
)));
|
||||
}
|
||||
let runtime_store_path = runtime_store_path.ok_or_else(|| {
|
||||
EpiphanyBridgeError::InvalidRequest(
|
||||
"cannot accept completed reorientation worker without a loaded runtime-spine store"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
require_launch_organ_contract(
|
||||
runtime_store_path,
|
||||
link.runtime_job_id.as_str(),
|
||||
"reorient",
|
||||
)?;
|
||||
return snapshot.finding.ok_or_else(|| {
|
||||
EpiphanyBridgeError::InvalidRequest(
|
||||
"cannot accept completed reorientation worker because no typed runtime-spine result was recorded"
|
||||
.to_string(),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
if !state
|
||||
.job_bindings
|
||||
.iter()
|
||||
.any(|binding| binding.id == binding_id)
|
||||
{
|
||||
return Err(EpiphanyBridgeError::InvalidRequest(format!(
|
||||
"epiphany reorientation binding {:?} was not found",
|
||||
binding_id
|
||||
)));
|
||||
}
|
||||
|
||||
Err(EpiphanyBridgeError::InvalidRequest(
|
||||
"reorientation findings without runtime-spine results are unsupported; accept only typed runtime-spine results"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn require_launch_organ_contract(
|
||||
runtime_store_path: &Path,
|
||||
job_id: &str,
|
||||
expected_document_kind: &str,
|
||||
) -> BridgeResult<()> {
|
||||
load_launch_organ_contract_for_runtime_job(runtime_store_path, job_id, expected_document_kind)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub fn load_launch_organ_contract_for_runtime_job(
|
||||
runtime_store_path: &Path,
|
||||
job_id: &str,
|
||||
expected_document_kind: &str,
|
||||
) -> BridgeResult<EpiphanyLaunchOrganContract> {
|
||||
let request = runtime_worker_launch_request(runtime_store_path, job_id).map_err(|err| {
|
||||
EpiphanyBridgeError::Fatal(format!(
|
||||
"failed to read worker launch request for runtime job {:?}: {err}",
|
||||
job_id
|
||||
))
|
||||
})?;
|
||||
let request = request.ok_or_else(|| {
|
||||
EpiphanyBridgeError::InvalidRequest(format!(
|
||||
"cannot accept runtime job {:?} without its typed worker launch request",
|
||||
job_id
|
||||
))
|
||||
})?;
|
||||
if request.document_kind != expected_document_kind {
|
||||
return Err(EpiphanyBridgeError::InvalidRequest(format!(
|
||||
"cannot accept runtime job {:?}: launch document kind {:?} does not match expected {:?}",
|
||||
job_id, request.document_kind, expected_document_kind
|
||||
)));
|
||||
}
|
||||
if request.organ_launch_contract.dependencies.is_empty()
|
||||
|| request
|
||||
.organ_launch_contract
|
||||
.receipt_proof_profiles
|
||||
.is_empty()
|
||||
{
|
||||
return Err(EpiphanyBridgeError::InvalidRequest(format!(
|
||||
"cannot accept runtime job {:?}: worker launch request has no organ dependency/proof-profile contract",
|
||||
job_id
|
||||
)));
|
||||
}
|
||||
if !request
|
||||
.organ_launch_contract
|
||||
.receipt_proof_profiles
|
||||
.iter()
|
||||
.any(|profile| {
|
||||
profile.effect_kind == EpiphanyReceiptEffectKind::StateAdmission
|
||||
&& profile
|
||||
.required_before_promotion_document_types
|
||||
.iter()
|
||||
.any(|document_type| document_type == MIND_GATEWAY_REVIEW_TYPE)
|
||||
})
|
||||
{
|
||||
return Err(EpiphanyBridgeError::InvalidRequest(format!(
|
||||
"cannot accept runtime job {:?}: worker launch contract has no state-admission proof profile requiring Mind review",
|
||||
job_id
|
||||
)));
|
||||
}
|
||||
Ok(request.organ_launch_contract)
|
||||
}
|
||||
|
||||
pub fn latest_epiphany_runtime_link_for_binding<'a>(
|
||||
state: &'a EpiphanyThreadState,
|
||||
binding_id: &str,
|
||||
) -> Option<&'a EpiphanyRuntimeLink> {
|
||||
state
|
||||
.runtime_links
|
||||
.iter()
|
||||
.find(|link| link.binding_id == binding_id && !link.runtime_job_id.trim().is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use epiphany_core::EpiphanyCoordinatorRoleResultStatus as CoreEpiphanyCoordinatorRoleResultStatus;
|
||||
use epiphany_core::EpiphanyCrrcResultStatus as CoreEpiphanyCrrcResultStatus;
|
||||
use epiphany_core::EpiphanyRoleResultRoleId;
|
||||
use epiphany_core::RuntimeSpineInitOptions;
|
||||
use epiphany_core::RuntimeSpineJobOptions;
|
||||
use epiphany_core::RuntimeSpineJobResultOptions;
|
||||
use epiphany_core::RuntimeSpineSessionOptions;
|
||||
use epiphany_core::complete_runtime_job;
|
||||
use epiphany_core::create_runtime_job;
|
||||
use epiphany_core::create_runtime_session;
|
||||
use epiphany_core::initialize_runtime_spine;
|
||||
|
||||
use super::*;
|
||||
|
||||
const NOW: &str = "2026-05-13T00:00:00Z";
|
||||
|
||||
#[test]
|
||||
fn role_result_requires_typed_worker_document() {
|
||||
let store = temp_store_path();
|
||||
seed_lifecycle_result(&store, "role-job", "modeling", "completed");
|
||||
|
||||
let result = load_core_epiphany_role_result_from_runtime_spine_job(
|
||||
"role-job",
|
||||
Some(store.as_path()),
|
||||
EpiphanyRoleResultRoleId::Modeling,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result.status,
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::BackendUnavailable
|
||||
);
|
||||
assert!(result.finding.is_none());
|
||||
assert!(
|
||||
result
|
||||
.note
|
||||
.contains("EpiphanyRuntimeRoleWorkerResult typed document")
|
||||
);
|
||||
|
||||
let _ = fs::remove_file(store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorient_result_requires_typed_worker_document() {
|
||||
let store = temp_store_path();
|
||||
seed_lifecycle_result(&store, "reorient-job", "reorientation", "completed");
|
||||
|
||||
let result = load_core_epiphany_reorient_result_from_runtime_spine_job(
|
||||
"reorient-job",
|
||||
Some(store.as_path()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result.status,
|
||||
CoreEpiphanyCrrcResultStatus::BackendUnavailable
|
||||
);
|
||||
assert!(result.finding.is_none());
|
||||
assert!(
|
||||
result
|
||||
.note
|
||||
.contains("EpiphanyRuntimeReorientWorkerResult typed document")
|
||||
);
|
||||
|
||||
let _ = fs::remove_file(store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_result_projects_failed_lifecycle_receipt_as_terminal() {
|
||||
let store = temp_store_path();
|
||||
seed_lifecycle_result(&store, "role-job", "modeling", "failed");
|
||||
|
||||
let result = load_core_epiphany_role_result_from_runtime_spine_job(
|
||||
"role-job",
|
||||
Some(store.as_path()),
|
||||
EpiphanyRoleResultRoleId::Modeling,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result.status,
|
||||
CoreEpiphanyCoordinatorRoleResultStatus::Failed
|
||||
);
|
||||
let finding = result.finding.expect("failed receipt should be visible");
|
||||
assert_eq!(finding.verdict.as_deref(), Some("failed"));
|
||||
assert_eq!(finding.runtime_job_id.as_deref(), Some("role-job"));
|
||||
assert!(
|
||||
finding
|
||||
.job_error
|
||||
.as_deref()
|
||||
.is_some_and(|error| error.contains("generic lifecycle result"))
|
||||
);
|
||||
assert!(result.note.contains("failed"));
|
||||
|
||||
let _ = fs::remove_file(store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorient_result_projects_failed_lifecycle_receipt_as_terminal() {
|
||||
let store = temp_store_path();
|
||||
seed_lifecycle_result(&store, "reorient-job", "reorientation", "failed");
|
||||
|
||||
let result = load_core_epiphany_reorient_result_from_runtime_spine_job(
|
||||
"reorient-job",
|
||||
Some(store.as_path()),
|
||||
);
|
||||
|
||||
assert_eq!(result.status, CoreEpiphanyCrrcResultStatus::Failed);
|
||||
let finding = result.finding.expect("failed receipt should be visible");
|
||||
assert_eq!(finding.runtime_job_id.as_deref(), Some("reorient-job"));
|
||||
assert!(
|
||||
finding
|
||||
.job_error
|
||||
.as_deref()
|
||||
.is_some_and(|error| error.contains("generic lifecycle result"))
|
||||
);
|
||||
assert!(result.note.contains("failed"));
|
||||
|
||||
let _ = fs::remove_file(store);
|
||||
}
|
||||
|
||||
fn temp_store_path() -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"epiphany-runtime-results-{}.cc",
|
||||
uuid::Uuid::new_v4()
|
||||
))
|
||||
}
|
||||
|
||||
fn seed_lifecycle_result(store: &Path, job_id: &str, role: &str, verdict: &str) {
|
||||
initialize_runtime_spine(
|
||||
store,
|
||||
RuntimeSpineInitOptions {
|
||||
runtime_id: "test-runtime".to_string(),
|
||||
display_name: "Test Runtime".to_string(),
|
||||
created_at: NOW.to_string(),
|
||||
},
|
||||
)
|
||||
.expect("initialize runtime");
|
||||
create_runtime_session(
|
||||
store,
|
||||
RuntimeSpineSessionOptions {
|
||||
session_id: "session-1".to_string(),
|
||||
objective: "test typed result boundary".to_string(),
|
||||
created_at: NOW.to_string(),
|
||||
coordinator_note: "test".to_string(),
|
||||
},
|
||||
)
|
||||
.expect("create session");
|
||||
create_runtime_job(
|
||||
store,
|
||||
RuntimeSpineJobOptions {
|
||||
job_id: job_id.to_string(),
|
||||
session_id: "session-1".to_string(),
|
||||
role: role.to_string(),
|
||||
created_at: NOW.to_string(),
|
||||
summary: "generic lifecycle job".to_string(),
|
||||
artifact_refs: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("create job");
|
||||
complete_runtime_job(
|
||||
store,
|
||||
RuntimeSpineJobResultOptions {
|
||||
result_id: format!("result-{job_id}"),
|
||||
job_id: job_id.to_string(),
|
||||
completed_at: NOW.to_string(),
|
||||
verdict: verdict.to_string(),
|
||||
summary: "generic lifecycle result".to_string(),
|
||||
next_safe_move: "do not review generic lifecycle receipts".to_string(),
|
||||
evidence_refs: Vec::new(),
|
||||
artifact_refs: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("complete job");
|
||||
}
|
||||
}
|
||||
16
epiphany-codex-bridge/src/scene.rs
Normal file
16
epiphany-codex-bridge/src/scene.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
use epiphany_core::EpiphanyScene;
|
||||
use epiphany_core::EpiphanySceneInput;
|
||||
use epiphany_core::derive_scene;
|
||||
use epiphany_state_model::EpiphanyThreadState;
|
||||
|
||||
pub fn derive_epiphany_scene(
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
loaded: bool,
|
||||
reorient_binding_id: &str,
|
||||
) -> EpiphanyScene {
|
||||
derive_scene(EpiphanySceneInput {
|
||||
state,
|
||||
loaded,
|
||||
reorient_binding_id,
|
||||
})
|
||||
}
|
||||
189
epiphany-codex-bridge/src/scene_protocol.rs
Normal file
189
epiphany-codex-bridge/src/scene_protocol.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
use codex_app_server_protocol::ThreadEpiphanyScene;
|
||||
use codex_app_server_protocol::ThreadEpiphanySceneAction;
|
||||
use codex_app_server_protocol::ThreadEpiphanySceneChurn;
|
||||
use codex_app_server_protocol::ThreadEpiphanySceneGraph;
|
||||
use codex_app_server_protocol::ThreadEpiphanySceneInvestigationCheckpoint;
|
||||
use codex_app_server_protocol::ThreadEpiphanySceneRecord;
|
||||
use codex_app_server_protocol::ThreadEpiphanySceneRecords;
|
||||
use codex_app_server_protocol::ThreadEpiphanySceneRetrieval;
|
||||
use codex_app_server_protocol::ThreadEpiphanySceneSource;
|
||||
use codex_app_server_protocol::ThreadEpiphanySceneStateStatus;
|
||||
use codex_app_server_protocol::ThreadEpiphanySceneStatusCount;
|
||||
use codex_app_server_protocol::ThreadEpiphanySceneSubgoal;
|
||||
use epiphany_core::EpiphanyScene as CoreEpiphanyScene;
|
||||
use epiphany_core::EpiphanySceneAction as CoreEpiphanySceneAction;
|
||||
use epiphany_core::EpiphanySceneChurn as CoreEpiphanySceneChurn;
|
||||
use epiphany_core::EpiphanySceneGraph as CoreEpiphanySceneGraph;
|
||||
use epiphany_core::EpiphanySceneInvestigationCheckpoint as CoreEpiphanySceneInvestigationCheckpoint;
|
||||
use epiphany_core::EpiphanySceneRecord as CoreEpiphanySceneRecord;
|
||||
use epiphany_core::EpiphanySceneRecords as CoreEpiphanySceneRecords;
|
||||
use epiphany_core::EpiphanySceneRetrieval as CoreEpiphanySceneRetrieval;
|
||||
use epiphany_core::EpiphanySceneSource as CoreEpiphanySceneSource;
|
||||
use epiphany_core::EpiphanySceneStateStatus as CoreEpiphanySceneStateStatus;
|
||||
use epiphany_core::EpiphanySceneStatusCount as CoreEpiphanySceneStatusCount;
|
||||
use epiphany_core::EpiphanySceneSubgoal as CoreEpiphanySceneSubgoal;
|
||||
|
||||
pub fn protocol_scene(scene: CoreEpiphanyScene) -> ThreadEpiphanyScene {
|
||||
ThreadEpiphanyScene {
|
||||
state_status: match scene.state_status {
|
||||
CoreEpiphanySceneStateStatus::Missing => ThreadEpiphanySceneStateStatus::Missing,
|
||||
CoreEpiphanySceneStateStatus::Ready => ThreadEpiphanySceneStateStatus::Ready,
|
||||
},
|
||||
source: match scene.source {
|
||||
CoreEpiphanySceneSource::Stored => ThreadEpiphanySceneSource::Stored,
|
||||
CoreEpiphanySceneSource::Live => ThreadEpiphanySceneSource::Live,
|
||||
},
|
||||
revision: scene.revision,
|
||||
objective: scene.objective,
|
||||
active_subgoal: scene.active_subgoal.map(protocol_scene_subgoal),
|
||||
subgoals: scene
|
||||
.subgoals
|
||||
.into_iter()
|
||||
.map(protocol_scene_subgoal)
|
||||
.collect(),
|
||||
invariant_status_counts: scene
|
||||
.invariant_status_counts
|
||||
.into_iter()
|
||||
.map(protocol_scene_status_count)
|
||||
.collect(),
|
||||
graph: protocol_scene_graph(scene.graph),
|
||||
retrieval: scene.retrieval.map(protocol_scene_retrieval),
|
||||
investigation_checkpoint: scene
|
||||
.investigation_checkpoint
|
||||
.map(protocol_scene_investigation_checkpoint),
|
||||
observations: protocol_scene_records(scene.observations),
|
||||
evidence: protocol_scene_records(scene.evidence),
|
||||
churn: scene.churn.map(protocol_scene_churn),
|
||||
available_actions: scene
|
||||
.available_actions
|
||||
.into_iter()
|
||||
.map(protocol_scene_action)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn protocol_scene_action(action: CoreEpiphanySceneAction) -> ThreadEpiphanySceneAction {
|
||||
match action {
|
||||
CoreEpiphanySceneAction::Index => ThreadEpiphanySceneAction::Index,
|
||||
CoreEpiphanySceneAction::Retrieve => ThreadEpiphanySceneAction::Retrieve,
|
||||
CoreEpiphanySceneAction::Distill => ThreadEpiphanySceneAction::Distill,
|
||||
CoreEpiphanySceneAction::Context => ThreadEpiphanySceneAction::Context,
|
||||
CoreEpiphanySceneAction::Planning => ThreadEpiphanySceneAction::Planning,
|
||||
CoreEpiphanySceneAction::GraphQuery => ThreadEpiphanySceneAction::GraphQuery,
|
||||
CoreEpiphanySceneAction::Jobs => ThreadEpiphanySceneAction::Jobs,
|
||||
CoreEpiphanySceneAction::Roles => ThreadEpiphanySceneAction::Roles,
|
||||
CoreEpiphanySceneAction::Coordinator => ThreadEpiphanySceneAction::Coordinator,
|
||||
CoreEpiphanySceneAction::RoleLaunch => ThreadEpiphanySceneAction::RoleLaunch,
|
||||
CoreEpiphanySceneAction::RoleResult => ThreadEpiphanySceneAction::RoleResult,
|
||||
CoreEpiphanySceneAction::RoleAccept => ThreadEpiphanySceneAction::RoleAccept,
|
||||
CoreEpiphanySceneAction::JobLaunch => ThreadEpiphanySceneAction::JobLaunch,
|
||||
CoreEpiphanySceneAction::JobInterrupt => ThreadEpiphanySceneAction::JobInterrupt,
|
||||
CoreEpiphanySceneAction::Freshness => ThreadEpiphanySceneAction::Freshness,
|
||||
CoreEpiphanySceneAction::Pressure => ThreadEpiphanySceneAction::Pressure,
|
||||
CoreEpiphanySceneAction::Reorient => ThreadEpiphanySceneAction::Reorient,
|
||||
CoreEpiphanySceneAction::Crrc => ThreadEpiphanySceneAction::Crrc,
|
||||
CoreEpiphanySceneAction::ReorientLaunch => ThreadEpiphanySceneAction::ReorientLaunch,
|
||||
CoreEpiphanySceneAction::ReorientResult => ThreadEpiphanySceneAction::ReorientResult,
|
||||
CoreEpiphanySceneAction::ReorientAccept => ThreadEpiphanySceneAction::ReorientAccept,
|
||||
CoreEpiphanySceneAction::Propose => ThreadEpiphanySceneAction::Propose,
|
||||
CoreEpiphanySceneAction::Promote => ThreadEpiphanySceneAction::Promote,
|
||||
CoreEpiphanySceneAction::Update => ThreadEpiphanySceneAction::Update,
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_scene_subgoal(subgoal: CoreEpiphanySceneSubgoal) -> ThreadEpiphanySceneSubgoal {
|
||||
ThreadEpiphanySceneSubgoal {
|
||||
id: subgoal.id,
|
||||
title: subgoal.title,
|
||||
status: subgoal.status,
|
||||
summary: subgoal.summary,
|
||||
active: subgoal.active,
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_scene_status_count(
|
||||
count: CoreEpiphanySceneStatusCount,
|
||||
) -> ThreadEpiphanySceneStatusCount {
|
||||
ThreadEpiphanySceneStatusCount {
|
||||
status: count.status,
|
||||
count: count.count,
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_scene_graph(graph: CoreEpiphanySceneGraph) -> ThreadEpiphanySceneGraph {
|
||||
ThreadEpiphanySceneGraph {
|
||||
architecture_node_count: graph.architecture_node_count,
|
||||
architecture_edge_count: graph.architecture_edge_count,
|
||||
dataflow_node_count: graph.dataflow_node_count,
|
||||
dataflow_edge_count: graph.dataflow_edge_count,
|
||||
link_count: graph.link_count,
|
||||
active_node_ids: graph.active_node_ids,
|
||||
active_edge_ids: graph.active_edge_ids,
|
||||
open_question_count: graph.open_question_count,
|
||||
open_gap_count: graph.open_gap_count,
|
||||
dirty_paths: graph.dirty_paths,
|
||||
checkpoint_id: graph.checkpoint_id,
|
||||
checkpoint_summary: graph.checkpoint_summary,
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_scene_retrieval(retrieval: CoreEpiphanySceneRetrieval) -> ThreadEpiphanySceneRetrieval {
|
||||
ThreadEpiphanySceneRetrieval {
|
||||
workspace_root: retrieval.workspace_root,
|
||||
status: retrieval.status,
|
||||
semantic_available: retrieval.semantic_available,
|
||||
index_revision: retrieval.index_revision,
|
||||
indexed_file_count: retrieval.indexed_file_count,
|
||||
indexed_chunk_count: retrieval.indexed_chunk_count,
|
||||
shard_count: retrieval.shard_count,
|
||||
dirty_path_count: retrieval.dirty_path_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_scene_investigation_checkpoint(
|
||||
checkpoint: CoreEpiphanySceneInvestigationCheckpoint,
|
||||
) -> ThreadEpiphanySceneInvestigationCheckpoint {
|
||||
ThreadEpiphanySceneInvestigationCheckpoint {
|
||||
checkpoint_id: checkpoint.checkpoint_id,
|
||||
kind: checkpoint.kind,
|
||||
disposition: checkpoint.disposition,
|
||||
focus: checkpoint.focus,
|
||||
summary: checkpoint.summary,
|
||||
next_action: checkpoint.next_action,
|
||||
captured_at_turn_id: checkpoint.captured_at_turn_id,
|
||||
open_question_count: checkpoint.open_question_count,
|
||||
code_ref_count: checkpoint.code_ref_count,
|
||||
evidence_count: checkpoint.evidence_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_scene_records(records: CoreEpiphanySceneRecords) -> ThreadEpiphanySceneRecords {
|
||||
ThreadEpiphanySceneRecords {
|
||||
total_count: records.total_count,
|
||||
latest: records
|
||||
.latest
|
||||
.into_iter()
|
||||
.map(protocol_scene_record)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_scene_record(record: CoreEpiphanySceneRecord) -> ThreadEpiphanySceneRecord {
|
||||
ThreadEpiphanySceneRecord {
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
status: record.status,
|
||||
summary: record.summary,
|
||||
code_ref_count: record.code_ref_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_scene_churn(churn: CoreEpiphanySceneChurn) -> ThreadEpiphanySceneChurn {
|
||||
ThreadEpiphanySceneChurn {
|
||||
understanding_status: churn.understanding_status,
|
||||
diff_pressure: churn.diff_pressure,
|
||||
graph_freshness: churn.graph_freshness,
|
||||
warning: churn.warning,
|
||||
unexplained_writes: churn.unexplained_writes,
|
||||
}
|
||||
}
|
||||
53
epiphany-codex-bridge/src/state.rs
Normal file
53
epiphany-codex-bridge/src/state.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use epiphany_core::write_thread_state;
|
||||
use epiphany_state_model::EpiphanyThreadState;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::retrieve::epiphany_retrieval_state_for_paths;
|
||||
|
||||
pub async fn client_visible_epiphany_state_for_paths(
|
||||
mut state: EpiphanyThreadState,
|
||||
workspace_root: &Path,
|
||||
codex_home: PathBuf,
|
||||
mirror_thread_id: Option<&str>,
|
||||
) -> EpiphanyThreadState {
|
||||
if state.retrieval.is_none() {
|
||||
state.retrieval = Some(
|
||||
epiphany_retrieval_state_for_paths(workspace_root.to_path_buf(), codex_home).await,
|
||||
);
|
||||
}
|
||||
if let Some(thread_id) = mirror_thread_id
|
||||
&& let Err(err) = mirror_thread_state_to_workspace(workspace_root, thread_id, &state)
|
||||
{
|
||||
warn!("failed to mirror Epiphany thread state into native store: {err}");
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
pub fn thread_state_store_path(workspace_root: &Path) -> PathBuf {
|
||||
workspace_root.join("state").join("thread-state.cc")
|
||||
}
|
||||
|
||||
pub fn mirror_thread_state_to_workspace(
|
||||
workspace_root: &Path,
|
||||
thread_id: &str,
|
||||
state: &EpiphanyThreadState,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
let store_path = thread_state_store_path(workspace_root);
|
||||
if let Some(parent) = store_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
write_thread_state(&store_path, thread_id, state)?;
|
||||
Ok(store_path)
|
||||
}
|
||||
|
||||
pub fn thread_state_mirror_id_from_rollout_path(rollout_path: Option<&Path>) -> String {
|
||||
rollout_path
|
||||
.and_then(|path| path.file_stem())
|
||||
.and_then(|stem| stem.to_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "live-thread".to_string())
|
||||
}
|
||||
746
epiphany-codex-bridge/src/view_protocol.rs
Normal file
746
epiphany-codex-bridge/src/view_protocol.rs
Normal file
|
|
@ -0,0 +1,746 @@
|
|||
use std::path::Path;
|
||||
|
||||
use codex_app_server_protocol::*;
|
||||
use epiphany_core::EPIPHANY_REORIENT_LAUNCH_BINDING_ID;
|
||||
use epiphany_core::EpiphanyContextParams;
|
||||
use epiphany_core::EpiphanyDistillInput;
|
||||
use epiphany_core::EpiphanyGraphQuery;
|
||||
use epiphany_core::EpiphanyMapProposalInput;
|
||||
use epiphany_core::EpiphanyRoleResultRoleId;
|
||||
use epiphany_core::EpiphanySceneInput;
|
||||
use epiphany_core::EpiphanyTokenUsageSnapshot;
|
||||
use epiphany_core::EpiphanyViewLens;
|
||||
use epiphany_core::derive_scene;
|
||||
use epiphany_core::distill_observation;
|
||||
use epiphany_core::epiphany_view_needs_jobs;
|
||||
use epiphany_core::epiphany_view_needs_pressure;
|
||||
use epiphany_core::epiphany_view_needs_reorientation_inputs;
|
||||
use epiphany_core::propose_map_update;
|
||||
use epiphany_core::reorient_finding_already_accepted;
|
||||
use epiphany_state_model::EpiphanyRetrievalState;
|
||||
use epiphany_state_model::EpiphanyThreadState;
|
||||
|
||||
use crate::context::derive_epiphany_context;
|
||||
use crate::context::derive_epiphany_graph_query;
|
||||
use crate::context::derive_epiphany_planning;
|
||||
use crate::context_protocol::protocol_context_view;
|
||||
use crate::context_protocol::protocol_graph_query_view;
|
||||
use crate::context_protocol::protocol_planning_view;
|
||||
use crate::coordinator::derive_epiphany_coordinator_status;
|
||||
use crate::coordinator::map_epiphany_crrc_recommendation;
|
||||
use crate::coordinator::map_epiphany_roles;
|
||||
use crate::coordinator::render_epiphany_roles_note;
|
||||
use crate::coordinator_protocol::protocol_coordinator_view;
|
||||
use crate::coordinator_protocol::protocol_crrc_recommendation;
|
||||
use crate::coordinator_protocol::protocol_role_board_lanes;
|
||||
use crate::cultnet::EpiphanyFreshnessSurface;
|
||||
use crate::cultnet::EpiphanySurfaceSource;
|
||||
use crate::error::EpiphanyBridgeError;
|
||||
use crate::error::Result as BridgeResult;
|
||||
use crate::jobs::map_epiphany_jobs;
|
||||
use crate::launch::epiphany_role_binding_id;
|
||||
use crate::pressure::derive_epiphany_pressure;
|
||||
use crate::protocol_edge::core_epiphany_view_needs_jobs;
|
||||
use crate::protocol_edge::core_epiphany_view_needs_pressure;
|
||||
use crate::protocol_edge::core_epiphany_view_needs_reorientation_inputs;
|
||||
use crate::protocol_edge::core_epiphany_view_needs_runtime_store;
|
||||
use crate::protocol_edge::default_core_epiphany_view_lenses;
|
||||
use crate::protocol_edge::protocol_context_params_to_core;
|
||||
use crate::protocol_edge::protocol_distill_params_to_core;
|
||||
use crate::protocol_edge::protocol_freshness_response_from_surface;
|
||||
use crate::protocol_edge::protocol_graph_query_to_core;
|
||||
use crate::protocol_edge::protocol_job_from_surface;
|
||||
use crate::protocol_edge::protocol_pressure_from_core;
|
||||
use crate::protocol_edge::protocol_reorient_decision;
|
||||
use crate::protocol_edge::protocol_reorient_finding;
|
||||
use crate::protocol_edge::protocol_reorient_result_status;
|
||||
use crate::protocol_edge::protocol_reorient_source;
|
||||
use crate::protocol_edge::protocol_reorient_state_status;
|
||||
use crate::protocol_edge::protocol_role_finding;
|
||||
use crate::protocol_edge::protocol_role_id_from_core;
|
||||
use crate::protocol_edge::protocol_role_id_to_core;
|
||||
use crate::protocol_edge::protocol_role_result_status;
|
||||
use crate::protocol_edge::protocol_roles_source;
|
||||
use crate::protocol_edge::protocol_view_lens_from_core;
|
||||
use crate::protocol_edge::protocol_view_lenses_to_core;
|
||||
use crate::reorient::EpiphanyFreshnessWatcherSnapshot;
|
||||
use crate::reorient::derive_epiphany_freshness_view;
|
||||
use crate::reorient::derive_epiphany_reorient;
|
||||
use crate::runtime_results::load_core_epiphany_reorient_result_snapshot;
|
||||
use crate::runtime_results::load_core_epiphany_role_result_snapshot;
|
||||
use crate::scene::derive_epiphany_scene;
|
||||
use crate::scene_protocol::protocol_scene;
|
||||
use crate::scene_protocol::protocol_scene_action;
|
||||
|
||||
pub struct EpiphanyFreshnessResponseInput<'a> {
|
||||
pub thread_id: String,
|
||||
pub loaded: bool,
|
||||
pub state: Option<&'a EpiphanyThreadState>,
|
||||
pub retrieval_override: Option<&'a EpiphanyRetrievalState>,
|
||||
pub watcher_snapshot: Option<EpiphanyFreshnessWatcherSnapshot<'a>>,
|
||||
}
|
||||
|
||||
pub fn derive_epiphany_freshness_surface(
|
||||
input: EpiphanyFreshnessResponseInput<'_>,
|
||||
) -> EpiphanyFreshnessSurface {
|
||||
let freshness = derive_epiphany_freshness_view(
|
||||
input.state,
|
||||
input.retrieval_override,
|
||||
input.watcher_snapshot,
|
||||
);
|
||||
EpiphanyFreshnessSurface {
|
||||
thread_id: input.thread_id,
|
||||
source: if input.loaded {
|
||||
EpiphanySurfaceSource::Live
|
||||
} else {
|
||||
EpiphanySurfaceSource::Stored
|
||||
},
|
||||
state_revision: freshness.state_revision,
|
||||
retrieval: freshness.retrieval,
|
||||
graph: freshness.graph,
|
||||
watcher: freshness.watcher,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_epiphany_freshness_response(
|
||||
input: EpiphanyFreshnessResponseInput<'_>,
|
||||
) -> ThreadEpiphanyFreshnessResponse {
|
||||
protocol_freshness_response_from_surface(derive_epiphany_freshness_surface(input))
|
||||
}
|
||||
|
||||
pub fn map_epiphany_context_response(
|
||||
thread_id: String,
|
||||
loaded: bool,
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
params: &EpiphanyContextParams,
|
||||
) -> ThreadEpiphanyContextResponse {
|
||||
let (state_status, state_revision, context, missing) =
|
||||
protocol_context_view(derive_epiphany_context(state, params));
|
||||
ThreadEpiphanyContextResponse {
|
||||
thread_id,
|
||||
source: if loaded {
|
||||
ThreadEpiphanyContextSource::Live
|
||||
} else {
|
||||
ThreadEpiphanyContextSource::Stored
|
||||
},
|
||||
state_status,
|
||||
state_revision,
|
||||
context,
|
||||
missing,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_thread_epiphany_context_response(
|
||||
params: ThreadEpiphanyContextParams,
|
||||
loaded: bool,
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
) -> ThreadEpiphanyContextResponse {
|
||||
let thread_id = params.thread_id.clone();
|
||||
let core_params = protocol_context_params_to_core(¶ms);
|
||||
map_epiphany_context_response(thread_id, loaded, state, &core_params)
|
||||
}
|
||||
|
||||
pub fn map_epiphany_graph_query_response(
|
||||
thread_id: String,
|
||||
loaded: bool,
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
query: &EpiphanyGraphQuery,
|
||||
) -> ThreadEpiphanyGraphQueryResponse {
|
||||
let (state_status, state_revision, graph, frontier, checkpoint, matched, missing) =
|
||||
protocol_graph_query_view(derive_epiphany_graph_query(state, query));
|
||||
ThreadEpiphanyGraphQueryResponse {
|
||||
thread_id,
|
||||
source: if loaded {
|
||||
ThreadEpiphanyContextSource::Live
|
||||
} else {
|
||||
ThreadEpiphanyContextSource::Stored
|
||||
},
|
||||
state_status,
|
||||
state_revision,
|
||||
graph,
|
||||
frontier,
|
||||
checkpoint,
|
||||
matched,
|
||||
missing,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_thread_epiphany_graph_query_response(
|
||||
params: ThreadEpiphanyGraphQueryParams,
|
||||
loaded: bool,
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
) -> ThreadEpiphanyGraphQueryResponse {
|
||||
let thread_id = params.thread_id.clone();
|
||||
let core_query = protocol_graph_query_to_core(¶ms.query);
|
||||
map_epiphany_graph_query_response(thread_id, loaded, state, &core_query)
|
||||
}
|
||||
|
||||
pub struct EpiphanyViewResponseInput<'a> {
|
||||
pub thread_id: String,
|
||||
pub lenses: Vec<EpiphanyViewLens>,
|
||||
pub loaded: bool,
|
||||
pub state: Option<&'a EpiphanyThreadState>,
|
||||
pub retrieval_override: Option<&'a EpiphanyRetrievalState>,
|
||||
pub watcher_snapshot: Option<EpiphanyFreshnessWatcherSnapshot<'a>>,
|
||||
pub token_usage_info: Option<&'a EpiphanyTokenUsageSnapshot>,
|
||||
pub runtime_store_path: Option<&'a Path>,
|
||||
}
|
||||
|
||||
pub struct EpiphanyViewRoutePlan {
|
||||
pub thread_id: String,
|
||||
pub lenses: Vec<EpiphanyViewLens>,
|
||||
pub needs_jobs: bool,
|
||||
pub needs_reorientation_inputs: bool,
|
||||
pub needs_pressure: bool,
|
||||
pub needs_runtime_store: bool,
|
||||
}
|
||||
|
||||
pub fn plan_thread_epiphany_view(params: ThreadEpiphanyViewParams) -> EpiphanyViewRoutePlan {
|
||||
let ThreadEpiphanyViewParams { thread_id, lenses } = params;
|
||||
let lenses = if lenses.is_empty() {
|
||||
default_core_epiphany_view_lenses()
|
||||
} else {
|
||||
protocol_view_lenses_to_core(lenses)
|
||||
};
|
||||
EpiphanyViewRoutePlan {
|
||||
thread_id,
|
||||
needs_jobs: core_epiphany_view_needs_jobs(&lenses),
|
||||
needs_reorientation_inputs: core_epiphany_view_needs_reorientation_inputs(&lenses),
|
||||
needs_pressure: core_epiphany_view_needs_pressure(&lenses),
|
||||
needs_runtime_store: core_epiphany_view_needs_runtime_store(&lenses),
|
||||
lenses,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn map_epiphany_view_response(
|
||||
input: EpiphanyViewResponseInput<'_>,
|
||||
) -> ThreadEpiphanyViewResponse {
|
||||
let EpiphanyViewResponseInput {
|
||||
thread_id,
|
||||
lenses,
|
||||
loaded,
|
||||
state,
|
||||
retrieval_override,
|
||||
watcher_snapshot,
|
||||
token_usage_info,
|
||||
runtime_store_path,
|
||||
} = input;
|
||||
|
||||
let needs_jobs = epiphany_view_needs_jobs(&lenses);
|
||||
let needs_reorientation_inputs = epiphany_view_needs_reorientation_inputs(&lenses);
|
||||
let needs_pressure = epiphany_view_needs_pressure(&lenses);
|
||||
let core_pressure = needs_pressure.then(|| derive_epiphany_pressure(token_usage_info));
|
||||
let pressure = needs_pressure
|
||||
.then(|| protocol_pressure_from_core(derive_epiphany_pressure(token_usage_info)));
|
||||
let core_freshness = needs_reorientation_inputs
|
||||
.then(|| derive_epiphany_freshness_view(state, retrieval_override, watcher_snapshot));
|
||||
let (state_revision, core_reorient_state_status, core_reorient_decision) =
|
||||
if let (Some(freshness), Some(core_pressure)) =
|
||||
(core_freshness.as_ref(), core_pressure.as_ref())
|
||||
{
|
||||
let (state_status, decision) = derive_epiphany_reorient(
|
||||
state,
|
||||
core_pressure,
|
||||
&freshness.retrieval,
|
||||
&freshness.graph,
|
||||
&freshness.watcher,
|
||||
);
|
||||
(freshness.state_revision, state_status, Some(decision))
|
||||
} else {
|
||||
(
|
||||
None,
|
||||
epiphany_core::EpiphanyReorientStateStatus::Missing,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let reorient_state_status = protocol_reorient_state_status(core_reorient_state_status);
|
||||
let reorient_decision = core_reorient_decision
|
||||
.clone()
|
||||
.map(protocol_reorient_decision);
|
||||
let jobs = if needs_jobs {
|
||||
map_epiphany_jobs(state, retrieval_override)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let reorient_job = jobs
|
||||
.iter()
|
||||
.find(|job| job.id == EPIPHANY_REORIENT_LAUNCH_BINDING_ID)
|
||||
.cloned();
|
||||
let reorient_result = if runtime_store_path.is_some()
|
||||
|| lenses.contains(&EpiphanyViewLens::Crrc)
|
||||
|| lenses.contains(&EpiphanyViewLens::Coordinator)
|
||||
{
|
||||
load_core_epiphany_reorient_result_snapshot(
|
||||
state,
|
||||
runtime_store_path,
|
||||
EPIPHANY_REORIENT_LAUNCH_BINDING_ID,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
crate::runtime_results::EpiphanyReorientResultSnapshot {
|
||||
status: epiphany_core::EpiphanyCrrcResultStatus::MissingState,
|
||||
finding: None,
|
||||
note: "Reorient result was not requested.".to_string(),
|
||||
}
|
||||
};
|
||||
let reorient_result_status = protocol_reorient_result_status(reorient_result.status);
|
||||
let reorient_finding = reorient_result
|
||||
.finding
|
||||
.clone()
|
||||
.map(protocol_reorient_finding);
|
||||
let reorient_result_note = reorient_result.note.clone();
|
||||
let checkpoint_present = state
|
||||
.and_then(|state| state.investigation_checkpoint.as_ref())
|
||||
.is_some();
|
||||
let reorient_finding_accepted = reorient_result.finding.as_ref().is_some_and(|finding| {
|
||||
state.is_some_and(|state| reorient_finding_already_accepted(state, finding))
|
||||
});
|
||||
let recommendation = if let (Some(core_pressure), Some(decision)) =
|
||||
(core_pressure.as_ref(), core_reorient_decision.as_ref())
|
||||
{
|
||||
Some(map_epiphany_crrc_recommendation(
|
||||
loaded,
|
||||
core_reorient_state_status,
|
||||
core_pressure,
|
||||
decision,
|
||||
reorient_result.status,
|
||||
checkpoint_present,
|
||||
reorient_finding.is_some(),
|
||||
reorient_finding_accepted,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let roles = if let (Some(core_pressure), Some(decision), Some(recommendation)) = (
|
||||
core_pressure.as_ref(),
|
||||
core_reorient_decision.as_ref(),
|
||||
recommendation.as_ref(),
|
||||
) {
|
||||
Some(map_epiphany_roles(
|
||||
state,
|
||||
&jobs,
|
||||
decision,
|
||||
core_pressure,
|
||||
recommendation,
|
||||
reorient_result.status,
|
||||
reorient_job.as_ref(),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let coordinator_response = if lenses.contains(&EpiphanyViewLens::Coordinator) {
|
||||
if let (Some(core_pressure), Some(recommendation), Some(roles)) = (
|
||||
core_pressure.as_ref(),
|
||||
recommendation.as_ref(),
|
||||
roles.clone(),
|
||||
) {
|
||||
let status = derive_epiphany_coordinator_status(
|
||||
state,
|
||||
runtime_store_path,
|
||||
core_reorient_state_status,
|
||||
core_pressure,
|
||||
recommendation,
|
||||
roles.roles.clone(),
|
||||
core_reorient_decision.as_ref(),
|
||||
reorient_result.status,
|
||||
reorient_result.finding.as_ref(),
|
||||
checkpoint_present,
|
||||
)
|
||||
.await;
|
||||
Some(protocol_coordinator_view(
|
||||
thread_id.clone(),
|
||||
loaded,
|
||||
core_reorient_state_status,
|
||||
state_revision,
|
||||
status,
|
||||
roles,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
ThreadEpiphanyViewResponse {
|
||||
thread_id: thread_id.clone(),
|
||||
scene: lenses.contains(&EpiphanyViewLens::Scene).then(|| {
|
||||
protocol_scene(derive_epiphany_scene(
|
||||
state,
|
||||
loaded,
|
||||
EPIPHANY_REORIENT_LAUNCH_BINDING_ID,
|
||||
))
|
||||
}),
|
||||
jobs: if lenses.contains(&EpiphanyViewLens::Jobs) {
|
||||
jobs.iter()
|
||||
.cloned()
|
||||
.map(|job| protocol_job_from_surface(job, None, None))
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
roles: lenses.contains(&EpiphanyViewLens::Roles).then(|| {
|
||||
let role_board = roles.clone();
|
||||
let protocol_roles = role_board
|
||||
.as_ref()
|
||||
.map(protocol_role_board_lanes)
|
||||
.unwrap_or_default();
|
||||
ThreadEpiphanyViewRoles {
|
||||
thread_id: thread_id.clone(),
|
||||
source: if loaded {
|
||||
ThreadEpiphanyRolesSource::Live
|
||||
} else {
|
||||
ThreadEpiphanyRolesSource::Stored
|
||||
},
|
||||
state_status: reorient_state_status,
|
||||
state_revision,
|
||||
note: render_epiphany_roles_note(
|
||||
role_board
|
||||
.as_ref()
|
||||
.map(|role_board| role_board.roles.as_slice())
|
||||
.unwrap_or(&[]),
|
||||
core_reorient_state_status,
|
||||
recommendation
|
||||
.as_ref()
|
||||
.map(|recommendation| recommendation.action)
|
||||
.unwrap_or(epiphany_core::EpiphanyCrrcAction::Continue),
|
||||
),
|
||||
roles: protocol_roles,
|
||||
}
|
||||
}),
|
||||
planning: lenses.contains(&EpiphanyViewLens::Planning).then(|| {
|
||||
let (state_status, state_revision, planning, summary) =
|
||||
protocol_planning_view(derive_epiphany_planning(state));
|
||||
ThreadEpiphanyViewPlanning {
|
||||
thread_id: thread_id.clone(),
|
||||
source: if loaded {
|
||||
ThreadEpiphanyContextSource::Live
|
||||
} else {
|
||||
ThreadEpiphanyContextSource::Stored
|
||||
},
|
||||
state_status,
|
||||
state_revision,
|
||||
planning,
|
||||
summary,
|
||||
}
|
||||
}),
|
||||
pressure: lenses
|
||||
.contains(&EpiphanyViewLens::Pressure)
|
||||
.then(|| pressure.clone())
|
||||
.flatten(),
|
||||
reorient: lenses
|
||||
.contains(&EpiphanyViewLens::Reorient)
|
||||
.then(|| {
|
||||
reorient_decision
|
||||
.clone()
|
||||
.map(|decision| ThreadEpiphanyViewReorient {
|
||||
thread_id: thread_id.clone(),
|
||||
source: if loaded {
|
||||
ThreadEpiphanyReorientSource::Live
|
||||
} else {
|
||||
ThreadEpiphanyReorientSource::Stored
|
||||
},
|
||||
state_status: reorient_state_status,
|
||||
state_revision,
|
||||
decision,
|
||||
})
|
||||
})
|
||||
.flatten(),
|
||||
crrc: lenses
|
||||
.contains(&EpiphanyViewLens::Crrc)
|
||||
.then(|| {
|
||||
let pressure = pressure.clone()?;
|
||||
let decision = reorient_decision.clone()?;
|
||||
let recommendation = recommendation.clone()?;
|
||||
let protocol_recommendation = protocol_crrc_recommendation(recommendation.clone());
|
||||
let available_actions = derive_scene(EpiphanySceneInput {
|
||||
state,
|
||||
loaded,
|
||||
reorient_binding_id: EPIPHANY_REORIENT_LAUNCH_BINDING_ID,
|
||||
})
|
||||
.available_actions
|
||||
.into_iter()
|
||||
.map(protocol_scene_action)
|
||||
.collect();
|
||||
let note = format!(
|
||||
"{} Result status: {:?}. {}",
|
||||
recommendation.reason, reorient_result_status, reorient_result_note
|
||||
);
|
||||
Some(ThreadEpiphanyViewCrrc {
|
||||
thread_id: thread_id.clone(),
|
||||
source: if loaded {
|
||||
ThreadEpiphanyReorientSource::Live
|
||||
} else {
|
||||
ThreadEpiphanyReorientSource::Stored
|
||||
},
|
||||
state_status: reorient_state_status,
|
||||
state_revision,
|
||||
pressure,
|
||||
decision,
|
||||
recommendation: protocol_recommendation,
|
||||
reorient_binding_id: EPIPHANY_REORIENT_LAUNCH_BINDING_ID.to_string(),
|
||||
reorient_result_status,
|
||||
reorient_job: reorient_job
|
||||
.clone()
|
||||
.map(|job| protocol_job_from_surface(job, None, None)),
|
||||
reorient_finding: reorient_finding.clone(),
|
||||
available_actions,
|
||||
note,
|
||||
})
|
||||
})
|
||||
.flatten(),
|
||||
coordinator: coordinator_response,
|
||||
lenses: lenses
|
||||
.into_iter()
|
||||
.map(protocol_view_lens_from_core)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EpiphanyRoleResultResponseInput<'a> {
|
||||
pub thread_id: String,
|
||||
pub role_id: EpiphanyRoleResultRoleId,
|
||||
pub source: EpiphanySurfaceSource,
|
||||
pub binding_id: String,
|
||||
pub state: Option<&'a EpiphanyThreadState>,
|
||||
pub runtime_store_path: Option<&'a Path>,
|
||||
}
|
||||
|
||||
pub async fn map_epiphany_role_result_response(
|
||||
input: EpiphanyRoleResultResponseInput<'_>,
|
||||
) -> ThreadEpiphanyRoleResultResponse {
|
||||
let EpiphanyRoleResultResponseInput {
|
||||
thread_id,
|
||||
role_id,
|
||||
source,
|
||||
binding_id,
|
||||
state,
|
||||
runtime_store_path,
|
||||
} = input;
|
||||
let protocol_role_id = protocol_role_id_from_core(role_id);
|
||||
let protocol_source = protocol_roles_source(source);
|
||||
let Some(state) = state else {
|
||||
return ThreadEpiphanyRoleResultResponse {
|
||||
thread_id,
|
||||
role_id: protocol_role_id,
|
||||
source: protocol_source,
|
||||
state_status: protocol_reorient_state_status(
|
||||
epiphany_core::EpiphanyReorientStateStatus::Missing,
|
||||
),
|
||||
state_revision: None,
|
||||
binding_id,
|
||||
status: ThreadEpiphanyRoleResultStatus::MissingState,
|
||||
job: None,
|
||||
finding: None,
|
||||
note: "No authoritative Epiphany state exists for this thread.".to_string(),
|
||||
};
|
||||
};
|
||||
|
||||
let job = map_epiphany_jobs(Some(state), None)
|
||||
.into_iter()
|
||||
.find(|job| job.id == binding_id)
|
||||
.map(|job| protocol_job_from_surface(job, None, None));
|
||||
let result =
|
||||
load_core_epiphany_role_result_snapshot(state, runtime_store_path, role_id, &binding_id)
|
||||
.await;
|
||||
|
||||
ThreadEpiphanyRoleResultResponse {
|
||||
thread_id,
|
||||
role_id: protocol_role_id,
|
||||
source: protocol_source,
|
||||
state_status: protocol_reorient_state_status(
|
||||
epiphany_core::EpiphanyReorientStateStatus::Ready,
|
||||
),
|
||||
state_revision: Some(state.revision),
|
||||
binding_id,
|
||||
status: protocol_role_result_status(result.status),
|
||||
job,
|
||||
finding: result
|
||||
.finding
|
||||
.map(|finding| protocol_role_finding(protocol_role_id, finding)),
|
||||
note: result.note,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn map_thread_epiphany_role_result_response(
|
||||
params: ThreadEpiphanyRoleResultParams,
|
||||
loaded: bool,
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
runtime_store_path: Option<&Path>,
|
||||
) -> BridgeResult<ThreadEpiphanyRoleResultResponse> {
|
||||
let ThreadEpiphanyRoleResultParams {
|
||||
thread_id,
|
||||
role_id,
|
||||
binding_id,
|
||||
} = params;
|
||||
let role_id = protocol_role_id_to_core(role_id);
|
||||
let default_binding_id =
|
||||
epiphany_role_binding_id(role_id).map_err(EpiphanyBridgeError::InvalidRequest)?;
|
||||
let binding_id = binding_id.unwrap_or_else(|| default_binding_id.to_string());
|
||||
Ok(
|
||||
map_epiphany_role_result_response(EpiphanyRoleResultResponseInput {
|
||||
thread_id,
|
||||
role_id,
|
||||
source: if loaded {
|
||||
EpiphanySurfaceSource::Live
|
||||
} else {
|
||||
EpiphanySurfaceSource::Stored
|
||||
},
|
||||
binding_id,
|
||||
state,
|
||||
runtime_store_path,
|
||||
})
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
pub struct EpiphanyReorientResultResponseInput<'a> {
|
||||
pub thread_id: String,
|
||||
pub source: EpiphanySurfaceSource,
|
||||
pub binding_id: String,
|
||||
pub state: Option<&'a EpiphanyThreadState>,
|
||||
pub runtime_store_path: Option<&'a Path>,
|
||||
}
|
||||
|
||||
pub async fn map_epiphany_reorient_result_response(
|
||||
input: EpiphanyReorientResultResponseInput<'_>,
|
||||
) -> ThreadEpiphanyReorientResultResponse {
|
||||
let EpiphanyReorientResultResponseInput {
|
||||
thread_id,
|
||||
source,
|
||||
binding_id,
|
||||
state,
|
||||
runtime_store_path,
|
||||
} = input;
|
||||
let protocol_source = protocol_reorient_source(source);
|
||||
let Some(state) = state else {
|
||||
return ThreadEpiphanyReorientResultResponse {
|
||||
thread_id,
|
||||
source: protocol_source,
|
||||
state_status: protocol_reorient_state_status(
|
||||
epiphany_core::EpiphanyReorientStateStatus::Missing,
|
||||
),
|
||||
state_revision: None,
|
||||
binding_id,
|
||||
status: ThreadEpiphanyReorientResultStatus::MissingState,
|
||||
job: None,
|
||||
finding: None,
|
||||
note: "No authoritative Epiphany state exists for this thread.".to_string(),
|
||||
};
|
||||
};
|
||||
|
||||
let job = map_epiphany_jobs(Some(state), None)
|
||||
.into_iter()
|
||||
.find(|job| job.id == binding_id)
|
||||
.map(|job| protocol_job_from_surface(job, None, None));
|
||||
let result =
|
||||
load_core_epiphany_reorient_result_snapshot(Some(state), runtime_store_path, &binding_id)
|
||||
.await;
|
||||
|
||||
ThreadEpiphanyReorientResultResponse {
|
||||
thread_id,
|
||||
source: protocol_source,
|
||||
state_status: protocol_reorient_state_status(
|
||||
epiphany_core::EpiphanyReorientStateStatus::Ready,
|
||||
),
|
||||
state_revision: Some(state.revision),
|
||||
binding_id,
|
||||
status: protocol_reorient_result_status(result.status),
|
||||
job,
|
||||
finding: result.finding.map(protocol_reorient_finding),
|
||||
note: result.note,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn map_thread_epiphany_reorient_result_response(
|
||||
params: ThreadEpiphanyReorientResultParams,
|
||||
loaded: bool,
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
runtime_store_path: Option<&Path>,
|
||||
) -> ThreadEpiphanyReorientResultResponse {
|
||||
let ThreadEpiphanyReorientResultParams {
|
||||
thread_id,
|
||||
binding_id,
|
||||
} = params;
|
||||
let binding_id = binding_id.unwrap_or_else(|| EPIPHANY_REORIENT_LAUNCH_BINDING_ID.into());
|
||||
map_epiphany_reorient_result_response(EpiphanyReorientResultResponseInput {
|
||||
thread_id,
|
||||
source: if loaded {
|
||||
EpiphanySurfaceSource::Live
|
||||
} else {
|
||||
EpiphanySurfaceSource::Stored
|
||||
},
|
||||
binding_id,
|
||||
state,
|
||||
runtime_store_path,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn map_epiphany_distill_response(
|
||||
expected_revision: u64,
|
||||
input: EpiphanyDistillInput,
|
||||
) -> std::result::Result<ThreadEpiphanyDistillResponse, String> {
|
||||
let proposal = distill_observation(input).map_err(|err| err.to_string())?;
|
||||
|
||||
Ok(ThreadEpiphanyDistillResponse {
|
||||
expected_revision,
|
||||
patch: ThreadEpiphanyUpdatePatch {
|
||||
observations: vec![proposal.observation],
|
||||
evidence: vec![proposal.evidence],
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn map_thread_epiphany_distill_response(
|
||||
params: ThreadEpiphanyDistillParams,
|
||||
state: Option<&EpiphanyThreadState>,
|
||||
) -> BridgeResult<ThreadEpiphanyDistillResponse> {
|
||||
let expected_revision = state.map(|state| state.revision).unwrap_or(0);
|
||||
let input = protocol_distill_params_to_core(params);
|
||||
map_epiphany_distill_response(expected_revision, input).map_err(|err| {
|
||||
EpiphanyBridgeError::InvalidRequest(format!(
|
||||
"failed to distill Epiphany observation: {err}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn map_epiphany_propose_response(
|
||||
state: EpiphanyThreadState,
|
||||
observation_ids: Vec<String>,
|
||||
) -> std::result::Result<ThreadEpiphanyProposeResponse, String> {
|
||||
let expected_revision = state.revision;
|
||||
let proposal = propose_map_update(EpiphanyMapProposalInput {
|
||||
state,
|
||||
observation_ids,
|
||||
})
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
Ok(ThreadEpiphanyProposeResponse {
|
||||
expected_revision,
|
||||
patch: ThreadEpiphanyUpdatePatch {
|
||||
observations: vec![proposal.observation],
|
||||
evidence: vec![proposal.evidence],
|
||||
graphs: Some(proposal.graphs),
|
||||
graph_frontier: Some(proposal.graph_frontier),
|
||||
churn: Some(proposal.churn),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn map_thread_epiphany_propose_response(
|
||||
params: ThreadEpiphanyProposeParams,
|
||||
state: Option<EpiphanyThreadState>,
|
||||
) -> BridgeResult<ThreadEpiphanyProposeResponse> {
|
||||
let thread_id = params.thread_id.clone();
|
||||
let Some(state) = state else {
|
||||
return Err(EpiphanyBridgeError::InvalidRequest(format!(
|
||||
"thread has no Epiphany state: {thread_id}"
|
||||
)));
|
||||
};
|
||||
map_epiphany_propose_response(state, params.observation_ids).map_err(|err| {
|
||||
EpiphanyBridgeError::InvalidRequest(format!("failed to propose Epiphany map update: {err}"))
|
||||
})
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ name = "epiphany-core"
|
|||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
autobins = false
|
||||
license-file = "../LICENSE"
|
||||
|
||||
[lib]
|
||||
|
|
@ -12,29 +11,28 @@ path = "src/lib.rs"
|
|||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
bm25 = "2.3.2"
|
||||
chrono = "0.4.44"
|
||||
ctrlc = { version = "3.5.1", features = ["termination"] }
|
||||
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a0813c6eed24d30bf88073ef615b633c77ebfcd6", package = "cultcache-rs" }
|
||||
cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a0813c6eed24d30bf88073ef615b633c77ebfcd6", package = "cultmesh-rs" }
|
||||
cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a0813c6eed24d30bf88073ef615b633c77ebfcd6", package = "cultnet-rs" }
|
||||
cultcache-rs = { path = "../vendor/cultcache-rs" }
|
||||
cultmesh-rs = { path = "../vendor/cultmesh-rs" }
|
||||
cultnet-rs = { path = "../vendor/cultnet-rs" }
|
||||
epiphany-openai-adapter = { path = "../epiphany-openai-adapter" }
|
||||
epiphany-model-adapter = { path = "../epiphany-model-adapter" }
|
||||
epiphany-tool-adapter = { path = "../epiphany-tool-adapter" }
|
||||
fs2 = "0.4"
|
||||
ghostlight-persona-projection = { git = "https://github.com/GameCult/Ghostlight.git", rev = "22937b71f056d2000dd7ea1f6af0c45825de495f", package = "ghostlight-persona-projection" }
|
||||
ed25519-dalek = "2"
|
||||
epiphany-state-model = { path = "../epiphany-state-model" }
|
||||
ignore = "0.4.23"
|
||||
postgres = "0.19"
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||
rmp-serde = "1"
|
||||
serde ={ version = "1", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
semver = { version = "1", features = ["serde"] }
|
||||
sha2 = "0.10"
|
||||
uuid = { version = "1", features = ["serde", "v4", "v5"] }
|
||||
sha1 = "0.10.6"
|
||||
toml = "0.9"
|
||||
tracing = "0.1.44"
|
||||
uuid = { version = "1", features = ["v4", "v5"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4.1"
|
||||
tempfile = "3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread"] }
|
||||
wiremock = "0.6"
|
||||
|
|
|
|||
|
|
@ -1,402 +0,0 @@
|
|||
use anyhow::{Result, anyhow, bail};
|
||||
use cultcache_rs::{CacheBackingStore, DatabaseEntry, SingleFileMessagePackBackingStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::Path;
|
||||
|
||||
pub const REQUEST_SCHEMA: &str = "epiphany.self.admitted_model_direction_consideration_request.v1";
|
||||
pub const REQUEST_CONTRACT: &str = "epiphany.admitted_model_direction_consideration_request.v1";
|
||||
pub const RESULT_SCHEMA: &str =
|
||||
"epiphany.imagination.admitted_model_direction_consideration_result.v1";
|
||||
pub const RESULT_CONTRACT: &str = "epiphany.admitted_model_direction_consideration_result.v1";
|
||||
pub const MAX_OPTION_DRAFTS: usize = 3;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AdmittedModelDirectionDisposition {
|
||||
Suggest,
|
||||
Hold,
|
||||
NoFit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "epiphany.self.admitted_model_direction_consideration_request",
|
||||
schema = "AdmittedModelDirectionConsiderationRequest"
|
||||
)]
|
||||
pub struct AdmittedModelDirectionConsiderationRequest {
|
||||
#[cultcache(key = 0)]
|
||||
pub schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub request_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
pub runtime_id: String,
|
||||
#[cultcache(key = 3)]
|
||||
pub thread_id: String,
|
||||
#[cultcache(key = 4)]
|
||||
pub model_projection_digest: String,
|
||||
#[cultcache(key = 5)]
|
||||
pub model_source_documents: Vec<crate::EpiphanyMindDocumentVersion>,
|
||||
#[cultcache(key = 7, default)]
|
||||
pub previous_terminal_result_id: Option<String>,
|
||||
#[cultcache(key = 8)]
|
||||
pub requested_at: String,
|
||||
#[cultcache(key = 9)]
|
||||
pub contract: String,
|
||||
#[cultcache(key = 10, default)]
|
||||
pub private_state_included: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "epiphany.imagination.admitted_model_direction_consideration_result",
|
||||
schema = "AdmittedModelDirectionConsiderationResult"
|
||||
)]
|
||||
pub struct AdmittedModelDirectionConsiderationResult {
|
||||
#[cultcache(key = 0)]
|
||||
pub schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub result_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
pub request_id: String,
|
||||
#[cultcache(key = 3)]
|
||||
pub runtime_id: String,
|
||||
#[cultcache(key = 4)]
|
||||
pub thread_id: String,
|
||||
#[cultcache(key = 5)]
|
||||
pub model_projection_digest: String,
|
||||
#[cultcache(key = 6)]
|
||||
pub model_source_documents: Vec<crate::EpiphanyMindDocumentVersion>,
|
||||
#[cultcache(key = 8)]
|
||||
pub disposition: AdmittedModelDirectionDisposition,
|
||||
#[cultcache(key = 9)]
|
||||
pub summary: String,
|
||||
#[cultcache(key = 10, default)]
|
||||
pub option_drafts: Vec<crate::ImaginationOptionDraft>,
|
||||
#[cultcache(key = 11, default)]
|
||||
pub uncertainties: Vec<String>,
|
||||
#[cultcache(key = 12, default)]
|
||||
pub evidence_refs: Vec<String>,
|
||||
#[cultcache(key = 13)]
|
||||
pub proposed_at: String,
|
||||
#[cultcache(key = 14)]
|
||||
pub contract: String,
|
||||
#[cultcache(key = 15, default)]
|
||||
pub proposal_only: bool,
|
||||
#[cultcache(key = 16, default)]
|
||||
pub terminal: bool,
|
||||
}
|
||||
|
||||
pub fn commit_request(
|
||||
runtime_store: &Path,
|
||||
requested_at: &str,
|
||||
) -> Result<Option<AdmittedModelDirectionConsiderationRequest>> {
|
||||
chrono::DateTime::parse_from_rfc3339(requested_at)
|
||||
.map_err(|_| anyhow!("model direction consideration timestamp must be RFC3339"))?;
|
||||
let mut cache = crate::runtime_spine_cache(runtime_store)?;
|
||||
cache.pull_all_backing_stores()?;
|
||||
let identity = cache
|
||||
.get::<crate::EpiphanyRuntimeIdentity>(crate::RUNTIME_IDENTITY_KEY)?
|
||||
.ok_or_else(|| anyhow!("model direction consideration requires runtime identity"))?;
|
||||
let model = crate::assemble_repo_model_view(runtime_store)?;
|
||||
let model_basis = model.reasoning_basis();
|
||||
let mut terminal = cache
|
||||
.get_all::<AdmittedModelDirectionConsiderationResult>()?
|
||||
.into_iter()
|
||||
.filter(|result| result.terminal)
|
||||
.collect::<Vec<_>>();
|
||||
terminal.sort_by(|left, right| left.proposed_at.cmp(&right.proposed_at));
|
||||
if terminal.iter().any(|result| {
|
||||
result.model_projection_digest == model_basis.projection_digest
|
||||
&& result.model_source_documents == model_basis.source_documents
|
||||
}) {
|
||||
return Ok(None);
|
||||
}
|
||||
let previous_terminal_result_id = terminal.last().map(|result| result.result_id.clone());
|
||||
let request_id = crate::admitted_model_direction_request_id(
|
||||
&identity.runtime_id,
|
||||
&model_basis.projection_digest,
|
||||
previous_terminal_result_id.as_deref(),
|
||||
);
|
||||
let request = AdmittedModelDirectionConsiderationRequest {
|
||||
schema_version: REQUEST_SCHEMA.into(),
|
||||
request_id: request_id.clone(),
|
||||
runtime_id: identity.runtime_id,
|
||||
thread_id: request_id.clone(),
|
||||
model_projection_digest: model_basis.projection_digest,
|
||||
model_source_documents: model_basis.source_documents,
|
||||
previous_terminal_result_id,
|
||||
requested_at: requested_at.into(),
|
||||
contract: REQUEST_CONTRACT.into(),
|
||||
private_state_included: false,
|
||||
};
|
||||
validate_current_request(&cache, &request)?;
|
||||
if let Some(existing) = cache.get::<AdmittedModelDirectionConsiderationRequest>(&request_id)? {
|
||||
let mut replay = request;
|
||||
replay.requested_at = existing.requested_at.clone();
|
||||
return if replay == existing {
|
||||
Ok(Some(existing))
|
||||
} else {
|
||||
bail!("model direction request identity collision")
|
||||
};
|
||||
}
|
||||
let (entry, _) = cache.prepare_entry(&request_id, &request)?;
|
||||
SingleFileMessagePackBackingStore::new(runtime_store).push(&entry)?;
|
||||
Ok(Some(request))
|
||||
}
|
||||
|
||||
pub fn validate_current_request(
|
||||
cache: &cultcache_rs::CultCache,
|
||||
request: &AdmittedModelDirectionConsiderationRequest,
|
||||
) -> Result<()> {
|
||||
validate_request(request)?;
|
||||
if (crate::EpiphanyRepoModelBasis {
|
||||
projection_digest: request.model_projection_digest.clone(),
|
||||
source_documents: request.model_source_documents.clone(),
|
||||
})
|
||||
.validate_against_cache(cache)
|
||||
.is_err()
|
||||
{
|
||||
bail!("model direction consideration request is stale");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_request(request: &AdmittedModelDirectionConsiderationRequest) -> Result<()> {
|
||||
if request.schema_version != REQUEST_SCHEMA
|
||||
|| request.contract != REQUEST_CONTRACT
|
||||
|| request.private_state_included
|
||||
|| request.request_id.trim().is_empty()
|
||||
|| request.runtime_id.trim().is_empty()
|
||||
|| request.thread_id.trim().is_empty()
|
||||
|| request
|
||||
.previous_terminal_result_id
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.trim().is_empty())
|
||||
|| chrono::DateTime::parse_from_rfc3339(&request.requested_at).is_err()
|
||||
{
|
||||
bail!("invalid model direction consideration request");
|
||||
}
|
||||
crate::EpiphanyRepoModelBasis {
|
||||
projection_digest: request.model_projection_digest.clone(),
|
||||
source_documents: request.model_source_documents.clone(),
|
||||
}
|
||||
.validate()
|
||||
.map_err(|_| anyhow!("invalid model direction consideration request"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn request_is_superseded(
|
||||
cache: &cultcache_rs::CultCache,
|
||||
request: &AdmittedModelDirectionConsiderationRequest,
|
||||
) -> Result<bool> {
|
||||
validate_request(request)?;
|
||||
Ok(crate::EpiphanyRepoModelBasis {
|
||||
projection_digest: request.model_projection_digest.clone(),
|
||||
source_documents: request.model_source_documents.clone(),
|
||||
}
|
||||
.validate_against_cache(cache)
|
||||
.is_err())
|
||||
}
|
||||
|
||||
pub fn validate_result(
|
||||
request: &AdmittedModelDirectionConsiderationRequest,
|
||||
result: &AdmittedModelDirectionConsiderationResult,
|
||||
) -> Result<()> {
|
||||
let requested_at = chrono::DateTime::parse_from_rfc3339(&request.requested_at)
|
||||
.map_err(|_| anyhow!("model direction request timestamp must be RFC3339"))?;
|
||||
let proposed_at = chrono::DateTime::parse_from_rfc3339(&result.proposed_at)
|
||||
.map_err(|_| anyhow!("model direction result timestamp must be RFC3339"))?;
|
||||
if result.schema_version != RESULT_SCHEMA
|
||||
|| result.contract != RESULT_CONTRACT
|
||||
|| !result.proposal_only
|
||||
|| !result.terminal
|
||||
|| result.request_id != request.request_id
|
||||
|| result.runtime_id != request.runtime_id
|
||||
|| result.thread_id != request.thread_id
|
||||
|| result.model_projection_digest != request.model_projection_digest
|
||||
|| result.model_source_documents != request.model_source_documents
|
||||
|| result.result_id.trim().is_empty()
|
||||
|| result.summary.trim().is_empty()
|
||||
|| proposed_at < requested_at
|
||||
{
|
||||
bail!("model direction result substituted causal identity");
|
||||
}
|
||||
if result.disposition == AdmittedModelDirectionDisposition::Suggest
|
||||
&& result.option_drafts.is_empty()
|
||||
{
|
||||
bail!("model direction suggestion requires at least one option");
|
||||
}
|
||||
if result.option_drafts.len() > MAX_OPTION_DRAFTS {
|
||||
bail!("model direction result exceeds bounded option fan-out");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn result_id_for_launch(request_id: &str, job_id: &str) -> String {
|
||||
format!(
|
||||
"admitted-model-direction-consideration-result-{:x}",
|
||||
Sha256::digest(format!("{request_id}:{job_id}").as_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_prompt(request: &AdmittedModelDirectionConsiderationRequest) -> String {
|
||||
format!(
|
||||
"Act as Epiphany Imagination for one proposal-only direction consideration. Inspect the exact current keyed Modeling map bound by request {} at projection digest {}. Suggest options or hold. Do not adopt, edit, execute, release, or deploy.",
|
||||
request.request_id, request.model_projection_digest
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn model_basis() -> crate::EpiphanyRepoModelBasis {
|
||||
let source_documents = vec![crate::EpiphanyMindDocumentVersion {
|
||||
store_id: "epiphany-mind".into(),
|
||||
document_type: "epiphany.mind.repo_model.identity.v1".into(),
|
||||
document_key: crate::REPO_MODEL_IDENTITY_KEY.into(),
|
||||
schema_id: Some("EpiphanyRepoModelIdentityDocument".into()),
|
||||
payload_msgpack: vec![1],
|
||||
payload_sha256: format!("sha256:{:x}", Sha256::digest([1])),
|
||||
}];
|
||||
crate::EpiphanyRepoModelBasis {
|
||||
projection_digest: format!(
|
||||
"sha256:{:x}",
|
||||
Sha256::digest(rmp_serde::to_vec_named(&source_documents).unwrap())
|
||||
),
|
||||
source_documents,
|
||||
}
|
||||
}
|
||||
|
||||
fn request() -> AdmittedModelDirectionConsiderationRequest {
|
||||
let basis = model_basis();
|
||||
AdmittedModelDirectionConsiderationRequest {
|
||||
schema_version: REQUEST_SCHEMA.into(),
|
||||
request_id: "request-1".into(),
|
||||
runtime_id: "runtime-1".into(),
|
||||
thread_id: "thread-1".into(),
|
||||
model_projection_digest: basis.projection_digest,
|
||||
model_source_documents: basis.source_documents,
|
||||
previous_terminal_result_id: None,
|
||||
requested_at: "2026-07-18T00:00:00Z".into(),
|
||||
contract: REQUEST_CONTRACT.into(),
|
||||
private_state_included: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn result(
|
||||
request: &AdmittedModelDirectionConsiderationRequest,
|
||||
) -> AdmittedModelDirectionConsiderationResult {
|
||||
AdmittedModelDirectionConsiderationResult {
|
||||
schema_version: RESULT_SCHEMA.into(),
|
||||
result_id: result_id_for_launch(&request.request_id, "job-1"),
|
||||
request_id: request.request_id.clone(),
|
||||
runtime_id: request.runtime_id.clone(),
|
||||
thread_id: request.thread_id.clone(),
|
||||
model_projection_digest: request.model_projection_digest.clone(),
|
||||
model_source_documents: request.model_source_documents.clone(),
|
||||
disposition: AdmittedModelDirectionDisposition::Hold,
|
||||
summary: "No direction should be promoted yet.".into(),
|
||||
option_drafts: Vec::new(),
|
||||
uncertainties: vec!["Current evidence is incomplete.".into()],
|
||||
evidence_refs: vec!["cultcache://runtime/repo-model/sha256:model-7".into()],
|
||||
proposed_at: "2026-07-18T00:01:00Z".into(),
|
||||
contract: RESULT_CONTRACT.into(),
|
||||
proposal_only: true,
|
||||
terminal: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_refuses_unbounded_autonomous_proposal_fanout() {
|
||||
let request = request();
|
||||
let mut result = result(&request);
|
||||
result.disposition = AdmittedModelDirectionDisposition::Suggest;
|
||||
result.option_drafts = (0..=MAX_OPTION_DRAFTS)
|
||||
.map(|ordinal| crate::ImaginationOptionDraft {
|
||||
title: format!("Option {ordinal}"),
|
||||
summary: "Bounded option.".into(),
|
||||
})
|
||||
.collect();
|
||||
assert!(validate_result(&request, &result).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intrinsic_request_validation_does_not_claim_current_repo_model_authority() {
|
||||
let request = request();
|
||||
assert!(validate_request(&request).is_ok());
|
||||
assert!(validate_current_request(&cultcache_rs::CultCache::new(), &request).is_err());
|
||||
|
||||
let mut substituted = request.clone();
|
||||
substituted.model_source_documents[0].payload_msgpack = vec![2];
|
||||
assert!(validate_request(&substituted).is_err());
|
||||
|
||||
let mut malformed = request;
|
||||
malformed.requested_at = "not-a-time".into();
|
||||
assert!(validate_request(&malformed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_requires_exact_request_model_receipt_and_proposal_only_terminal_authority() {
|
||||
let request = request();
|
||||
assert!(validate_result(&request, &result(&request)).is_ok());
|
||||
for mutation in 0..6 {
|
||||
let mut substituted = result(&request);
|
||||
match mutation {
|
||||
0 => substituted.request_id = "request-stale".into(),
|
||||
1 => substituted.model_projection_digest = format!("sha256:{}", "0".repeat(64)),
|
||||
2 => substituted.model_source_documents.clear(),
|
||||
3 => substituted.runtime_id = "runtime-substituted".into(),
|
||||
4 => substituted.proposal_only = false,
|
||||
_ => substituted.terminal = false,
|
||||
}
|
||||
assert!(validate_result(&request, &substituted).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggestion_requires_an_option_but_hold_remains_non_actuating() {
|
||||
let request = request();
|
||||
let mut proposed = result(&request);
|
||||
proposed.disposition = AdmittedModelDirectionDisposition::Suggest;
|
||||
assert!(validate_result(&request, &proposed).is_err());
|
||||
proposed.option_drafts.push(crate::ImaginationOptionDraft {
|
||||
title: "Explore typed route".into(),
|
||||
summary: "Ask Modeling to assess a bounded proposal.".into(),
|
||||
});
|
||||
assert!(validate_result(&request, &proposed).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyed_repo_model_without_thread_creates_direction_request() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store = temp.path().join("runtime.cc");
|
||||
crate::initialize_runtime_spine(
|
||||
&store,
|
||||
crate::RuntimeSpineInitOptions {
|
||||
runtime_id: "runtime-cold".into(),
|
||||
display_name: "Cold runtime".into(),
|
||||
created_at: "2026-07-18T00:00:00Z".into(),
|
||||
},
|
||||
)?;
|
||||
let mut cache = crate::runtime_spine_cache(&store)?;
|
||||
cache.put(
|
||||
crate::REPO_MODEL_IDENTITY_KEY,
|
||||
&crate::EpiphanyRepoModelIdentityDocument {
|
||||
schema_epoch: crate::REPO_MODEL_SCHEMA_EPOCH.into(),
|
||||
graph_id: "graph-cold".into(),
|
||||
runtime_id: "runtime-cold".into(),
|
||||
swarm_id: "swarm-cold".into(),
|
||||
workspace_id: "workspace-cold".into(),
|
||||
body_binding_sha256: "sha256:body-cold".into(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let request = commit_request(&store, "2026-07-18T00:01:00Z")?
|
||||
.ok_or_else(|| anyhow!("keyed RepoModel should create direction work"))?;
|
||||
assert_eq!(request.thread_id, request.request_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
1837
epiphany-core/src/agent_memory.rs
Normal file
1837
epiphany-core/src/agent_memory.rs
Normal file
File diff suppressed because it is too large
Load diff
1371
epiphany-core/src/agent_utterance_state.rs
Normal file
1371
epiphany-core/src/agent_utterance_state.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,902 +0,0 @@
|
|||
use anyhow::{Result, bail};
|
||||
use cultcache_rs::DatabaseEntry;
|
||||
use semver::{Version, VersionReq};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::RepositoryBodyObservationBasis;
|
||||
|
||||
pub const ATLAS_SURFACE_OFFER_SCHEMA: &str = "gamecult.model.surface_offer.v0";
|
||||
pub const ATLAS_DEPENDENCY_CLAIM_SCHEMA: &str = "gamecult.model.dependency_claim.v0";
|
||||
pub const ATLAS_DEPENDENCY_VERIFICATION_SCHEMA: &str = "gamecult.model.dependency_verification.v0";
|
||||
pub const ATLAS_DEPENDENCY_IMPACT_SCHEMA: &str = "epiphany.model.dependency_impact.v0";
|
||||
pub const ATLAS_PUBLICATION_SCHEMA: &str = "gamecult.model.atlas_publication.v0";
|
||||
pub const ATLAS_PROJECTION_SCHEMA: &str = "gamecult.model.entanglement_projection.v0";
|
||||
pub const ATLAS_TRUST_BINDING_SCHEMA: &str = "gamecult.model.atlas_publisher_trust.v0";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasRepositoryIdentity {
|
||||
pub swarm_id: String,
|
||||
pub workspace_id: String,
|
||||
pub repository_uri: String,
|
||||
}
|
||||
|
||||
impl AtlasRepositoryIdentity {
|
||||
pub fn new(swarm_id: impl Into<String>, workspace_id: impl Into<String>) -> Result<Self> {
|
||||
let swarm_id = swarm_id.into();
|
||||
let workspace_id = workspace_id.into();
|
||||
validate_identifier(&swarm_id, "Atlas swarm id")?;
|
||||
validate_identifier(&workspace_id, "Atlas workspace id")?;
|
||||
Ok(Self {
|
||||
repository_uri: format!("gamecult://swarm/{swarm_id}/workspace/{workspace_id}"),
|
||||
swarm_id,
|
||||
workspace_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
validate_identifier(&self.swarm_id, "Atlas swarm id")?;
|
||||
validate_identifier(&self.workspace_id, "Atlas workspace id")?;
|
||||
if self.repository_uri
|
||||
!= format!(
|
||||
"gamecult://swarm/{}/workspace/{}",
|
||||
self.swarm_id, self.workspace_id
|
||||
)
|
||||
{
|
||||
bail!("Atlas repository URI is not the canonical swarm/workspace identity")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AtlasEntanglementKind {
|
||||
Build,
|
||||
Runtime,
|
||||
Deployment,
|
||||
SchemaProtocol,
|
||||
DataState,
|
||||
InfrastructureControl,
|
||||
Governance,
|
||||
LorePersona,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AtlasCriticality {
|
||||
Blocking,
|
||||
Degrading,
|
||||
Informational,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "version_scheme")]
|
||||
pub enum AtlasContractDescriptor {
|
||||
Semver {
|
||||
contract_id: String,
|
||||
version: Version,
|
||||
},
|
||||
ExactSchema {
|
||||
contract_id: String,
|
||||
schema_id: String,
|
||||
},
|
||||
ExactDigest {
|
||||
contract_id: String,
|
||||
sha256: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl AtlasContractDescriptor {
|
||||
pub fn contract_id(&self) -> &str {
|
||||
match self {
|
||||
Self::Semver { contract_id, .. }
|
||||
| Self::ExactSchema { contract_id, .. }
|
||||
| Self::ExactDigest { contract_id, .. } => contract_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
validate_identifier(self.contract_id(), "Atlas contract id")?;
|
||||
match self {
|
||||
Self::Semver { .. } => Ok(()),
|
||||
Self::ExactSchema { schema_id, .. } => {
|
||||
validate_identifier(schema_id, "Atlas exact schema id")
|
||||
}
|
||||
Self::ExactDigest { sha256, .. } => {
|
||||
validate_sha256(sha256, "Atlas exact contract digest")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "version_scheme")]
|
||||
pub enum AtlasContractRequirement {
|
||||
Semver {
|
||||
contract_id: String,
|
||||
requirement: VersionReq,
|
||||
},
|
||||
ExactSchema {
|
||||
contract_id: String,
|
||||
schema_id: String,
|
||||
},
|
||||
ExactDigest {
|
||||
contract_id: String,
|
||||
sha256: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl AtlasContractRequirement {
|
||||
pub fn contract_id(&self) -> &str {
|
||||
match self {
|
||||
Self::Semver { contract_id, .. }
|
||||
| Self::ExactSchema { contract_id, .. }
|
||||
| Self::ExactDigest { contract_id, .. } => contract_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
validate_identifier(self.contract_id(), "Atlas required contract id")?;
|
||||
match self {
|
||||
Self::Semver { .. } => Ok(()),
|
||||
Self::ExactSchema { schema_id, .. } => {
|
||||
validate_identifier(schema_id, "Atlas required exact schema id")
|
||||
}
|
||||
Self::ExactDigest { sha256, .. } => {
|
||||
validate_sha256(sha256, "Atlas required exact contract digest")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "state")]
|
||||
pub enum AtlasOfferLifecycle {
|
||||
Active,
|
||||
Deprecated {
|
||||
replacement_surface_id: Option<Uuid>,
|
||||
},
|
||||
Withdrawn,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasBodyEvidenceRef {
|
||||
pub path: String,
|
||||
pub raw_sha256: String,
|
||||
}
|
||||
|
||||
impl AtlasBodyEvidenceRef {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.path.is_empty()
|
||||
|| self.path.len() > 1024
|
||||
|| self.path.starts_with('/')
|
||||
|| self.path.contains('\\')
|
||||
|| self
|
||||
.path
|
||||
.split('/')
|
||||
.any(|segment| segment.is_empty() || segment == "." || segment == "..")
|
||||
{
|
||||
bail!("Atlas Body evidence path is not a canonical portable relative path")
|
||||
}
|
||||
validate_repository_body_sha256(&self.raw_sha256, "Atlas Body evidence raw content digest")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "gamecult.model.surface_offer.v0",
|
||||
schema = "gamecult.model.surface_offer.v0"
|
||||
)]
|
||||
pub struct AtlasSurfaceOffer {
|
||||
#[cultcache(key = 0)]
|
||||
pub schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub provider: AtlasRepositoryIdentity,
|
||||
#[cultcache(key = 2)]
|
||||
pub surface_id: Uuid,
|
||||
#[cultcache(key = 3)]
|
||||
pub contract: AtlasContractDescriptor,
|
||||
#[cultcache(key = 4)]
|
||||
pub lifecycle: AtlasOfferLifecycle,
|
||||
#[cultcache(key = 5)]
|
||||
pub label: String,
|
||||
#[cultcache(key = 6)]
|
||||
pub body_evidence: Vec<AtlasBodyEvidenceRef>,
|
||||
}
|
||||
|
||||
impl AtlasSurfaceOffer {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
require_schema(&self.schema_version, ATLAS_SURFACE_OFFER_SCHEMA)?;
|
||||
self.provider.validate()?;
|
||||
if self.surface_id.is_nil() {
|
||||
bail!("Atlas surface id must be an opaque non-nil UUID")
|
||||
}
|
||||
self.contract.validate()?;
|
||||
validate_label_and_body_evidence(&self.label, &self.body_evidence, "Atlas surface offer")?;
|
||||
if matches!(
|
||||
self.lifecycle,
|
||||
AtlasOfferLifecycle::Deprecated {
|
||||
replacement_surface_id: Some(replacement)
|
||||
} if replacement == self.surface_id
|
||||
) {
|
||||
bail!("Atlas deprecated offer cannot replace itself")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "resolution")]
|
||||
pub enum AtlasDependencyTarget {
|
||||
Exact {
|
||||
provider: AtlasRepositoryIdentity,
|
||||
surface_id: Uuid,
|
||||
requirement: AtlasContractRequirement,
|
||||
},
|
||||
Unresolved {
|
||||
requirement: AtlasContractRequirement,
|
||||
},
|
||||
}
|
||||
|
||||
impl AtlasDependencyTarget {
|
||||
pub fn requirement(&self) -> &AtlasContractRequirement {
|
||||
match self {
|
||||
Self::Exact { requirement, .. } | Self::Unresolved { requirement } => requirement,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
match self {
|
||||
Self::Exact {
|
||||
provider,
|
||||
surface_id,
|
||||
requirement,
|
||||
} => {
|
||||
provider.validate()?;
|
||||
if surface_id.is_nil() {
|
||||
bail!("Atlas exact dependency target surface must be a non-nil UUID")
|
||||
}
|
||||
requirement.validate()
|
||||
}
|
||||
Self::Unresolved { requirement } => requirement.validate(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AtlasFailureSemantics {
|
||||
FailClosed,
|
||||
Degrade,
|
||||
LastKnownSafe,
|
||||
HumanDecision,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AtlasClaimLifecycle {
|
||||
Active,
|
||||
Retired,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "scope")]
|
||||
pub enum AtlasImpactScope {
|
||||
WholeRepository,
|
||||
LocalSurfaces { surface_ids: Vec<Uuid> },
|
||||
}
|
||||
|
||||
impl AtlasImpactScope {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if let Self::LocalSurfaces { surface_ids } = self {
|
||||
if surface_ids.is_empty()
|
||||
|| surface_ids.iter().any(Uuid::is_nil)
|
||||
|| !strictly_sorted_unique(surface_ids)
|
||||
{
|
||||
bail!("Atlas local impact scope must contain sorted unique non-nil surface UUIDs")
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "gamecult.model.dependency_claim.v0",
|
||||
schema = "gamecult.model.dependency_claim.v0"
|
||||
)]
|
||||
pub struct AtlasDependencyClaim {
|
||||
#[cultcache(key = 0)]
|
||||
pub schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub consumer: AtlasRepositoryIdentity,
|
||||
#[cultcache(key = 2)]
|
||||
pub claim_id: Uuid,
|
||||
#[cultcache(key = 3)]
|
||||
pub target: AtlasDependencyTarget,
|
||||
#[cultcache(key = 4)]
|
||||
pub entanglement_kind: AtlasEntanglementKind,
|
||||
#[cultcache(key = 5)]
|
||||
pub failure_semantics: AtlasFailureSemantics,
|
||||
#[cultcache(key = 6)]
|
||||
pub impact_scope: AtlasImpactScope,
|
||||
#[cultcache(key = 7)]
|
||||
pub lifecycle: AtlasClaimLifecycle,
|
||||
#[cultcache(key = 8)]
|
||||
pub label: String,
|
||||
#[cultcache(key = 9)]
|
||||
pub body_evidence: Vec<AtlasBodyEvidenceRef>,
|
||||
}
|
||||
|
||||
impl AtlasDependencyClaim {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
require_schema(&self.schema_version, ATLAS_DEPENDENCY_CLAIM_SCHEMA)?;
|
||||
self.consumer.validate()?;
|
||||
if self.claim_id.is_nil() {
|
||||
bail!("Atlas dependency claim id must be an opaque non-nil UUID")
|
||||
}
|
||||
self.target.validate()?;
|
||||
self.impact_scope.validate()?;
|
||||
validate_label_and_body_evidence(
|
||||
&self.label,
|
||||
&self.body_evidence,
|
||||
"Atlas dependency claim",
|
||||
)?;
|
||||
if let AtlasDependencyTarget::Exact { provider, .. } = &self.target {
|
||||
if provider == &self.consumer {
|
||||
bail!("Atlas dependency claim cannot target its owning repository")
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
self.entanglement_kind,
|
||||
AtlasEntanglementKind::Build
|
||||
| AtlasEntanglementKind::Deployment
|
||||
| AtlasEntanglementKind::InfrastructureControl
|
||||
) && self.failure_semantics != AtlasFailureSemantics::FailClosed
|
||||
{
|
||||
bail!("build, deployment, and infrastructure/control dependencies must fail closed")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AtlasVerificationVerdict {
|
||||
Passed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "gamecult.model.dependency_verification.v0",
|
||||
schema = "gamecult.model.dependency_verification.v0"
|
||||
)]
|
||||
pub struct AtlasDependencyVerification {
|
||||
#[cultcache(key = 0)]
|
||||
pub schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub consumer: AtlasRepositoryIdentity,
|
||||
#[cultcache(key = 2)]
|
||||
pub claim_id: Uuid,
|
||||
#[cultcache(key = 3)]
|
||||
pub claim_publication_id: String,
|
||||
#[cultcache(key = 4)]
|
||||
pub offer_publication_id: String,
|
||||
#[cultcache(key = 5)]
|
||||
pub exact_contract: AtlasContractDescriptor,
|
||||
#[cultcache(key = 6)]
|
||||
pub verdict: AtlasVerificationVerdict,
|
||||
#[cultcache(key = 7)]
|
||||
pub evidence_sha256: String,
|
||||
}
|
||||
|
||||
impl AtlasDependencyVerification {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
require_schema(&self.schema_version, ATLAS_DEPENDENCY_VERIFICATION_SCHEMA)?;
|
||||
self.consumer.validate()?;
|
||||
if self.claim_id.is_nil() {
|
||||
bail!("Atlas verification claim id must be non-nil")
|
||||
}
|
||||
validate_sha256(
|
||||
&self.claim_publication_id,
|
||||
"Atlas verification claim publication id",
|
||||
)?;
|
||||
validate_sha256(
|
||||
&self.offer_publication_id,
|
||||
"Atlas verification offer publication id",
|
||||
)?;
|
||||
self.exact_contract.validate()?;
|
||||
validate_sha256(&self.evidence_sha256, "Atlas verification evidence digest")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "epiphany.model.dependency_impact.v0",
|
||||
schema = "epiphany.model.dependency_impact.v0"
|
||||
)]
|
||||
pub struct AtlasDependencyImpact {
|
||||
#[cultcache(key = 0)]
|
||||
pub schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub impact_id: Uuid,
|
||||
#[cultcache(key = 2)]
|
||||
pub consumer: AtlasRepositoryIdentity,
|
||||
#[cultcache(key = 3)]
|
||||
pub claim_id: Uuid,
|
||||
#[cultcache(key = 4)]
|
||||
pub claim_source_payload_sha256: String,
|
||||
#[cultcache(key = 5)]
|
||||
pub projection_sha256: String,
|
||||
#[cultcache(key = 6)]
|
||||
pub source_publication_ids: Vec<String>,
|
||||
#[cultcache(key = 7)]
|
||||
pub criticality: AtlasCriticality,
|
||||
}
|
||||
|
||||
impl AtlasDependencyImpact {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
require_schema(&self.schema_version, ATLAS_DEPENDENCY_IMPACT_SCHEMA)?;
|
||||
self.consumer.validate()?;
|
||||
if self.impact_id.is_nil() || self.claim_id.is_nil() {
|
||||
bail!("Atlas dependency impact and claim ids must be non-nil")
|
||||
}
|
||||
validate_sha256(
|
||||
&self.claim_source_payload_sha256,
|
||||
"Atlas impact local claim source digest",
|
||||
)?;
|
||||
validate_sha256(
|
||||
&self.projection_sha256,
|
||||
"Atlas impact source projection digest",
|
||||
)?;
|
||||
validate_sorted_unique_sha256(
|
||||
&self.source_publication_ids,
|
||||
"Atlas impact source publication ids",
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasMindSourceVersion {
|
||||
pub store_id: String,
|
||||
pub document_type: String,
|
||||
pub document_key: String,
|
||||
pub schema_id: Option<String>,
|
||||
pub payload_sha256: String,
|
||||
}
|
||||
|
||||
impl AtlasMindSourceVersion {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
validate_identifier(&self.store_id, "Atlas source store id")?;
|
||||
validate_identifier(&self.document_type, "Atlas source document type")?;
|
||||
validate_identifier(&self.document_key, "Atlas source document key")?;
|
||||
if let Some(schema_id) = &self.schema_id {
|
||||
validate_identifier(schema_id, "Atlas source schema id")?;
|
||||
}
|
||||
validate_sha256(&self.payload_sha256, "Atlas source payload digest")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasMindCommitBinding {
|
||||
pub receipt_id: String,
|
||||
pub receipt_sha256: String,
|
||||
pub invariant_owner: String,
|
||||
pub source: AtlasMindSourceVersion,
|
||||
}
|
||||
|
||||
impl AtlasMindCommitBinding {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
validate_identifier(&self.receipt_id, "Atlas Mind receipt id")?;
|
||||
validate_sha256(&self.receipt_sha256, "Atlas Mind receipt digest")?;
|
||||
validate_identifier(&self.invariant_owner, "Atlas Mind invariant owner")?;
|
||||
self.source.validate()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AtlasPublisherState {
|
||||
Serving,
|
||||
Draining,
|
||||
Retired,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasPublicationWatermark {
|
||||
pub source_schema: String,
|
||||
pub source_key: String,
|
||||
pub source_payload_sha256: String,
|
||||
pub publication_sequence: u64,
|
||||
}
|
||||
|
||||
impl AtlasPublicationWatermark {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
validate_identifier(&self.source_schema, "Atlas watermark source schema")?;
|
||||
validate_identifier(&self.source_key, "Atlas watermark source key")?;
|
||||
validate_sha256(
|
||||
&self.source_payload_sha256,
|
||||
"Atlas watermark source payload digest",
|
||||
)?;
|
||||
if self.publication_sequence == 0 {
|
||||
bail!("Atlas watermark publication sequence must be positive")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasPublisherStatus {
|
||||
pub publisher: AtlasRepositoryIdentity,
|
||||
pub runtime_id: String,
|
||||
pub runtime_incarnation_id: String,
|
||||
pub heartbeat_sequence: u64,
|
||||
pub heartbeat_at_unix_ms: u64,
|
||||
pub state: AtlasPublisherState,
|
||||
pub watermarks: Vec<AtlasPublicationWatermark>,
|
||||
}
|
||||
|
||||
impl AtlasPublisherStatus {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.publisher.validate()?;
|
||||
validate_identifier(&self.runtime_id, "Atlas publisher runtime id")?;
|
||||
validate_identifier(
|
||||
&self.runtime_incarnation_id,
|
||||
"Atlas publisher runtime incarnation id",
|
||||
)?;
|
||||
if self.heartbeat_sequence == 0
|
||||
|| !self.watermarks.windows(2).all(|pair| {
|
||||
(&pair[0].source_schema, &pair[0].source_key)
|
||||
< (&pair[1].source_schema, &pair[1].source_key)
|
||||
})
|
||||
{
|
||||
bail!("Atlas publisher heartbeat sequence or watermark ordering is invalid")
|
||||
}
|
||||
for watermark in &self.watermarks {
|
||||
watermark.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "payload", content = "value")]
|
||||
pub enum AtlasPublicationPayload {
|
||||
PublisherStatus(AtlasPublisherStatus),
|
||||
SurfaceOffer(AtlasSurfaceOffer),
|
||||
DependencyClaim(AtlasDependencyClaim),
|
||||
DependencyVerification(AtlasDependencyVerification),
|
||||
}
|
||||
|
||||
impl AtlasPublicationPayload {
|
||||
pub fn schema(&self) -> &'static str {
|
||||
match self {
|
||||
Self::PublisherStatus(_) => ATLAS_PUBLICATION_SCHEMA,
|
||||
Self::SurfaceOffer(_) => ATLAS_SURFACE_OFFER_SCHEMA,
|
||||
Self::DependencyClaim(_) => ATLAS_DEPENDENCY_CLAIM_SCHEMA,
|
||||
Self::DependencyVerification(_) => ATLAS_DEPENDENCY_VERIFICATION_SCHEMA,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self) -> String {
|
||||
match self {
|
||||
Self::PublisherStatus(status) => {
|
||||
format!("publisher-status:{}", status.publisher.repository_uri)
|
||||
}
|
||||
Self::SurfaceOffer(offer) => offer.surface_id.to_string(),
|
||||
Self::DependencyClaim(claim) => claim.claim_id.to_string(),
|
||||
Self::DependencyVerification(verification) => verification.claim_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn owner(&self) -> &AtlasRepositoryIdentity {
|
||||
match self {
|
||||
Self::PublisherStatus(status) => &status.publisher,
|
||||
Self::SurfaceOffer(offer) => &offer.provider,
|
||||
Self::DependencyClaim(claim) => &claim.consumer,
|
||||
Self::DependencyVerification(verification) => &verification.consumer,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn requires_mind_binding(&self) -> bool {
|
||||
!matches!(self, Self::PublisherStatus(_))
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
match self {
|
||||
Self::PublisherStatus(status) => status.validate(),
|
||||
Self::SurfaceOffer(offer) => offer.validate(),
|
||||
Self::DependencyClaim(claim) => claim.validate(),
|
||||
Self::DependencyVerification(verification) => verification.validate(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AtlasPublicationVisibility {
|
||||
GamecultLocal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasPublicationStatement {
|
||||
pub schema_version: String,
|
||||
pub publication_id: String,
|
||||
pub publisher: AtlasRepositoryIdentity,
|
||||
pub publisher_identity_id: String,
|
||||
pub publication_sequence: u64,
|
||||
pub runtime_id: String,
|
||||
pub runtime_incarnation_id: String,
|
||||
pub body_basis: RepositoryBodyObservationBasis,
|
||||
pub verse_id: String,
|
||||
pub visibility: AtlasPublicationVisibility,
|
||||
pub source: Option<AtlasMindSourceVersion>,
|
||||
pub mind_commit: Option<AtlasMindCommitBinding>,
|
||||
pub canonical_payload_msgpack_sha256: String,
|
||||
pub published_at_unix_ms: u64,
|
||||
pub payload: AtlasPublicationPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "gamecult.model.atlas_publication.v0",
|
||||
schema = "gamecult.model.atlas_publication.v0"
|
||||
)]
|
||||
pub struct AtlasPublicationEnvelope {
|
||||
#[cultcache(key = 0)]
|
||||
pub statement: AtlasPublicationStatement,
|
||||
#[cultcache(key = 1)]
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasProjectionAudience {
|
||||
pub audience_id: String,
|
||||
pub gamecult_local: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AtlasPublicationFreshness {
|
||||
Current,
|
||||
LastKnownStale,
|
||||
Retired,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasFreshnessPolicy {
|
||||
pub publisher_status_maximum_age_ms: u64,
|
||||
pub maximum_future_skew_ms: u64,
|
||||
}
|
||||
|
||||
impl AtlasFreshnessPolicy {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.publisher_status_maximum_age_ms == 0 {
|
||||
bail!("Atlas publisher status maximum age must be positive")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AtlasCompatibility {
|
||||
Exact,
|
||||
Compatible,
|
||||
Unresolved,
|
||||
OfferMissing,
|
||||
OfferWithdrawn,
|
||||
ContractIdMismatch,
|
||||
VersionSchemeMismatch,
|
||||
VersionMismatch,
|
||||
ClaimRetired,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AtlasVerificationState {
|
||||
Passed,
|
||||
Failed,
|
||||
Missing,
|
||||
LastKnownStale,
|
||||
ExactEdgeMismatch,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasProjectedEntanglement {
|
||||
pub claim_id: Uuid,
|
||||
pub claim_label: String,
|
||||
pub claim_requirement: AtlasContractRequirement,
|
||||
pub claim_body_evidence: Vec<AtlasBodyEvidenceRef>,
|
||||
pub consumer: AtlasRepositoryIdentity,
|
||||
pub provider: Option<AtlasRepositoryIdentity>,
|
||||
pub surface_id: Option<Uuid>,
|
||||
pub offer_label: Option<String>,
|
||||
pub offer_contract: Option<AtlasContractDescriptor>,
|
||||
pub offer_lifecycle: Option<AtlasOfferLifecycle>,
|
||||
pub offer_body_evidence: Vec<AtlasBodyEvidenceRef>,
|
||||
pub entanglement_kind: AtlasEntanglementKind,
|
||||
pub failure_semantics: AtlasFailureSemantics,
|
||||
pub impact_scope: AtlasImpactScope,
|
||||
pub claim_freshness: AtlasPublicationFreshness,
|
||||
pub offer_freshness: Option<AtlasPublicationFreshness>,
|
||||
pub compatibility: AtlasCompatibility,
|
||||
pub verification: AtlasVerificationState,
|
||||
pub claim_publication_id: String,
|
||||
pub offer_publication_id: Option<String>,
|
||||
pub verification_publication_id: Option<String>,
|
||||
pub verification_evidence_sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AtlasCycleClass {
|
||||
ForbiddenBuild,
|
||||
ForbiddenDeployment,
|
||||
ForbiddenInfrastructureControl,
|
||||
ReviewRequired,
|
||||
Informational,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasProjectedCycle {
|
||||
pub repositories: Vec<AtlasRepositoryIdentity>,
|
||||
pub entanglement_kinds: Vec<AtlasEntanglementKind>,
|
||||
pub classification: AtlasCycleClass,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasAffectedRepository {
|
||||
pub repository: AtlasRepositoryIdentity,
|
||||
pub minimum_hops: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasBlastRadius {
|
||||
pub source: AtlasRepositoryIdentity,
|
||||
pub source_surface_id: Uuid,
|
||||
pub affected: Vec<AtlasAffectedRepository>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasPublisherProjectionStatus {
|
||||
pub publisher: AtlasRepositoryIdentity,
|
||||
pub runtime_id: String,
|
||||
pub runtime_incarnation_id: String,
|
||||
pub heartbeat_sequence: u64,
|
||||
pub heartbeat_at_unix_ms: u64,
|
||||
pub freshness: AtlasPublicationFreshness,
|
||||
pub watermarks: Vec<AtlasPublicationWatermark>,
|
||||
pub status_publication_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "gamecult.model.entanglement_projection.v0",
|
||||
schema = "gamecult.model.entanglement_projection.v0"
|
||||
)]
|
||||
pub struct AtlasEntanglementProjection {
|
||||
#[cultcache(key = 0)]
|
||||
pub schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub audience_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
pub evaluated_at_unix_ms: u64,
|
||||
#[cultcache(key = 3)]
|
||||
pub source_publication_ids: Vec<String>,
|
||||
#[cultcache(key = 4)]
|
||||
pub publisher_status: Vec<AtlasPublisherProjectionStatus>,
|
||||
#[cultcache(key = 5)]
|
||||
pub entanglements: Vec<AtlasProjectedEntanglement>,
|
||||
#[cultcache(key = 6)]
|
||||
pub cycles: Vec<AtlasProjectedCycle>,
|
||||
#[cultcache(key = 7)]
|
||||
pub blast_radii: Vec<AtlasBlastRadius>,
|
||||
#[cultcache(key = 8)]
|
||||
pub projection_sha256: String,
|
||||
}
|
||||
|
||||
pub(crate) fn require_schema(actual: &str, expected: &str) -> Result<()> {
|
||||
if actual != expected {
|
||||
bail!("Atlas document schema does not match its typed contract")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_identifier(value: &str, field: &str) -> Result<()> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 320
|
||||
|| !value.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'/' | b'@')
|
||||
})
|
||||
{
|
||||
bail!("{field} is not a bounded canonical identifier")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_sha256(value: &str, field: &str) -> Result<()> {
|
||||
if value.len() != 71
|
||||
|| !value.starts_with("sha256-")
|
||||
|| !value[7..]
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||||
{
|
||||
bail!("{field} is not a canonical sha256 digest")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_repository_body_sha256(value: &str, field: &str) -> Result<()> {
|
||||
if value.len() != 64
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||||
{
|
||||
bail!("{field} is not a canonical repository Body SHA-256 digest")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_sorted_unique_sha256(
|
||||
values: &[String],
|
||||
field: &str,
|
||||
require_non_empty: bool,
|
||||
) -> Result<()> {
|
||||
if (require_non_empty && values.is_empty()) || !strictly_sorted_unique(values) {
|
||||
bail!("{field} must be a non-empty strictly sorted set")
|
||||
}
|
||||
for value in values {
|
||||
validate_sha256(value, field)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn strictly_sorted_unique<T: Ord>(values: &[T]) -> bool {
|
||||
values.windows(2).all(|pair| pair[0] < pair[1])
|
||||
}
|
||||
|
||||
fn validate_label_and_body_evidence(
|
||||
label: &str,
|
||||
evidence: &[AtlasBodyEvidenceRef],
|
||||
field: &str,
|
||||
) -> Result<()> {
|
||||
if label.trim().is_empty() || label.len() > 320 {
|
||||
bail!("{field} label must be non-empty and bounded")
|
||||
}
|
||||
if evidence.is_empty() || !strictly_sorted_unique(evidence) {
|
||||
bail!("{field} Body evidence must be a non-empty strictly sorted set")
|
||||
}
|
||||
for source in evidence {
|
||||
source.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn canonical_entanglement_kinds(
|
||||
kinds: impl IntoIterator<Item = AtlasEntanglementKind>,
|
||||
) -> Vec<AtlasEntanglementKind> {
|
||||
kinds
|
||||
.into_iter()
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,280 +0,0 @@
|
|||
use anyhow::{Context, Result, bail};
|
||||
use cultnet_rs::{
|
||||
ServiceIdentityProfile, ServiceIdentitySignature, ServiceIdentitySigner,
|
||||
ServiceIdentityTrustAnchor, ServiceSignaturePurpose, derive_service_identity_id,
|
||||
verify_service_identity_signature,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::contracts::*;
|
||||
|
||||
pub const ATLAS_MAX_PUBLICATION_BYTES: usize = 1024 * 1024;
|
||||
|
||||
pub enum AtlasRepositorySigningIdentity {}
|
||||
|
||||
impl ServiceIdentityProfile for AtlasRepositorySigningIdentity {
|
||||
const PRIVATE_TYPE: &'static str = "gamecult.model.atlas_repository_identity.private.v0";
|
||||
const PRIVATE_SCHEMA: &'static str = "gamecult.model.atlas_repository_identity.private.v0";
|
||||
const PRIVATE_KEY: &'static str = "atlas-repository-signing-identity";
|
||||
const TRUST_ANCHOR_TYPE: &'static str =
|
||||
"gamecult.model.atlas_repository_identity.trust_anchor.v0";
|
||||
const TRUST_ANCHOR_SCHEMA: &'static str =
|
||||
"gamecult.model.atlas_repository_identity.trust_anchor.v0";
|
||||
const TRUST_ANCHOR_KEY: &'static str = "atlas-repository-signing-identity-public";
|
||||
const ID_DOMAIN: &'static [u8] = b"gamecult.model.atlas-repository-identity.id.v0\0";
|
||||
const SIGNATURE_DOMAIN: &'static [u8] = b"gamecult.model.atlas-publication.signature.v0\0";
|
||||
const PROTECTOR_CONTEXT: &'static str = "gamecult-model-atlas-repository-identity-v0";
|
||||
}
|
||||
|
||||
pub struct AtlasPublicationPurpose;
|
||||
|
||||
impl ServiceSignaturePurpose<AtlasRepositorySigningIdentity> for AtlasPublicationPurpose {
|
||||
const PURPOSE: &'static [u8] = b"gamecult.model.atlas-publication.v0";
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasPublisherTrustBinding {
|
||||
pub schema_version: String,
|
||||
pub publisher: AtlasRepositoryIdentity,
|
||||
pub signer_identity_id: String,
|
||||
pub trusted_from_unix_ms: u64,
|
||||
pub expires_at_unix_ms: Option<u64>,
|
||||
pub revoked: bool,
|
||||
pub trust_anchor: ServiceIdentityTrustAnchor,
|
||||
}
|
||||
|
||||
impl AtlasPublisherTrustBinding {
|
||||
pub fn validate_at(&self, now_unix_ms: u64) -> Result<()> {
|
||||
require_schema(&self.schema_version, ATLAS_TRUST_BINDING_SCHEMA)?;
|
||||
self.publisher.validate()?;
|
||||
validate_identifier(&self.signer_identity_id, "Atlas trusted signer identity id")?;
|
||||
if self.trust_anchor.schema_version != AtlasRepositorySigningIdentity::TRUST_ANCHOR_SCHEMA
|
||||
|| self.trust_anchor.identity_id != self.signer_identity_id
|
||||
|| derive_service_identity_id::<AtlasRepositorySigningIdentity>(
|
||||
&self.trust_anchor.public_key,
|
||||
)? != self.signer_identity_id
|
||||
|| self.trusted_from_unix_ms > now_unix_ms
|
||||
|| self
|
||||
.expires_at_unix_ms
|
||||
.is_some_and(|expires| expires <= now_unix_ms)
|
||||
|| self.revoked
|
||||
{
|
||||
bail!("Atlas publisher trust binding is invalid, inactive, expired, or revoked")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn sign_atlas_publication(
|
||||
signer: &ServiceIdentitySigner<AtlasRepositorySigningIdentity>,
|
||||
publisher: AtlasRepositoryIdentity,
|
||||
publication_sequence: u64,
|
||||
runtime_id: String,
|
||||
runtime_incarnation_id: String,
|
||||
body_basis: crate::RepositoryBodyObservationBasis,
|
||||
verse_id: String,
|
||||
source: Option<AtlasMindSourceVersion>,
|
||||
mind_commit: Option<AtlasMindCommitBinding>,
|
||||
published_at_unix_ms: u64,
|
||||
payload: AtlasPublicationPayload,
|
||||
) -> Result<AtlasPublicationEnvelope> {
|
||||
let canonical_payload_msgpack_sha256 = sha256(&canonical_atlas_payload_msgpack(&payload)?);
|
||||
let mut statement = AtlasPublicationStatement {
|
||||
schema_version: ATLAS_PUBLICATION_SCHEMA.into(),
|
||||
publication_id: String::new(),
|
||||
publisher,
|
||||
publisher_identity_id: signer.entry().identity_id.clone(),
|
||||
publication_sequence,
|
||||
runtime_id,
|
||||
runtime_incarnation_id,
|
||||
body_basis,
|
||||
verse_id,
|
||||
visibility: AtlasPublicationVisibility::GamecultLocal,
|
||||
source,
|
||||
mind_commit,
|
||||
canonical_payload_msgpack_sha256,
|
||||
published_at_unix_ms,
|
||||
payload,
|
||||
};
|
||||
validate_statement_without_id(&statement)?;
|
||||
statement.publication_id = derive_publication_id(&statement)?;
|
||||
let signature = signer
|
||||
.sign::<AtlasPublicationPurpose>(&rmp_serde::to_vec_named(&statement)?)
|
||||
.signature;
|
||||
Ok(AtlasPublicationEnvelope {
|
||||
statement,
|
||||
signature,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn verify_atlas_publication(
|
||||
trust: &AtlasPublisherTrustBinding,
|
||||
publication: &AtlasPublicationEnvelope,
|
||||
now_unix_ms: u64,
|
||||
) -> Result<()> {
|
||||
if rmp_serde::to_vec(publication)?.len() > ATLAS_MAX_PUBLICATION_BYTES {
|
||||
bail!("Atlas publication exceeds the signed transport size limit")
|
||||
}
|
||||
trust.validate_at(now_unix_ms)?;
|
||||
let statement = &publication.statement;
|
||||
validate_atlas_publication_statement(statement)?;
|
||||
if trust.publisher != statement.publisher
|
||||
|| trust.signer_identity_id != statement.publisher_identity_id
|
||||
|| publication.signature.len() != 64
|
||||
{
|
||||
bail!("Atlas publication owner, signer, or signature shape is invalid")
|
||||
}
|
||||
verify_service_identity_signature::<AtlasRepositorySigningIdentity, AtlasPublicationPurpose>(
|
||||
&trust.trust_anchor,
|
||||
&rmp_serde::to_vec_named(statement)?,
|
||||
&ServiceIdentitySignature {
|
||||
identity_id: statement.publisher_identity_id.clone(),
|
||||
signature: publication.signature.clone(),
|
||||
},
|
||||
)
|
||||
.context("Atlas publication signature is invalid")
|
||||
}
|
||||
|
||||
pub fn validate_atlas_publication_statement(statement: &AtlasPublicationStatement) -> Result<()> {
|
||||
validate_sha256(&statement.publication_id, "Atlas publication id")?;
|
||||
validate_statement_without_id(statement)?;
|
||||
if statement.publication_id != derive_publication_id(statement)? {
|
||||
bail!("Atlas publication id does not match its canonical signed statement")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn canonical_atlas_payload_msgpack(payload: &AtlasPublicationPayload) -> Result<Vec<u8>> {
|
||||
match payload {
|
||||
AtlasPublicationPayload::PublisherStatus(status) => Ok(rmp_serde::to_vec_named(status)?),
|
||||
AtlasPublicationPayload::SurfaceOffer(offer) => Ok(rmp_serde::to_vec_named(offer)?),
|
||||
AtlasPublicationPayload::DependencyClaim(claim) => Ok(rmp_serde::to_vec_named(claim)?),
|
||||
AtlasPublicationPayload::DependencyVerification(verification) => {
|
||||
Ok(rmp_serde::to_vec_named(verification)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact bytes used by the local CultCache `DatabaseEntry` encoding. This is
|
||||
/// the source-version payload, distinct from the named canonical publication
|
||||
/// payload above.
|
||||
pub fn atlas_source_payload_msgpack(payload: &AtlasPublicationPayload) -> Result<Vec<u8>> {
|
||||
match payload {
|
||||
AtlasPublicationPayload::PublisherStatus(status) => Ok(rmp_serde::to_vec(status)?),
|
||||
AtlasPublicationPayload::SurfaceOffer(offer) => Ok(rmp_serde::to_vec(offer)?),
|
||||
AtlasPublicationPayload::DependencyClaim(claim) => Ok(rmp_serde::to_vec(claim)?),
|
||||
AtlasPublicationPayload::DependencyVerification(verification) => {
|
||||
Ok(rmp_serde::to_vec(verification)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_statement_without_id(statement: &AtlasPublicationStatement) -> Result<()> {
|
||||
require_schema(&statement.schema_version, ATLAS_PUBLICATION_SCHEMA)?;
|
||||
statement.publisher.validate()?;
|
||||
statement.payload.validate()?;
|
||||
validate_identifier(
|
||||
&statement.publisher_identity_id,
|
||||
"Atlas publication signer identity id",
|
||||
)?;
|
||||
validate_identifier(&statement.runtime_id, "Atlas publication runtime id")?;
|
||||
validate_identifier(
|
||||
&statement.runtime_incarnation_id,
|
||||
"Atlas publication runtime incarnation id",
|
||||
)?;
|
||||
validate_identifier(&statement.verse_id, "Atlas publication Verse id")?;
|
||||
if !statement.verse_id.starts_with("cultmesh://gamecult-local/") {
|
||||
bail!("Atlas publication is not bound to the GameCult-local Verse")
|
||||
}
|
||||
if statement.publication_sequence == 0
|
||||
|| statement.publisher != *statement.payload.owner()
|
||||
|| statement.body_basis.swarm_id != statement.publisher.swarm_id
|
||||
|| statement.body_basis.workspace_id != statement.publisher.workspace_id
|
||||
|| statement.body_basis.runtime_id != statement.runtime_id
|
||||
|| statement.body_basis.generation == 0
|
||||
{
|
||||
bail!(
|
||||
"Atlas publication owner, sequence, runtime, or repository Body basis is inconsistent"
|
||||
)
|
||||
}
|
||||
validate_identifier(
|
||||
&statement.body_basis.schema_version,
|
||||
"Atlas Body basis schema",
|
||||
)?;
|
||||
validate_identifier(
|
||||
&statement.body_basis.observation_id,
|
||||
"Atlas Body observation id",
|
||||
)?;
|
||||
validate_repository_body_sha256(
|
||||
&statement.body_basis.body_binding_sha256,
|
||||
"Atlas Body binding digest",
|
||||
)?;
|
||||
validate_repository_body_sha256(
|
||||
&statement.body_basis.manifest_root_sha256,
|
||||
"Atlas Body manifest digest",
|
||||
)?;
|
||||
|
||||
let canonical_payload_sha256 = sha256(&canonical_atlas_payload_msgpack(&statement.payload)?);
|
||||
let source_payload_sha256 = sha256(&atlas_source_payload_msgpack(&statement.payload)?);
|
||||
if canonical_payload_sha256 != statement.canonical_payload_msgpack_sha256 {
|
||||
bail!("Atlas canonical payload MessagePack digest is invalid")
|
||||
}
|
||||
match (
|
||||
statement.payload.requires_mind_binding(),
|
||||
&statement.source,
|
||||
&statement.mind_commit,
|
||||
) {
|
||||
(false, None, None) => {}
|
||||
(true, Some(source), Some(commit)) => {
|
||||
source.validate()?;
|
||||
commit.validate()?;
|
||||
if source != &commit.source
|
||||
|| source.document_type != statement.payload.schema()
|
||||
|| source.document_key != statement.payload.key()
|
||||
|| source.schema_id.as_deref() != Some(statement.payload.schema())
|
||||
|| source.payload_sha256 != source_payload_sha256
|
||||
{
|
||||
bail!(
|
||||
"Atlas publication source, Mind receipt binding, schema, key, or payload digest was substituted"
|
||||
)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
bail!(
|
||||
"Atlas Mind document publications require exact source/commit binding; publisher status forbids it"
|
||||
)
|
||||
}
|
||||
}
|
||||
if let AtlasPublicationPayload::PublisherStatus(status) = &statement.payload {
|
||||
if status.publisher != statement.publisher
|
||||
|| status.runtime_id != statement.runtime_id
|
||||
|| status.runtime_incarnation_id != statement.runtime_incarnation_id
|
||||
|| status.heartbeat_at_unix_ms != statement.published_at_unix_ms
|
||||
{
|
||||
bail!(
|
||||
"Atlas publisher status payload is not exact for its publication runtime and time"
|
||||
)
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn derive_publication_id(statement: &AtlasPublicationStatement) -> Result<String> {
|
||||
let mut basis = statement.clone();
|
||||
basis.publication_id.clear();
|
||||
let basis_msgpack = rmp_serde::to_vec_named(&basis)?;
|
||||
Ok(sha256(
|
||||
&[
|
||||
b"gamecult.model.atlas-publication.id.v0\0".as_slice(),
|
||||
basis_msgpack.as_slice(),
|
||||
]
|
||||
.concat(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn sha256(bytes: &[u8]) -> String {
|
||||
format!("sha256-{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
|
@ -1,902 +0,0 @@
|
|||
use anyhow::{Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::contracts::*;
|
||||
use super::identity::{atlas_source_payload_msgpack, sha256};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AtlasLocalClaimInput {
|
||||
pub claim: AtlasDependencyClaim,
|
||||
pub source_payload_sha256: String,
|
||||
}
|
||||
|
||||
impl AtlasLocalClaimInput {
|
||||
fn validate(&self, local_repository: &AtlasRepositoryIdentity) -> Result<()> {
|
||||
self.claim.validate()?;
|
||||
validate_sha256(
|
||||
&self.source_payload_sha256,
|
||||
"Atlas impact ingress local claim source digest",
|
||||
)?;
|
||||
let source_digest = sha256(&atlas_source_payload_msgpack(
|
||||
&AtlasPublicationPayload::DependencyClaim(self.claim.clone()),
|
||||
)?);
|
||||
if self.claim.consumer != *local_repository || source_digest != self.source_payload_sha256 {
|
||||
bail!("Atlas impact ingress claim is not the exact current local Mind document")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "reason")]
|
||||
pub enum AtlasImpactReason {
|
||||
MissingFromProjection,
|
||||
ProjectedClaimDiffers,
|
||||
ClaimNotCurrent {
|
||||
freshness: AtlasPublicationFreshness,
|
||||
},
|
||||
OfferNotCurrent {
|
||||
freshness: Option<AtlasPublicationFreshness>,
|
||||
},
|
||||
Compatibility {
|
||||
state: AtlasCompatibility,
|
||||
},
|
||||
Verification {
|
||||
state: AtlasVerificationState,
|
||||
},
|
||||
TransitiveBlastRadius {
|
||||
source_repository_uri: String,
|
||||
source_surface_id: Uuid,
|
||||
minimum_hops: u32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AtlasImpactLane {
|
||||
Modeling,
|
||||
Soul,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AtlasImpactProposalAuthority {
|
||||
LocalReviewOnly,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasLocalImpactProposal {
|
||||
pub proposal_id: Uuid,
|
||||
pub dedupe_key: String,
|
||||
pub lane: AtlasImpactLane,
|
||||
pub reason: AtlasImpactReason,
|
||||
pub authority: AtlasImpactProposalAuthority,
|
||||
pub impact: AtlasDependencyImpact,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AtlasImpactLaneState {
|
||||
pub lane: AtlasImpactLane,
|
||||
pub pending_proposal_id: Option<Uuid>,
|
||||
pub last_completed_at_unix_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AtlasImpactIngressPolicy {
|
||||
pub cooldown_after_completion_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AtlasImpactBrakeState {
|
||||
pub engaged: bool,
|
||||
pub brake_id: Option<String>,
|
||||
}
|
||||
|
||||
impl AtlasImpactBrakeState {
|
||||
fn validate(&self) -> Result<()> {
|
||||
match (self.engaged, &self.brake_id) {
|
||||
(true, Some(brake_id)) => validate_identifier(brake_id, "Atlas impact brake id"),
|
||||
(false, None) => Ok(()),
|
||||
_ => bail!("Atlas impact brake state is internally inconsistent"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "disposition")]
|
||||
pub enum AtlasImpactScheduleDisposition {
|
||||
Schedule {
|
||||
lane: AtlasImpactLane,
|
||||
},
|
||||
VisibleOnly,
|
||||
Deduplicated,
|
||||
HeldByBrake {
|
||||
brake_id: String,
|
||||
},
|
||||
HeldByPendingLane {
|
||||
lane: AtlasImpactLane,
|
||||
pending_proposal_id: Uuid,
|
||||
},
|
||||
HeldByCooldown {
|
||||
lane: AtlasImpactLane,
|
||||
retry_at_unix_ms: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasImpactSchedulingDecision {
|
||||
pub proposal_id: Uuid,
|
||||
pub claim_id: Uuid,
|
||||
pub criticality: AtlasCriticality,
|
||||
pub disposition: AtlasImpactScheduleDisposition,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AtlasImpactIngressResult {
|
||||
pub proposals: Vec<AtlasLocalImpactProposal>,
|
||||
pub scheduling_decisions: Vec<AtlasImpactSchedulingDecision>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn evaluate_local_atlas_impacts(
|
||||
local_repository: &AtlasRepositoryIdentity,
|
||||
projection: &AtlasEntanglementProjection,
|
||||
local_claims: &[AtlasLocalClaimInput],
|
||||
seen_dedupe_keys: &BTreeSet<String>,
|
||||
lane_states: &[AtlasImpactLaneState],
|
||||
brake: &AtlasImpactBrakeState,
|
||||
policy: &AtlasImpactIngressPolicy,
|
||||
now_unix_ms: u64,
|
||||
) -> Result<AtlasImpactIngressResult> {
|
||||
local_repository.validate()?;
|
||||
require_schema(&projection.schema_version, ATLAS_PROJECTION_SCHEMA)?;
|
||||
validate_sha256(
|
||||
&projection.projection_sha256,
|
||||
"Atlas impact source projection digest",
|
||||
)?;
|
||||
validate_sorted_unique_sha256(
|
||||
&projection.source_publication_ids,
|
||||
"Atlas impact source projection publication ids",
|
||||
true,
|
||||
)?;
|
||||
brake.validate()?;
|
||||
|
||||
let lanes = validated_lanes(lane_states)?;
|
||||
let mut claims = BTreeMap::<Uuid, &AtlasLocalClaimInput>::new();
|
||||
for claim in local_claims {
|
||||
claim.validate(local_repository)?;
|
||||
if claims.insert(claim.claim.claim_id, claim).is_some() {
|
||||
bail!("Atlas impact ingress received duplicate local claim ids")
|
||||
}
|
||||
}
|
||||
let mut projected = BTreeMap::<Uuid, &AtlasProjectedEntanglement>::new();
|
||||
for entanglement in projection
|
||||
.entanglements
|
||||
.iter()
|
||||
.filter(|entanglement| entanglement.consumer == *local_repository)
|
||||
{
|
||||
if projected
|
||||
.insert(entanglement.claim_id, entanglement)
|
||||
.is_some()
|
||||
{
|
||||
bail!("Atlas projection contains duplicate local entanglement ids")
|
||||
}
|
||||
}
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
for claim in claims.into_values() {
|
||||
if claim.claim.lifecycle == AtlasClaimLifecycle::Retired {
|
||||
continue;
|
||||
}
|
||||
let entanglement = projected.get(&claim.claim.claim_id).copied();
|
||||
let direct = impact_reason(&claim.claim, entanglement);
|
||||
let transitive = transitive_impact_reason(projection, &claim.claim);
|
||||
let selected = match &direct {
|
||||
Some((
|
||||
AtlasImpactReason::Verification {
|
||||
state: AtlasVerificationState::Missing,
|
||||
},
|
||||
_,
|
||||
)) if transitive.is_some() => transitive,
|
||||
_ => direct.or(transitive),
|
||||
};
|
||||
let Some((reason, lane)) = selected else {
|
||||
continue;
|
||||
};
|
||||
let criticality = criticality(&claim.claim, &reason);
|
||||
let source_publication_ids = impact_source_publications(projection, entanglement, &reason);
|
||||
validate_sorted_unique_sha256(
|
||||
&source_publication_ids,
|
||||
"Atlas local impact source publication ids",
|
||||
true,
|
||||
)?;
|
||||
let dedupe_key = sha256(&rmp_serde::to_vec_named(&(
|
||||
"epiphany.model.dependency-impact-dedupe.v0",
|
||||
claim.claim.claim_id,
|
||||
&claim.source_payload_sha256,
|
||||
&source_publication_ids,
|
||||
&reason,
|
||||
criticality,
|
||||
))?);
|
||||
let proposal_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, dedupe_key.as_bytes());
|
||||
let impact = AtlasDependencyImpact {
|
||||
schema_version: ATLAS_DEPENDENCY_IMPACT_SCHEMA.into(),
|
||||
impact_id: proposal_id,
|
||||
consumer: local_repository.clone(),
|
||||
claim_id: claim.claim.claim_id,
|
||||
claim_source_payload_sha256: claim.source_payload_sha256.clone(),
|
||||
projection_sha256: projection.projection_sha256.clone(),
|
||||
source_publication_ids,
|
||||
criticality,
|
||||
};
|
||||
impact.validate()?;
|
||||
candidates.push(AtlasLocalImpactProposal {
|
||||
proposal_id,
|
||||
dedupe_key,
|
||||
lane,
|
||||
reason,
|
||||
authority: AtlasImpactProposalAuthority::LocalReviewOnly,
|
||||
impact,
|
||||
});
|
||||
}
|
||||
candidates.sort_by(|left, right| {
|
||||
(left.impact.criticality, left.impact.claim_id, &left.reason).cmp(&(
|
||||
right.impact.criticality,
|
||||
right.impact.claim_id,
|
||||
&right.reason,
|
||||
))
|
||||
});
|
||||
|
||||
let mut proposals = Vec::new();
|
||||
let mut scheduling_decisions = Vec::with_capacity(candidates.len());
|
||||
let mut reserved = BTreeMap::<AtlasImpactLane, Uuid>::new();
|
||||
for proposal in candidates {
|
||||
let disposition = if seen_dedupe_keys.contains(&proposal.dedupe_key) {
|
||||
AtlasImpactScheduleDisposition::Deduplicated
|
||||
} else if brake.engaged {
|
||||
AtlasImpactScheduleDisposition::HeldByBrake {
|
||||
brake_id: brake
|
||||
.brake_id
|
||||
.clone()
|
||||
.expect("engaged brake validated above"),
|
||||
}
|
||||
} else if proposal.impact.criticality == AtlasCriticality::Informational {
|
||||
AtlasImpactScheduleDisposition::VisibleOnly
|
||||
} else if let Some(pending) = lanes[&proposal.lane].pending_proposal_id {
|
||||
AtlasImpactScheduleDisposition::HeldByPendingLane {
|
||||
lane: proposal.lane,
|
||||
pending_proposal_id: pending,
|
||||
}
|
||||
} else if let Some(pending) = reserved.get(&proposal.lane).copied() {
|
||||
AtlasImpactScheduleDisposition::HeldByPendingLane {
|
||||
lane: proposal.lane,
|
||||
pending_proposal_id: pending,
|
||||
}
|
||||
} else if let Some(retry_at_unix_ms) =
|
||||
cooldown_until(&lanes[&proposal.lane], policy.cooldown_after_completion_ms)?
|
||||
.filter(|retry_at| *retry_at > now_unix_ms)
|
||||
{
|
||||
AtlasImpactScheduleDisposition::HeldByCooldown {
|
||||
lane: proposal.lane,
|
||||
retry_at_unix_ms,
|
||||
}
|
||||
} else {
|
||||
reserved.insert(proposal.lane, proposal.proposal_id);
|
||||
AtlasImpactScheduleDisposition::Schedule {
|
||||
lane: proposal.lane,
|
||||
}
|
||||
};
|
||||
scheduling_decisions.push(AtlasImpactSchedulingDecision {
|
||||
proposal_id: proposal.proposal_id,
|
||||
claim_id: proposal.impact.claim_id,
|
||||
criticality: proposal.impact.criticality,
|
||||
disposition,
|
||||
});
|
||||
if !seen_dedupe_keys.contains(&proposal.dedupe_key) {
|
||||
proposals.push(proposal);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AtlasImpactIngressResult {
|
||||
proposals,
|
||||
scheduling_decisions,
|
||||
})
|
||||
}
|
||||
|
||||
fn validated_lanes(
|
||||
lane_states: &[AtlasImpactLaneState],
|
||||
) -> Result<BTreeMap<AtlasImpactLane, &AtlasImpactLaneState>> {
|
||||
let mut lanes = BTreeMap::new();
|
||||
for state in lane_states {
|
||||
if lanes.insert(state.lane, state).is_some() {
|
||||
bail!("Atlas impact ingress received duplicate lane state")
|
||||
}
|
||||
}
|
||||
for lane in [AtlasImpactLane::Modeling, AtlasImpactLane::Soul] {
|
||||
if !lanes.contains_key(&lane) {
|
||||
bail!("Atlas impact ingress requires explicit Modeling and Soul lane state")
|
||||
}
|
||||
}
|
||||
Ok(lanes)
|
||||
}
|
||||
|
||||
fn impact_reason(
|
||||
claim: &AtlasDependencyClaim,
|
||||
entanglement: Option<&AtlasProjectedEntanglement>,
|
||||
) -> Option<(AtlasImpactReason, AtlasImpactLane)> {
|
||||
let Some(entanglement) = entanglement else {
|
||||
return Some((
|
||||
AtlasImpactReason::MissingFromProjection,
|
||||
AtlasImpactLane::Modeling,
|
||||
));
|
||||
};
|
||||
if !projected_claim_matches(claim, entanglement) {
|
||||
return Some((
|
||||
AtlasImpactReason::ProjectedClaimDiffers,
|
||||
AtlasImpactLane::Modeling,
|
||||
));
|
||||
}
|
||||
if entanglement.claim_freshness != AtlasPublicationFreshness::Current {
|
||||
return Some((
|
||||
AtlasImpactReason::ClaimNotCurrent {
|
||||
freshness: entanglement.claim_freshness,
|
||||
},
|
||||
AtlasImpactLane::Modeling,
|
||||
));
|
||||
}
|
||||
if matches!(claim.target, AtlasDependencyTarget::Exact { .. })
|
||||
&& entanglement.offer_freshness != Some(AtlasPublicationFreshness::Current)
|
||||
{
|
||||
return Some((
|
||||
AtlasImpactReason::OfferNotCurrent {
|
||||
freshness: entanglement.offer_freshness,
|
||||
},
|
||||
AtlasImpactLane::Modeling,
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
entanglement.compatibility,
|
||||
AtlasCompatibility::Exact | AtlasCompatibility::Compatible
|
||||
) {
|
||||
return Some((
|
||||
AtlasImpactReason::Compatibility {
|
||||
state: entanglement.compatibility,
|
||||
},
|
||||
AtlasImpactLane::Modeling,
|
||||
));
|
||||
}
|
||||
if entanglement.verification != AtlasVerificationState::Passed {
|
||||
return Some((
|
||||
AtlasImpactReason::Verification {
|
||||
state: entanglement.verification,
|
||||
},
|
||||
AtlasImpactLane::Soul,
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn projected_claim_matches(
|
||||
claim: &AtlasDependencyClaim,
|
||||
entanglement: &AtlasProjectedEntanglement,
|
||||
) -> bool {
|
||||
if entanglement.consumer != claim.consumer
|
||||
|| entanglement.claim_id != claim.claim_id
|
||||
|| entanglement.entanglement_kind != claim.entanglement_kind
|
||||
|| entanglement.failure_semantics != claim.failure_semantics
|
||||
|| entanglement.impact_scope != claim.impact_scope
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match &claim.target {
|
||||
AtlasDependencyTarget::Exact {
|
||||
provider,
|
||||
surface_id,
|
||||
..
|
||||
} => {
|
||||
entanglement.provider.as_ref() == Some(provider)
|
||||
&& entanglement.surface_id == Some(*surface_id)
|
||||
}
|
||||
AtlasDependencyTarget::Unresolved { .. } => {
|
||||
entanglement.provider.is_none() && entanglement.surface_id.is_none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn criticality(claim: &AtlasDependencyClaim, reason: &AtlasImpactReason) -> AtlasCriticality {
|
||||
if matches!(
|
||||
reason,
|
||||
AtlasImpactReason::ProjectedClaimDiffers
|
||||
| AtlasImpactReason::Verification {
|
||||
state: AtlasVerificationState::Failed
|
||||
}
|
||||
) || matches!(
|
||||
claim.failure_semantics,
|
||||
AtlasFailureSemantics::FailClosed | AtlasFailureSemantics::HumanDecision
|
||||
) {
|
||||
AtlasCriticality::Blocking
|
||||
} else if matches!(
|
||||
reason,
|
||||
AtlasImpactReason::Verification {
|
||||
state: AtlasVerificationState::Missing
|
||||
}
|
||||
) && claim.entanglement_kind == AtlasEntanglementKind::LorePersona
|
||||
{
|
||||
AtlasCriticality::Informational
|
||||
} else {
|
||||
AtlasCriticality::Degrading
|
||||
}
|
||||
}
|
||||
|
||||
fn transitive_impact_reason(
|
||||
projection: &AtlasEntanglementProjection,
|
||||
claim: &AtlasDependencyClaim,
|
||||
) -> Option<(AtlasImpactReason, AtlasImpactLane)> {
|
||||
let AtlasDependencyTarget::Exact { provider, .. } = &claim.target else {
|
||||
return None;
|
||||
};
|
||||
projection.blast_radii.iter().find_map(|radius| {
|
||||
let local = radius
|
||||
.affected
|
||||
.iter()
|
||||
.find(|affected| affected.repository == claim.consumer && affected.minimum_hops >= 2)?;
|
||||
let provider_hops = if radius.source == *provider {
|
||||
0
|
||||
} else {
|
||||
radius
|
||||
.affected
|
||||
.iter()
|
||||
.find(|affected| affected.repository == *provider)?
|
||||
.minimum_hops
|
||||
};
|
||||
if local.minimum_hops != provider_hops.saturating_add(1)
|
||||
|| !projection.entanglements.iter().any(|edge| {
|
||||
edge.provider.as_ref() == Some(&radius.source)
|
||||
&& edge.surface_id == Some(radius.source_surface_id)
|
||||
&& (edge.claim_freshness != AtlasPublicationFreshness::Current
|
||||
|| edge.offer_freshness != Some(AtlasPublicationFreshness::Current)
|
||||
|| !matches!(
|
||||
edge.compatibility,
|
||||
AtlasCompatibility::Exact | AtlasCompatibility::Compatible
|
||||
)
|
||||
|| edge.verification == AtlasVerificationState::Failed)
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
AtlasImpactReason::TransitiveBlastRadius {
|
||||
source_repository_uri: radius.source.repository_uri.clone(),
|
||||
source_surface_id: radius.source_surface_id,
|
||||
minimum_hops: local.minimum_hops,
|
||||
},
|
||||
AtlasImpactLane::Modeling,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn impact_source_publications(
|
||||
projection: &AtlasEntanglementProjection,
|
||||
entanglement: Option<&AtlasProjectedEntanglement>,
|
||||
reason: &AtlasImpactReason,
|
||||
) -> Vec<String> {
|
||||
let mut source_ids = if matches!(reason, AtlasImpactReason::TransitiveBlastRadius { .. }) {
|
||||
projection.source_publication_ids.clone()
|
||||
} else {
|
||||
entanglement
|
||||
.map(|entanglement| {
|
||||
let mut ids = vec![entanglement.claim_publication_id.clone()];
|
||||
ids.extend(entanglement.offer_publication_id.iter().cloned());
|
||||
ids
|
||||
})
|
||||
.unwrap_or_else(|| projection.source_publication_ids.clone())
|
||||
};
|
||||
source_ids.sort();
|
||||
source_ids.dedup();
|
||||
source_ids
|
||||
}
|
||||
|
||||
fn cooldown_until(state: &AtlasImpactLaneState, cooldown_ms: u64) -> Result<Option<u64>> {
|
||||
state
|
||||
.last_completed_at_unix_ms
|
||||
.map(|completed| {
|
||||
completed
|
||||
.checked_add(cooldown_ms)
|
||||
.ok_or_else(|| anyhow::anyhow!("Atlas impact lane cooldown overflowed"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use semver::VersionReq;
|
||||
|
||||
fn digest(seed: &str) -> String {
|
||||
sha256(seed.as_bytes())
|
||||
}
|
||||
|
||||
fn body_evidence() -> Vec<AtlasBodyEvidenceRef> {
|
||||
vec![AtlasBodyEvidenceRef {
|
||||
path: "Cargo.toml".into(),
|
||||
raw_sha256: "0".repeat(64),
|
||||
}]
|
||||
}
|
||||
|
||||
fn repository(workspace: &str) -> AtlasRepositoryIdentity {
|
||||
AtlasRepositoryIdentity::new("swarm", workspace).unwrap()
|
||||
}
|
||||
|
||||
fn claim() -> AtlasDependencyClaim {
|
||||
AtlasDependencyClaim {
|
||||
schema_version: ATLAS_DEPENDENCY_CLAIM_SCHEMA.into(),
|
||||
consumer: repository("consumer"),
|
||||
claim_id: Uuid::from_u128(1),
|
||||
target: AtlasDependencyTarget::Exact {
|
||||
provider: repository("provider"),
|
||||
surface_id: Uuid::from_u128(2),
|
||||
requirement: AtlasContractRequirement::Semver {
|
||||
contract_id: "contract.surface".into(),
|
||||
requirement: VersionReq::parse("^1").unwrap(),
|
||||
},
|
||||
},
|
||||
entanglement_kind: AtlasEntanglementKind::Runtime,
|
||||
failure_semantics: AtlasFailureSemantics::Degrade,
|
||||
impact_scope: AtlasImpactScope::WholeRepository,
|
||||
lifecycle: AtlasClaimLifecycle::Active,
|
||||
label: "Consumer runtime dependency".into(),
|
||||
body_evidence: body_evidence(),
|
||||
}
|
||||
}
|
||||
|
||||
fn local_claim() -> AtlasLocalClaimInput {
|
||||
let claim = claim();
|
||||
let source_payload_sha256 = sha256(
|
||||
&atlas_source_payload_msgpack(&AtlasPublicationPayload::DependencyClaim(claim.clone()))
|
||||
.unwrap(),
|
||||
);
|
||||
AtlasLocalClaimInput {
|
||||
claim,
|
||||
source_payload_sha256,
|
||||
}
|
||||
}
|
||||
|
||||
fn entanglement(
|
||||
compatibility: AtlasCompatibility,
|
||||
verification: AtlasVerificationState,
|
||||
) -> AtlasProjectedEntanglement {
|
||||
AtlasProjectedEntanglement {
|
||||
claim_id: Uuid::from_u128(1),
|
||||
claim_label: "Consumer runtime dependency".into(),
|
||||
claim_requirement: AtlasContractRequirement::ExactSchema {
|
||||
contract_id: "contract.surface".into(),
|
||||
schema_id: "schema.surface.v1".into(),
|
||||
},
|
||||
claim_body_evidence: body_evidence(),
|
||||
consumer: repository("consumer"),
|
||||
provider: Some(repository("provider")),
|
||||
surface_id: Some(Uuid::from_u128(2)),
|
||||
offer_label: Some("Provider runtime surface".into()),
|
||||
offer_contract: Some(AtlasContractDescriptor::ExactSchema {
|
||||
contract_id: "contract.surface".into(),
|
||||
schema_id: "schema.surface.v1".into(),
|
||||
}),
|
||||
offer_lifecycle: Some(AtlasOfferLifecycle::Active),
|
||||
offer_body_evidence: body_evidence(),
|
||||
entanglement_kind: AtlasEntanglementKind::Runtime,
|
||||
failure_semantics: AtlasFailureSemantics::Degrade,
|
||||
impact_scope: AtlasImpactScope::WholeRepository,
|
||||
claim_freshness: AtlasPublicationFreshness::Current,
|
||||
offer_freshness: Some(AtlasPublicationFreshness::Current),
|
||||
compatibility,
|
||||
verification,
|
||||
claim_publication_id: digest("claim-publication"),
|
||||
offer_publication_id: Some(digest("offer-publication")),
|
||||
verification_publication_id: (!matches!(verification, AtlasVerificationState::Missing))
|
||||
.then(|| digest("verification-publication")),
|
||||
verification_evidence_sha256: (!matches!(
|
||||
verification,
|
||||
AtlasVerificationState::Missing
|
||||
))
|
||||
.then(|| digest("verification-evidence")),
|
||||
}
|
||||
}
|
||||
|
||||
fn projection(entanglement: AtlasProjectedEntanglement) -> AtlasEntanglementProjection {
|
||||
let mut source_publication_ids = vec![
|
||||
entanglement.claim_publication_id.clone(),
|
||||
entanglement.offer_publication_id.clone().unwrap(),
|
||||
];
|
||||
source_publication_ids.sort();
|
||||
AtlasEntanglementProjection {
|
||||
schema_version: ATLAS_PROJECTION_SCHEMA.into(),
|
||||
audience_id: "local-consumer".into(),
|
||||
evaluated_at_unix_ms: 1_800_000_000_000,
|
||||
source_publication_ids,
|
||||
publisher_status: Vec::new(),
|
||||
entanglements: vec![entanglement],
|
||||
cycles: Vec::new(),
|
||||
blast_radii: Vec::new(),
|
||||
projection_sha256: digest("projection"),
|
||||
}
|
||||
}
|
||||
|
||||
fn lanes() -> Vec<AtlasImpactLaneState> {
|
||||
vec![
|
||||
AtlasImpactLaneState {
|
||||
lane: AtlasImpactLane::Modeling,
|
||||
pending_proposal_id: None,
|
||||
last_completed_at_unix_ms: None,
|
||||
},
|
||||
AtlasImpactLaneState {
|
||||
lane: AtlasImpactLane::Soul,
|
||||
pending_proposal_id: None,
|
||||
last_completed_at_unix_ms: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn no_brake() -> AtlasImpactBrakeState {
|
||||
AtlasImpactBrakeState {
|
||||
engaged: false,
|
||||
brake_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incompatible_projection_yields_only_a_modeling_proposal() -> Result<()> {
|
||||
let result = evaluate_local_atlas_impacts(
|
||||
&repository("consumer"),
|
||||
&projection(entanglement(
|
||||
AtlasCompatibility::VersionMismatch,
|
||||
AtlasVerificationState::Missing,
|
||||
)),
|
||||
&[local_claim()],
|
||||
&BTreeSet::new(),
|
||||
&lanes(),
|
||||
&no_brake(),
|
||||
&AtlasImpactIngressPolicy {
|
||||
cooldown_after_completion_ms: 100,
|
||||
},
|
||||
1_800_000_000_000,
|
||||
)?;
|
||||
|
||||
assert_eq!(result.proposals.len(), 1);
|
||||
assert_eq!(result.proposals[0].lane, AtlasImpactLane::Modeling);
|
||||
assert_eq!(
|
||||
result.proposals[0].authority,
|
||||
AtlasImpactProposalAuthority::LocalReviewOnly
|
||||
);
|
||||
assert!(matches!(
|
||||
result.scheduling_decisions[0].disposition,
|
||||
AtlasImpactScheduleDisposition::Schedule {
|
||||
lane: AtlasImpactLane::Modeling
|
||||
}
|
||||
));
|
||||
result.proposals[0].impact.validate()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn informational_change_is_visible_without_waking_a_lane() -> Result<()> {
|
||||
let mut informational_claim = claim();
|
||||
informational_claim.entanglement_kind = AtlasEntanglementKind::LorePersona;
|
||||
let source_payload_sha256 = sha256(&atlas_source_payload_msgpack(
|
||||
&AtlasPublicationPayload::DependencyClaim(informational_claim.clone()),
|
||||
)?);
|
||||
let mut edge = entanglement(AtlasCompatibility::Exact, AtlasVerificationState::Missing);
|
||||
edge.entanglement_kind = AtlasEntanglementKind::LorePersona;
|
||||
let result = evaluate_local_atlas_impacts(
|
||||
&repository("consumer"),
|
||||
&projection(edge),
|
||||
&[AtlasLocalClaimInput {
|
||||
claim: informational_claim,
|
||||
source_payload_sha256,
|
||||
}],
|
||||
&BTreeSet::new(),
|
||||
&lanes(),
|
||||
&no_brake(),
|
||||
&AtlasImpactIngressPolicy {
|
||||
cooldown_after_completion_ms: 100,
|
||||
},
|
||||
1_800_000_000_000,
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
result.proposals[0].impact.criticality,
|
||||
AtlasCriticality::Informational
|
||||
);
|
||||
assert_eq!(
|
||||
result.scheduling_decisions[0].disposition,
|
||||
AtlasImpactScheduleDisposition::VisibleOnly
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitive_blocking_blast_wakes_the_exact_downstream_claim() -> Result<()> {
|
||||
let mut local_claim = claim();
|
||||
local_claim.failure_semantics = AtlasFailureSemantics::FailClosed;
|
||||
let local_source = sha256(&atlas_source_payload_msgpack(
|
||||
&AtlasPublicationPayload::DependencyClaim(local_claim.clone()),
|
||||
)?);
|
||||
let mut local_edge =
|
||||
entanglement(AtlasCompatibility::Exact, AtlasVerificationState::Passed);
|
||||
local_edge.failure_semantics = AtlasFailureSemantics::FailClosed;
|
||||
let eve = repository("eve");
|
||||
let odin = repository("provider");
|
||||
let mut source_edge = local_edge.clone();
|
||||
source_edge.claim_id = Uuid::from_u128(9);
|
||||
source_edge.consumer = odin.clone();
|
||||
source_edge.provider = Some(eve.clone());
|
||||
source_edge.surface_id = Some(Uuid::from_u128(8));
|
||||
source_edge.compatibility = AtlasCompatibility::VersionMismatch;
|
||||
source_edge.claim_publication_id = digest("odin-eve-claim");
|
||||
source_edge.offer_publication_id = Some(digest("eve-offer"));
|
||||
let mut source_publication_ids = vec![
|
||||
local_edge.claim_publication_id.clone(),
|
||||
local_edge.offer_publication_id.clone().unwrap(),
|
||||
source_edge.claim_publication_id.clone(),
|
||||
source_edge.offer_publication_id.clone().unwrap(),
|
||||
];
|
||||
source_publication_ids.sort();
|
||||
let projection = AtlasEntanglementProjection {
|
||||
schema_version: ATLAS_PROJECTION_SCHEMA.into(),
|
||||
audience_id: "gamecult-local".into(),
|
||||
evaluated_at_unix_ms: 1_800_000_000_000,
|
||||
source_publication_ids,
|
||||
publisher_status: Vec::new(),
|
||||
entanglements: vec![local_edge, source_edge],
|
||||
cycles: Vec::new(),
|
||||
blast_radii: vec![AtlasBlastRadius {
|
||||
source: eve,
|
||||
source_surface_id: Uuid::from_u128(8),
|
||||
affected: vec![
|
||||
AtlasAffectedRepository {
|
||||
repository: odin,
|
||||
minimum_hops: 1,
|
||||
},
|
||||
AtlasAffectedRepository {
|
||||
repository: repository("consumer"),
|
||||
minimum_hops: 2,
|
||||
},
|
||||
],
|
||||
}],
|
||||
projection_sha256: digest("transitive-projection"),
|
||||
};
|
||||
let result = evaluate_local_atlas_impacts(
|
||||
&repository("consumer"),
|
||||
&projection,
|
||||
&[AtlasLocalClaimInput {
|
||||
claim: local_claim,
|
||||
source_payload_sha256: local_source,
|
||||
}],
|
||||
&BTreeSet::new(),
|
||||
&lanes(),
|
||||
&no_brake(),
|
||||
&AtlasImpactIngressPolicy {
|
||||
cooldown_after_completion_ms: 100,
|
||||
},
|
||||
1_800_000_000_000,
|
||||
)?;
|
||||
assert_eq!(result.proposals.len(), 1);
|
||||
assert_eq!(result.proposals[0].lane, AtlasImpactLane::Modeling);
|
||||
assert_eq!(
|
||||
result.proposals[0].impact.criticality,
|
||||
AtlasCriticality::Blocking
|
||||
);
|
||||
assert!(matches!(
|
||||
result.proposals[0].reason,
|
||||
AtlasImpactReason::TransitiveBlastRadius {
|
||||
minimum_hops: 2,
|
||||
..
|
||||
}
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brake_preserves_new_proposal_but_dedupe_suppresses_repeat_pressure() -> Result<()> {
|
||||
let projection = projection(entanglement(
|
||||
AtlasCompatibility::VersionMismatch,
|
||||
AtlasVerificationState::Missing,
|
||||
));
|
||||
let brake = AtlasImpactBrakeState {
|
||||
engaged: true,
|
||||
brake_id: Some("operator-brake".into()),
|
||||
};
|
||||
let first = evaluate_local_atlas_impacts(
|
||||
&repository("consumer"),
|
||||
&projection,
|
||||
&[local_claim()],
|
||||
&BTreeSet::new(),
|
||||
&lanes(),
|
||||
&brake,
|
||||
&AtlasImpactIngressPolicy {
|
||||
cooldown_after_completion_ms: 100,
|
||||
},
|
||||
1_800_000_000_000,
|
||||
)?;
|
||||
assert_eq!(first.proposals.len(), 1);
|
||||
assert!(matches!(
|
||||
first.scheduling_decisions[0].disposition,
|
||||
AtlasImpactScheduleDisposition::HeldByBrake { .. }
|
||||
));
|
||||
|
||||
let seen = BTreeSet::from([first.proposals[0].dedupe_key.clone()]);
|
||||
let repeated = evaluate_local_atlas_impacts(
|
||||
&repository("consumer"),
|
||||
&projection,
|
||||
&[local_claim()],
|
||||
&seen,
|
||||
&lanes(),
|
||||
&brake,
|
||||
&AtlasImpactIngressPolicy {
|
||||
cooldown_after_completion_ms: 100,
|
||||
},
|
||||
1_800_000_000_000,
|
||||
)?;
|
||||
assert!(repeated.proposals.is_empty());
|
||||
assert_eq!(
|
||||
repeated.scheduling_decisions[0].disposition,
|
||||
AtlasImpactScheduleDisposition::Deduplicated
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verification_pressure_obeys_pending_lane_then_completion_cooldown() -> Result<()> {
|
||||
let projection = projection(entanglement(
|
||||
AtlasCompatibility::Exact,
|
||||
AtlasVerificationState::Missing,
|
||||
));
|
||||
let mut lane_state = lanes();
|
||||
lane_state[1].pending_proposal_id = Some(Uuid::from_u128(99));
|
||||
let pending = evaluate_local_atlas_impacts(
|
||||
&repository("consumer"),
|
||||
&projection,
|
||||
&[local_claim()],
|
||||
&BTreeSet::new(),
|
||||
&lane_state,
|
||||
&no_brake(),
|
||||
&AtlasImpactIngressPolicy {
|
||||
cooldown_after_completion_ms: 100,
|
||||
},
|
||||
1_000,
|
||||
)?;
|
||||
assert!(matches!(
|
||||
pending.scheduling_decisions[0].disposition,
|
||||
AtlasImpactScheduleDisposition::HeldByPendingLane {
|
||||
lane: AtlasImpactLane::Soul,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
lane_state[1].pending_proposal_id = None;
|
||||
lane_state[1].last_completed_at_unix_ms = Some(950);
|
||||
let cooling = evaluate_local_atlas_impacts(
|
||||
&repository("consumer"),
|
||||
&projection,
|
||||
&[local_claim()],
|
||||
&BTreeSet::new(),
|
||||
&lane_state,
|
||||
&no_brake(),
|
||||
&AtlasImpactIngressPolicy {
|
||||
cooldown_after_completion_ms: 100,
|
||||
},
|
||||
1_000,
|
||||
)?;
|
||||
assert_eq!(
|
||||
cooling.scheduling_decisions[0].disposition,
|
||||
AtlasImpactScheduleDisposition::HeldByCooldown {
|
||||
lane: AtlasImpactLane::Soul,
|
||||
retry_at_unix_ms: 1_050,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
//! Atlas is a read-only federated Modeling projection.
|
||||
//!
|
||||
//! Repository identities sign only documents owned by their local repository.
|
||||
//! Providers advertise surfaces, consumers declare reliance and impact, and
|
||||
//! consumer Soul publishes exact-edge verification. Atlas validates those
|
||||
//! publications and derives joins, cycles, and blast radius. It owns no edge,
|
||||
//! grants no capability, and writes no foreign Mind state.
|
||||
|
||||
mod contracts;
|
||||
mod eve_surface;
|
||||
mod identity;
|
||||
mod impact_ingress;
|
||||
mod planners;
|
||||
mod projector;
|
||||
mod publisher;
|
||||
mod runtime;
|
||||
mod store;
|
||||
mod transport;
|
||||
|
||||
pub use contracts::*;
|
||||
pub use eve_surface::*;
|
||||
pub use identity::*;
|
||||
pub use impact_ingress::*;
|
||||
pub use planners::*;
|
||||
pub use projector::*;
|
||||
pub use publisher::*;
|
||||
pub use runtime::*;
|
||||
pub use store::*;
|
||||
pub use transport::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,496 +0,0 @@
|
|||
use anyhow::{Result, bail};
|
||||
use cultnet_rs::ServiceIdentitySigner;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::contracts::*;
|
||||
use super::identity::{
|
||||
AtlasRepositorySigningIdentity, atlas_source_payload_msgpack, canonical_atlas_payload_msgpack,
|
||||
sha256, sign_atlas_publication,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AtlasLocalMindDocument {
|
||||
pub payload: AtlasPublicationPayload,
|
||||
pub source: AtlasMindSourceVersion,
|
||||
pub mind_commit: AtlasMindCommitBinding,
|
||||
}
|
||||
|
||||
impl AtlasLocalMindDocument {
|
||||
fn validate(&self, publisher: &AtlasRepositoryIdentity) -> Result<String> {
|
||||
self.payload.validate()?;
|
||||
self.source.validate()?;
|
||||
self.mind_commit.validate()?;
|
||||
if !self.payload.requires_mind_binding()
|
||||
|| self.payload.owner() != publisher
|
||||
|| self.source != self.mind_commit.source
|
||||
|| self.source.document_type != self.payload.schema()
|
||||
|| self.source.schema_id.as_deref() != Some(self.payload.schema())
|
||||
|| self.source.document_key != self.payload.key()
|
||||
{
|
||||
bail!("Atlas local publication input is not an exact owner/Mind source/commit binding")
|
||||
}
|
||||
let digest = sha256(&atlas_source_payload_msgpack(&self.payload)?);
|
||||
if self.source.payload_sha256 != digest {
|
||||
bail!("Atlas local Mind source digest does not match its CultCache payload")
|
||||
}
|
||||
Ok(digest)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasLatestPublicationPointer {
|
||||
pub publisher: AtlasRepositoryIdentity,
|
||||
pub source_schema: String,
|
||||
pub source_key: String,
|
||||
pub source_payload_sha256: String,
|
||||
pub publication_id: String,
|
||||
pub publication_sequence: u64,
|
||||
}
|
||||
|
||||
impl AtlasLatestPublicationPointer {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.publisher.validate()?;
|
||||
validate_identifier(&self.source_schema, "Atlas latest pointer source schema")?;
|
||||
validate_identifier(&self.source_key, "Atlas latest pointer source key")?;
|
||||
validate_sha256(
|
||||
&self.source_payload_sha256,
|
||||
"Atlas latest pointer source payload digest",
|
||||
)?;
|
||||
validate_sha256(&self.publication_id, "Atlas latest pointer publication id")?;
|
||||
if self.publication_sequence == 0 {
|
||||
bail!("Atlas latest pointer sequence must be positive")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtlasLatestPublicationPointerIntent {
|
||||
pub intent_id: String,
|
||||
pub publisher: AtlasRepositoryIdentity,
|
||||
pub source_schema: String,
|
||||
pub source_key: String,
|
||||
pub expected_current_publication_id: Option<String>,
|
||||
pub next: AtlasLatestPublicationPointer,
|
||||
}
|
||||
|
||||
impl AtlasLatestPublicationPointerIntent {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
validate_sha256(&self.intent_id, "Atlas latest pointer intent id")?;
|
||||
self.publisher.validate()?;
|
||||
validate_identifier(
|
||||
&self.source_schema,
|
||||
"Atlas latest pointer intent source schema",
|
||||
)?;
|
||||
validate_identifier(&self.source_key, "Atlas latest pointer intent source key")?;
|
||||
if let Some(publication_id) = &self.expected_current_publication_id {
|
||||
validate_sha256(
|
||||
publication_id,
|
||||
"Atlas latest pointer expected publication id",
|
||||
)?;
|
||||
}
|
||||
self.next.validate()?;
|
||||
if self.publisher != self.next.publisher
|
||||
|| self.source_schema != self.next.source_schema
|
||||
|| self.source_key != self.next.source_key
|
||||
{
|
||||
bail!("Atlas latest pointer intent substituted its target identity")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AtlasLocalPublicationSnapshot {
|
||||
pub publisher: AtlasRepositoryIdentity,
|
||||
pub current_mind_documents: Vec<AtlasLocalMindDocument>,
|
||||
pub latest_pointers: Vec<AtlasLatestPublicationPointer>,
|
||||
}
|
||||
|
||||
/// The publisher can see only the local repository snapshot passed through this
|
||||
/// port. There is deliberately no path, workspace id, or foreign-store method.
|
||||
pub trait AtlasLocalPublicationStore {
|
||||
fn load_local_atlas_snapshot(&self) -> Result<AtlasLocalPublicationSnapshot>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AtlasPublisherContext {
|
||||
pub runtime_id: String,
|
||||
pub runtime_incarnation_id: String,
|
||||
pub body_basis: crate::RepositoryBodyObservationBasis,
|
||||
pub verse_id: String,
|
||||
pub heartbeat_sequence: u64,
|
||||
pub heartbeat_at_unix_ms: u64,
|
||||
pub publisher_state: AtlasPublisherState,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AtlasPublicationBatch {
|
||||
pub publications: Vec<AtlasPublicationEnvelope>,
|
||||
pub latest_pointer_intents: Vec<AtlasLatestPublicationPointerIntent>,
|
||||
}
|
||||
|
||||
pub fn publish_local_atlas_state(
|
||||
store: &impl AtlasLocalPublicationStore,
|
||||
signer: &ServiceIdentitySigner<AtlasRepositorySigningIdentity>,
|
||||
context: &AtlasPublisherContext,
|
||||
) -> Result<AtlasPublicationBatch> {
|
||||
let mut snapshot = store.load_local_atlas_snapshot()?;
|
||||
snapshot.publisher.validate()?;
|
||||
validate_context(&snapshot.publisher, context)?;
|
||||
|
||||
let mut latest = BTreeMap::<(String, String), AtlasLatestPublicationPointer>::new();
|
||||
let mut maximum_sequence = 0_u64;
|
||||
for pointer in snapshot.latest_pointers.drain(..) {
|
||||
pointer.validate()?;
|
||||
if pointer.publisher != snapshot.publisher {
|
||||
bail!("Atlas local store returned a foreign latest pointer")
|
||||
}
|
||||
maximum_sequence = maximum_sequence.max(pointer.publication_sequence);
|
||||
let key = (pointer.source_schema.clone(), pointer.source_key.clone());
|
||||
if latest.insert(key, pointer).is_some() {
|
||||
bail!("Atlas local store returned duplicate latest pointer identities")
|
||||
}
|
||||
}
|
||||
|
||||
let mut current = BTreeMap::<(String, String), (AtlasLocalMindDocument, String)>::new();
|
||||
for document in snapshot.current_mind_documents.drain(..) {
|
||||
let digest = document.validate(&snapshot.publisher)?;
|
||||
let key = (document.payload.schema().into(), document.payload.key());
|
||||
if current.insert(key, (document, digest)).is_some() {
|
||||
bail!("Atlas local Mind snapshot returned duplicate semantic documents")
|
||||
}
|
||||
}
|
||||
|
||||
let mut publications = Vec::new();
|
||||
let mut latest_pointer_intents = Vec::new();
|
||||
let mut watermarks = Vec::with_capacity(current.len());
|
||||
for ((source_schema, source_key), (document, payload_sha256)) in current {
|
||||
let previous = latest.get(&(source_schema.clone(), source_key.clone()));
|
||||
let pointer =
|
||||
if previous.is_some_and(|pointer| pointer.source_payload_sha256 == payload_sha256) {
|
||||
previous.cloned().expect("checked above")
|
||||
} else {
|
||||
maximum_sequence = maximum_sequence
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| anyhow::anyhow!("Atlas publication sequence exhausted"))?;
|
||||
let publication = sign_atlas_publication(
|
||||
signer,
|
||||
snapshot.publisher.clone(),
|
||||
maximum_sequence,
|
||||
context.runtime_id.clone(),
|
||||
context.runtime_incarnation_id.clone(),
|
||||
context.body_basis.clone(),
|
||||
context.verse_id.clone(),
|
||||
Some(document.source),
|
||||
Some(document.mind_commit),
|
||||
context.heartbeat_at_unix_ms,
|
||||
document.payload,
|
||||
)?;
|
||||
let pointer = AtlasLatestPublicationPointer {
|
||||
publisher: snapshot.publisher.clone(),
|
||||
source_schema: source_schema.clone(),
|
||||
source_key: source_key.clone(),
|
||||
source_payload_sha256: payload_sha256.clone(),
|
||||
publication_id: publication.statement.publication_id.clone(),
|
||||
publication_sequence: maximum_sequence,
|
||||
};
|
||||
latest_pointer_intents.push(pointer_intent(previous, pointer.clone())?);
|
||||
publications.push(publication);
|
||||
pointer
|
||||
};
|
||||
watermarks.push(AtlasPublicationWatermark {
|
||||
source_schema,
|
||||
source_key,
|
||||
source_payload_sha256: payload_sha256,
|
||||
publication_sequence: pointer.publication_sequence,
|
||||
});
|
||||
}
|
||||
|
||||
let status = AtlasPublisherStatus {
|
||||
publisher: snapshot.publisher.clone(),
|
||||
runtime_id: context.runtime_id.clone(),
|
||||
runtime_incarnation_id: context.runtime_incarnation_id.clone(),
|
||||
heartbeat_sequence: context.heartbeat_sequence,
|
||||
heartbeat_at_unix_ms: context.heartbeat_at_unix_ms,
|
||||
state: context.publisher_state,
|
||||
watermarks,
|
||||
};
|
||||
status.validate()?;
|
||||
let status_payload = AtlasPublicationPayload::PublisherStatus(status);
|
||||
let status_schema = status_payload.schema().to_string();
|
||||
let status_key = status_payload.key();
|
||||
let status_sha256 = sha256(&canonical_atlas_payload_msgpack(&status_payload)?);
|
||||
let previous_status = latest.get(&(status_schema.clone(), status_key.clone()));
|
||||
if !previous_status.is_some_and(|pointer| pointer.source_payload_sha256 == status_sha256) {
|
||||
maximum_sequence = maximum_sequence
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| anyhow::anyhow!("Atlas publication sequence exhausted"))?;
|
||||
let publication = sign_atlas_publication(
|
||||
signer,
|
||||
snapshot.publisher.clone(),
|
||||
maximum_sequence,
|
||||
context.runtime_id.clone(),
|
||||
context.runtime_incarnation_id.clone(),
|
||||
context.body_basis.clone(),
|
||||
context.verse_id.clone(),
|
||||
None,
|
||||
None,
|
||||
context.heartbeat_at_unix_ms,
|
||||
status_payload,
|
||||
)?;
|
||||
let pointer = AtlasLatestPublicationPointer {
|
||||
publisher: snapshot.publisher,
|
||||
source_schema: status_schema,
|
||||
source_key: status_key,
|
||||
source_payload_sha256: status_sha256,
|
||||
publication_id: publication.statement.publication_id.clone(),
|
||||
publication_sequence: maximum_sequence,
|
||||
};
|
||||
latest_pointer_intents.push(pointer_intent(previous_status, pointer)?);
|
||||
publications.push(publication);
|
||||
}
|
||||
|
||||
Ok(AtlasPublicationBatch {
|
||||
publications,
|
||||
latest_pointer_intents,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_context(
|
||||
publisher: &AtlasRepositoryIdentity,
|
||||
context: &AtlasPublisherContext,
|
||||
) -> Result<()> {
|
||||
validate_identifier(&context.runtime_id, "Atlas publisher runtime id")?;
|
||||
validate_identifier(
|
||||
&context.runtime_incarnation_id,
|
||||
"Atlas publisher runtime incarnation id",
|
||||
)?;
|
||||
validate_identifier(&context.verse_id, "Atlas publisher Verse id")?;
|
||||
if context.heartbeat_sequence == 0
|
||||
|| context.heartbeat_at_unix_ms == 0
|
||||
|| context.body_basis.swarm_id != publisher.swarm_id
|
||||
|| context.body_basis.workspace_id != publisher.workspace_id
|
||||
|| context.body_basis.runtime_id != context.runtime_id
|
||||
|| context.body_basis.generation == 0
|
||||
{
|
||||
bail!("Atlas publisher context is not bound to the local repository Body")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pointer_intent(
|
||||
previous: Option<&AtlasLatestPublicationPointer>,
|
||||
next: AtlasLatestPublicationPointer,
|
||||
) -> Result<AtlasLatestPublicationPointerIntent> {
|
||||
next.validate()?;
|
||||
let expected_current_publication_id = previous.map(|pointer| pointer.publication_id.clone());
|
||||
let intent_id = sha256(&rmp_serde::to_vec_named(&(
|
||||
"gamecult.model.atlas-latest-pointer-intent.v0",
|
||||
&next.publisher,
|
||||
&next.source_schema,
|
||||
&next.source_key,
|
||||
&expected_current_publication_id,
|
||||
&next.publication_id,
|
||||
next.publication_sequence,
|
||||
))?);
|
||||
let intent = AtlasLatestPublicationPointerIntent {
|
||||
intent_id,
|
||||
publisher: next.publisher.clone(),
|
||||
source_schema: next.source_schema.clone(),
|
||||
source_key: next.source_key.clone(),
|
||||
expected_current_publication_id,
|
||||
next,
|
||||
};
|
||||
intent.validate()?;
|
||||
Ok(intent)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cultnet_rs::enroll_service_identity_at;
|
||||
use semver::Version;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MemoryStore(AtlasLocalPublicationSnapshot);
|
||||
|
||||
impl AtlasLocalPublicationStore for MemoryStore {
|
||||
fn load_local_atlas_snapshot(&self) -> Result<AtlasLocalPublicationSnapshot> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn digest(seed: &str) -> String {
|
||||
sha256(seed.as_bytes())
|
||||
}
|
||||
|
||||
fn body_digest(seed: &str) -> String {
|
||||
digest(seed).trim_start_matches("sha256-").to_owned()
|
||||
}
|
||||
|
||||
fn repository() -> AtlasRepositoryIdentity {
|
||||
AtlasRepositoryIdentity::new("swarm", "workspace").unwrap()
|
||||
}
|
||||
|
||||
fn body() -> crate::RepositoryBodyObservationBasis {
|
||||
crate::RepositoryBodyObservationBasis {
|
||||
schema_version: "gamecult.epiphany.repository_body_observation_basis.v0".into(),
|
||||
workspace_id: "workspace".into(),
|
||||
swarm_id: "swarm".into(),
|
||||
runtime_id: "runtime".into(),
|
||||
scope: "whole_repository".into(),
|
||||
body_binding_sha256: body_digest("body-binding"),
|
||||
observation_id: "body-observation".into(),
|
||||
generation: 1,
|
||||
manifest_root_sha256: body_digest("manifest"),
|
||||
scan_started_at: "2026-08-14T00:00:00Z".into(),
|
||||
scan_finished_at: "2026-08-14T00:00:01Z".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn context() -> AtlasPublisherContext {
|
||||
AtlasPublisherContext {
|
||||
runtime_id: "runtime".into(),
|
||||
runtime_incarnation_id: "incarnation".into(),
|
||||
body_basis: body(),
|
||||
verse_id: "cultmesh://gamecult-local/swarm/atlas".into(),
|
||||
heartbeat_sequence: 1,
|
||||
heartbeat_at_unix_ms: 1_800_000_000_000,
|
||||
publisher_state: AtlasPublisherState::Serving,
|
||||
}
|
||||
}
|
||||
|
||||
fn local_offer(owner: AtlasRepositoryIdentity) -> AtlasLocalMindDocument {
|
||||
let payload = AtlasPublicationPayload::SurfaceOffer(AtlasSurfaceOffer {
|
||||
schema_version: ATLAS_SURFACE_OFFER_SCHEMA.into(),
|
||||
provider: owner,
|
||||
surface_id: Uuid::from_u128(1),
|
||||
contract: AtlasContractDescriptor::Semver {
|
||||
contract_id: "contract.surface".into(),
|
||||
version: Version::new(1, 0, 0),
|
||||
},
|
||||
lifecycle: AtlasOfferLifecycle::Active,
|
||||
label: "Published surface".into(),
|
||||
body_evidence: vec![AtlasBodyEvidenceRef {
|
||||
path: "Cargo.toml".into(),
|
||||
raw_sha256: "0".repeat(64),
|
||||
}],
|
||||
});
|
||||
let payload_sha256 = sha256(&atlas_source_payload_msgpack(&payload).unwrap());
|
||||
let source = AtlasMindSourceVersion {
|
||||
store_id: "epiphany-mind".into(),
|
||||
document_type: payload.schema().into(),
|
||||
document_key: payload.key(),
|
||||
schema_id: Some(payload.schema().into()),
|
||||
payload_sha256,
|
||||
};
|
||||
AtlasLocalMindDocument {
|
||||
payload,
|
||||
mind_commit: AtlasMindCommitBinding {
|
||||
receipt_id: "mind-receipt".into(),
|
||||
receipt_sha256: digest("mind-receipt"),
|
||||
invariant_owner: "Mind".into(),
|
||||
source: source.clone(),
|
||||
},
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_only_local_mind_documents_and_status_with_pointer_intents() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let signer = enroll_service_identity_at::<AtlasRepositorySigningIdentity>(
|
||||
&temp.path().join("atlas-identity.cc"),
|
||||
)?;
|
||||
let owner = repository();
|
||||
let batch = publish_local_atlas_state(
|
||||
&MemoryStore(AtlasLocalPublicationSnapshot {
|
||||
publisher: owner.clone(),
|
||||
current_mind_documents: vec![local_offer(owner.clone())],
|
||||
latest_pointers: Vec::new(),
|
||||
}),
|
||||
&signer,
|
||||
&context(),
|
||||
)?;
|
||||
|
||||
assert_eq!(batch.publications.len(), 2);
|
||||
assert_eq!(batch.latest_pointer_intents.len(), 2);
|
||||
assert!(batch.publications.iter().all(|publication| {
|
||||
publication.statement.publisher == owner && publication.signature.len() == 64
|
||||
}));
|
||||
assert!(matches!(
|
||||
batch.publications[0].statement.payload,
|
||||
AtlasPublicationPayload::SurfaceOffer(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
batch.publications[1].statement.payload,
|
||||
AtlasPublicationPayload::PublisherStatus(_)
|
||||
));
|
||||
assert!(batch.latest_pointer_intents.iter().all(|intent| {
|
||||
intent.expected_current_publication_id.is_none() && intent.validate().is_ok()
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_local_snapshot_is_idempotent_at_the_same_heartbeat() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let signer = enroll_service_identity_at::<AtlasRepositorySigningIdentity>(
|
||||
&temp.path().join("atlas-identity.cc"),
|
||||
)?;
|
||||
let owner = repository();
|
||||
let first = publish_local_atlas_state(
|
||||
&MemoryStore(AtlasLocalPublicationSnapshot {
|
||||
publisher: owner.clone(),
|
||||
current_mind_documents: vec![local_offer(owner.clone())],
|
||||
latest_pointers: Vec::new(),
|
||||
}),
|
||||
&signer,
|
||||
&context(),
|
||||
)?;
|
||||
let latest_pointers = first
|
||||
.latest_pointer_intents
|
||||
.iter()
|
||||
.map(|intent| intent.next.clone())
|
||||
.collect();
|
||||
let repeated = publish_local_atlas_state(
|
||||
&MemoryStore(AtlasLocalPublicationSnapshot {
|
||||
publisher: owner.clone(),
|
||||
current_mind_documents: vec![local_offer(owner)],
|
||||
latest_pointers,
|
||||
}),
|
||||
&signer,
|
||||
&context(),
|
||||
)?;
|
||||
|
||||
assert!(repeated.publications.is_empty());
|
||||
assert!(repeated.latest_pointer_intents.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_mind_document_is_refused_without_store_access() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let signer = enroll_service_identity_at::<AtlasRepositorySigningIdentity>(
|
||||
&temp.path().join("atlas-identity.cc"),
|
||||
)?;
|
||||
let owner = repository();
|
||||
let foreign = AtlasRepositoryIdentity::new("swarm", "foreign")?;
|
||||
let result = publish_local_atlas_state(
|
||||
&MemoryStore(AtlasLocalPublicationSnapshot {
|
||||
publisher: owner,
|
||||
current_mind_documents: vec![local_offer(foreign)],
|
||||
latest_pointers: Vec::new(),
|
||||
}),
|
||||
&signer,
|
||||
&context(),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,289 +0,0 @@
|
|||
use std::net::{SocketAddr, UdpSocket};
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use cultmesh_rs::{
|
||||
CULTMESH_RUDP_DOCUMENT_CATALOG_CONNECTION_ID, CultMesh, CultMeshNodeOptions,
|
||||
CultMeshRudpDocumentPublishOptions,
|
||||
};
|
||||
use cultnet_rs::{
|
||||
CultNetMessage, CultNetRawPayloadEncoding, CultNetRudpSocketTransportConnection,
|
||||
CultNetRudpSocketTransportOptions, CultNetWireContract, decode_cultnet_message_from_slice,
|
||||
encode_cultnet_message_to_vec,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
ATLAS_MAX_PUBLICATION_BYTES, ATLAS_PROJECTION_SCHEMA, ATLAS_PUBLICATION_SCHEMA,
|
||||
AtlasEntanglementProjection, AtlasEveDocuments, AtlasPublicationEnvelope,
|
||||
EVE_PROVIDER_ADVERTISEMENT_SCHEMA, EVE_SURFACE_SCHEMA, EveProviderAdvertisement,
|
||||
EveSurfaceDocument, MODEL_ATLAS_PROVIDER_ID, MODEL_ATLAS_SURFACE_ID,
|
||||
};
|
||||
|
||||
cultmesh_rs::cultmesh_documents!(AtlasFederationDocuments {
|
||||
AtlasPublicationEnvelope => ATLAS_PUBLICATION_SCHEMA,
|
||||
AtlasEntanglementProjection => ATLAS_PROJECTION_SCHEMA,
|
||||
EveProviderAdvertisement => EVE_PROVIDER_ADVERTISEMENT_SCHEMA,
|
||||
EveSurfaceDocument => EVE_SURFACE_SCHEMA,
|
||||
});
|
||||
|
||||
pub const ATLAS_RUDP_TRANSPORT_TAG: &str = "gamecult.model.atlas-rudp.v0";
|
||||
|
||||
pub fn persist_atlas_publications(
|
||||
local_store: &Path,
|
||||
runtime_id: &str,
|
||||
publications: &[AtlasPublicationEnvelope],
|
||||
) -> Result<()> {
|
||||
require_id(runtime_id, "Atlas publication runtime")?;
|
||||
let mut node = CultMesh::create_node(
|
||||
local_store,
|
||||
AtlasFederationDocuments,
|
||||
CultMeshNodeOptions {
|
||||
runtime_id: runtime_id.into(),
|
||||
pull_on_start: true,
|
||||
},
|
||||
)?;
|
||||
for publication in publications {
|
||||
if rmp_serde::to_vec(publication)?.len() > ATLAS_MAX_PUBLICATION_BYTES {
|
||||
bail!("Atlas publication exceeds the transport size limit")
|
||||
}
|
||||
node.put(publication.statement.publication_id.clone(), publication)?;
|
||||
}
|
||||
node.flush()
|
||||
}
|
||||
|
||||
pub fn publish_atlas_publications_rudp(
|
||||
local_store: &Path,
|
||||
endpoint: SocketAddr,
|
||||
runtime_id: &str,
|
||||
source_agent_id: &str,
|
||||
publications: &[AtlasPublicationEnvelope],
|
||||
) -> Result<()> {
|
||||
require_id(runtime_id, "Atlas publication runtime")?;
|
||||
require_id(source_agent_id, "Atlas publication agent")?;
|
||||
let node = CultMesh::create_node(
|
||||
local_store,
|
||||
AtlasFederationDocuments,
|
||||
CultMeshNodeOptions {
|
||||
runtime_id: runtime_id.into(),
|
||||
pull_on_start: true,
|
||||
},
|
||||
)?;
|
||||
for publication in publications {
|
||||
node.publish_document_to_rudp_catalog(
|
||||
publication.statement.publication_id.clone(),
|
||||
publication,
|
||||
CultMeshRudpDocumentPublishOptions {
|
||||
target: endpoint,
|
||||
runtime_id: runtime_id.into(),
|
||||
source_agent_id: Some(source_agent_id.into()),
|
||||
source_role: Some("epiphany-atlas-publisher".into()),
|
||||
tags: vec![ATLAS_RUDP_TRANSPORT_TAG.into()],
|
||||
..CultMeshRudpDocumentPublishOptions::default()
|
||||
},
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn persist_atlas_projection(
|
||||
projector_store: &Path,
|
||||
runtime_id: &str,
|
||||
projection: &AtlasEntanglementProjection,
|
||||
) -> Result<()> {
|
||||
require_id(runtime_id, "Atlas projector runtime")?;
|
||||
let mut node = CultMesh::create_node(
|
||||
projector_store,
|
||||
AtlasFederationDocuments,
|
||||
CultMeshNodeOptions {
|
||||
runtime_id: runtime_id.into(),
|
||||
pull_on_start: true,
|
||||
},
|
||||
)?;
|
||||
node.put(projection.audience_id.clone(), projection)?;
|
||||
node.flush()
|
||||
}
|
||||
|
||||
pub fn persist_atlas_projection_and_eve(
|
||||
projector_store: &Path,
|
||||
runtime_id: &str,
|
||||
projection: &AtlasEntanglementProjection,
|
||||
eve: &AtlasEveDocuments,
|
||||
) -> Result<()> {
|
||||
require_id(runtime_id, "Atlas projector runtime")?;
|
||||
let mut node = CultMesh::create_node(
|
||||
projector_store,
|
||||
AtlasFederationDocuments,
|
||||
CultMeshNodeOptions {
|
||||
runtime_id: runtime_id.into(),
|
||||
pull_on_start: true,
|
||||
},
|
||||
)?;
|
||||
node.put(projection.audience_id.clone(), projection)?;
|
||||
node.put(MODEL_ATLAS_PROVIDER_ID, &eve.advertisement)?;
|
||||
node.put(MODEL_ATLAS_SURFACE_ID, &eve.surface)?;
|
||||
node.flush()
|
||||
}
|
||||
|
||||
pub fn publish_atlas_projection_and_eve_rudp(
|
||||
projector_store: &Path,
|
||||
endpoint: SocketAddr,
|
||||
runtime_id: &str,
|
||||
source_agent_id: &str,
|
||||
projection: &AtlasEntanglementProjection,
|
||||
eve: &AtlasEveDocuments,
|
||||
) -> Result<()> {
|
||||
require_id(runtime_id, "Atlas projector runtime")?;
|
||||
require_id(source_agent_id, "Atlas projector agent")?;
|
||||
let node = CultMesh::create_node(
|
||||
projector_store,
|
||||
AtlasFederationDocuments,
|
||||
CultMeshNodeOptions {
|
||||
runtime_id: runtime_id.into(),
|
||||
pull_on_start: true,
|
||||
},
|
||||
)?;
|
||||
let options = |role: &str| CultMeshRudpDocumentPublishOptions {
|
||||
target: endpoint,
|
||||
runtime_id: runtime_id.into(),
|
||||
source_agent_id: Some(source_agent_id.into()),
|
||||
source_role: Some(role.into()),
|
||||
tags: vec![ATLAS_RUDP_TRANSPORT_TAG.into()],
|
||||
..CultMeshRudpDocumentPublishOptions::default()
|
||||
};
|
||||
node.publish_document_to_rudp_catalog(
|
||||
projection.audience_id.clone(),
|
||||
projection,
|
||||
options("epiphany-model-entanglement-projector"),
|
||||
)?;
|
||||
node.publish_document_to_rudp_catalog(
|
||||
MODEL_ATLAS_PROVIDER_ID,
|
||||
&eve.advertisement,
|
||||
options("epiphany-model-atlas-eve-provider"),
|
||||
)?;
|
||||
node.publish_document_to_rudp_catalog(
|
||||
MODEL_ATLAS_SURFACE_ID,
|
||||
&eve.surface,
|
||||
options("epiphany-model-atlas-eve-surface"),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_atlas_projection(
|
||||
projector_store: &Path,
|
||||
audience_id: &str,
|
||||
) -> Result<Option<AtlasEntanglementProjection>> {
|
||||
let node = CultMesh::create_node(
|
||||
projector_store,
|
||||
AtlasFederationDocuments,
|
||||
CultMeshNodeOptions::default(),
|
||||
)?;
|
||||
node.get(audience_id)
|
||||
}
|
||||
|
||||
pub fn query_atlas_publications_rudp(
|
||||
endpoint: SocketAddr,
|
||||
runtime_id: &str,
|
||||
) -> Result<Vec<AtlasPublicationEnvelope>> {
|
||||
require_id(runtime_id, "Atlas projector query runtime")?;
|
||||
let message_id = format!("atlas-snapshot-{}", Uuid::new_v4());
|
||||
let response = exchange_catalog(
|
||||
endpoint,
|
||||
runtime_id,
|
||||
CultNetMessage::SnapshotRequest {
|
||||
message_id: message_id.clone(),
|
||||
schema_ids: Some(vec![ATLAS_PUBLICATION_SCHEMA.into()]),
|
||||
record_keys: None,
|
||||
},
|
||||
)?;
|
||||
let CultNetMessage::SnapshotResponseRaw {
|
||||
message_id: response_id,
|
||||
documents,
|
||||
} = response
|
||||
else {
|
||||
bail!("Odin Atlas query did not return a raw typed snapshot")
|
||||
};
|
||||
if response_id != message_id {
|
||||
bail!("Odin Atlas snapshot response substituted its request identity")
|
||||
}
|
||||
let mut publications = Vec::with_capacity(documents.len());
|
||||
for document in documents {
|
||||
if document.schema_id != ATLAS_PUBLICATION_SCHEMA
|
||||
|| document.payload_encoding != CultNetRawPayloadEncoding::Messagepack
|
||||
|| document.payload.len() > ATLAS_MAX_PUBLICATION_BYTES
|
||||
{
|
||||
bail!("Odin Atlas snapshot contained an unknown, non-MessagePack, or oversized record")
|
||||
}
|
||||
let publication: AtlasPublicationEnvelope = rmp_serde::from_slice(&document.payload)
|
||||
.context("Odin Atlas publication payload is malformed")?;
|
||||
if rmp_serde::to_vec(&publication)? != document.payload
|
||||
|| publication.statement.publication_id != document.record_key
|
||||
{
|
||||
bail!("Odin Atlas publication payload is noncanonical or key-substituted")
|
||||
}
|
||||
publications.push(publication);
|
||||
}
|
||||
publications.sort_by(|left, right| {
|
||||
left.statement
|
||||
.publication_id
|
||||
.cmp(&right.statement.publication_id)
|
||||
});
|
||||
Ok(publications)
|
||||
}
|
||||
|
||||
fn exchange_catalog(
|
||||
endpoint: SocketAddr,
|
||||
runtime_id: &str,
|
||||
request: CultNetMessage,
|
||||
) -> Result<CultNetMessage> {
|
||||
let bind = if endpoint.is_ipv4() {
|
||||
"0.0.0.0:0"
|
||||
} else {
|
||||
"[::]:0"
|
||||
};
|
||||
let socket = UdpSocket::bind(bind)?;
|
||||
socket.set_read_timeout(Some(Duration::from_millis(50)))?;
|
||||
let mut transport =
|
||||
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions::client(
|
||||
runtime_id,
|
||||
socket,
|
||||
endpoint,
|
||||
CULTMESH_RUDP_DOCUMENT_CATALOG_CONNECTION_ID,
|
||||
))?;
|
||||
transport.connect(Vec::new())?;
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while !transport.connected() {
|
||||
let _ = transport.receive_once()?;
|
||||
transport.poll_resends()?;
|
||||
if Instant::now() >= deadline {
|
||||
bail!("Odin Atlas catalog handshake timed out")
|
||||
}
|
||||
}
|
||||
transport.send(
|
||||
"schema",
|
||||
encode_cultnet_message_to_vec(&request, CultNetWireContract::CultNetSchemaV0)?,
|
||||
)?;
|
||||
loop {
|
||||
if let Some(frame) = transport.receive_once()? {
|
||||
if frame.channel_id != "schema" {
|
||||
bail!("Odin Atlas catalog replied on an unknown channel")
|
||||
}
|
||||
return decode_cultnet_message_from_slice(
|
||||
&frame.payload,
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
);
|
||||
}
|
||||
transport.poll_resends()?;
|
||||
if Instant::now() >= deadline {
|
||||
bail!("Odin Atlas catalog snapshot timed out")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn require_id(value: &str, label: &str) -> Result<()> {
|
||||
if value.trim().is_empty() {
|
||||
bail!("{label} is empty")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
211
epiphany-core/src/bin/epiphany-agent-memory-store.rs
Normal file
211
epiphany-core/src/bin/epiphany-agent-memory-store.rs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use epiphany_core::AgentSelfPatch;
|
||||
use epiphany_core::agent_memory_status;
|
||||
use epiphany_core::apply_agent_self_patch_document;
|
||||
use epiphany_core::migrate_agent_memory_json_dir_to_cultcache;
|
||||
use epiphany_core::project_agent_memory_to_json_dir;
|
||||
use epiphany_core::project_persona_state_for_role;
|
||||
use epiphany_core::review_agent_self_patch_document;
|
||||
use epiphany_core::validate_agent_memory_store;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = env::args().skip(1);
|
||||
let Some(command) = args.next() else {
|
||||
print_usage();
|
||||
std::process::exit(2);
|
||||
};
|
||||
match command.as_str() {
|
||||
"migrate-json-dir" => {
|
||||
let agent_dir = require_path_arg(&mut args, "--agent-dir")?;
|
||||
let store = require_path_arg(&mut args, "--store")?;
|
||||
print_json(&migrate_agent_memory_json_dir_to_cultcache(
|
||||
agent_dir, store,
|
||||
)?)?;
|
||||
}
|
||||
"project-json-dir" => {
|
||||
let store = require_path_arg(&mut args, "--store")?;
|
||||
let output_dir = require_path_arg(&mut args, "--output-dir")?;
|
||||
print_json(&project_agent_memory_to_json_dir(store, output_dir)?)?;
|
||||
}
|
||||
"project-persona" => {
|
||||
let store = require_path_arg(&mut args, "--store")?;
|
||||
let role_id = require_string_arg(&mut args, "--role-id")?;
|
||||
print_json(&project_persona_state_for_role(store, &role_id)?)?;
|
||||
}
|
||||
"status" => {
|
||||
let store = require_path_arg(&mut args, "--store")?;
|
||||
print_json(&agent_memory_status(store)?)?;
|
||||
}
|
||||
"validate" => {
|
||||
let store = require_path_arg(&mut args, "--store")?;
|
||||
let errors = validate_agent_memory_store(&store)?;
|
||||
print_json(&serde_json::json!({
|
||||
"ok": errors.is_empty(),
|
||||
"store": store,
|
||||
"errors": errors,
|
||||
}))?;
|
||||
if !errors.is_empty() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
"review-patch" => {
|
||||
let store = require_path_arg(&mut args, "--store")?;
|
||||
let role_id = require_string_arg(&mut args, "--role-id")?;
|
||||
let patch = read_patch_arg(&require_string_arg(&mut args, "--patch")?)?;
|
||||
print_json(&review_agent_self_patch_document(&role_id, &patch, store))?;
|
||||
}
|
||||
"apply-patch" => {
|
||||
let store = require_path_arg(&mut args, "--store")?;
|
||||
let role_id = require_string_arg(&mut args, "--role-id")?;
|
||||
let patch = read_patch_arg(&require_string_arg(&mut args, "--patch")?)?;
|
||||
let result = apply_agent_self_patch_document(&role_id, patch, store)?;
|
||||
let accepted = result.status == "accepted";
|
||||
print_json(&result)?;
|
||||
if !accepted {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
"smoke" => {
|
||||
let store = optional_path_arg(&mut args, "--store")?
|
||||
.unwrap_or_else(|| PathBuf::from("state/agents.cc"));
|
||||
let result = run_smoke(&store)?;
|
||||
let ok = result["ok"].as_bool().unwrap_or(false);
|
||||
print_json(&result)?;
|
||||
if !ok {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
print_usage();
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_path_arg(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(require_string_arg(args, name)?))
|
||||
}
|
||||
|
||||
fn require_string_arg(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
let Some(flag) = args.next() else {
|
||||
anyhow::bail!("missing {name}");
|
||||
};
|
||||
if flag != name {
|
||||
anyhow::bail!("expected {name}, got {flag}");
|
||||
}
|
||||
args.next()
|
||||
.with_context(|| format!("missing value for {name}"))
|
||||
}
|
||||
|
||||
fn optional_path_arg(
|
||||
args: &mut impl Iterator<Item = String>,
|
||||
name: &str,
|
||||
) -> Result<Option<PathBuf>> {
|
||||
let values: Vec<String> = args.collect();
|
||||
if values.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if values.len() != 2 || values[0] != name {
|
||||
anyhow::bail!("expected optional {name} <path>, got {values:?}");
|
||||
}
|
||||
Ok(Some(PathBuf::from(&values[1])))
|
||||
}
|
||||
|
||||
fn read_patch_arg(value: &str) -> Result<AgentSelfPatch> {
|
||||
let path = PathBuf::from(value);
|
||||
if path.exists() {
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read patch {}", path.display()))?;
|
||||
return serde_json::from_str(&raw)
|
||||
.with_context(|| format!("failed to decode typed selfPatch {}", path.display()));
|
||||
}
|
||||
serde_json::from_str(value).context("failed to decode typed selfPatch JSON")
|
||||
}
|
||||
|
||||
fn print_json<T: serde::Serialize>(value: &T) -> Result<()> {
|
||||
println!("{}", serde_json::to_string_pretty(value)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: epiphany-agent-memory-store <migrate-json-dir|project-json-dir|project-persona|status|validate|review-patch|apply-patch|smoke> ..."
|
||||
);
|
||||
}
|
||||
|
||||
fn run_smoke(store: &Path) -> Result<serde_json::Value> {
|
||||
let errors = validate_agent_memory_store(store)?;
|
||||
if !errors.is_empty() {
|
||||
return Ok(serde_json::json!({
|
||||
"ok": false,
|
||||
"phase": "validate",
|
||||
"errors": errors,
|
||||
}));
|
||||
}
|
||||
let accepted_patch: AgentSelfPatch = serde_json::from_value(serde_json::json!({
|
||||
"agentId": "epiphany.proprioception",
|
||||
"reason": "Proprioception should remember accepted graph growth must be source-grounded and bounded.",
|
||||
"semanticMemories": [{
|
||||
"memoryId": "mem-body-smoke-source-grounding",
|
||||
"summary": "A modeling self-memory request is acceptable when it improves future graph/checkpoint judgment without smuggling project truth.",
|
||||
"salience": 0.74,
|
||||
"confidence": 0.86,
|
||||
}],
|
||||
}))?;
|
||||
let accepted = review_agent_self_patch_document("modeling", &accepted_patch, store);
|
||||
let wrong_role = review_agent_self_patch_document("verification", &accepted_patch, store);
|
||||
let forbidden_patch: AgentSelfPatch = serde_json::from_value(serde_json::json!({
|
||||
"agentId": "epiphany.proprioception",
|
||||
"reason": "This tries to put project state in lane memory, which should be refused.",
|
||||
"graphs": {},
|
||||
"semanticMemories": [{
|
||||
"memoryId": "mem-body-bad-project-truth",
|
||||
"summary": "Bad patch.",
|
||||
"salience": 0.5,
|
||||
"confidence": 0.5,
|
||||
}],
|
||||
}))?;
|
||||
let forbidden = review_agent_self_patch_document("modeling", &forbidden_patch, store);
|
||||
|
||||
let temp_dir = scoped_temp_dir("epiphany-agent-memory-smoke")?;
|
||||
let temp_store = temp_dir.join("agents.cc");
|
||||
fs::copy(store, &temp_store).with_context(|| {
|
||||
format!(
|
||||
"failed to copy {} to {}",
|
||||
store.display(),
|
||||
temp_store.display()
|
||||
)
|
||||
})?;
|
||||
let applied = apply_agent_self_patch_document("modeling", accepted_patch, &temp_store)?;
|
||||
let temp_validation_errors = validate_agent_memory_store(&temp_store)?;
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
let ok = accepted.status == "accepted"
|
||||
&& wrong_role.status == "rejected"
|
||||
&& forbidden.status == "rejected"
|
||||
&& applied.status == "accepted"
|
||||
&& temp_validation_errors.is_empty()
|
||||
&& project_persona_state_for_role(store, "persona")?["schemaVersion"]
|
||||
== epiphany_core::PERSONA_STATE_SCHEMA_VERSION;
|
||||
Ok(serde_json::json!({
|
||||
"ok": ok,
|
||||
"accepted": accepted,
|
||||
"wrongRole": wrong_role,
|
||||
"forbidden": forbidden,
|
||||
"applied": applied,
|
||||
"tempValidationErrors": temp_validation_errors,
|
||||
}))
|
||||
}
|
||||
|
||||
fn scoped_temp_dir(prefix: &str) -> Result<PathBuf> {
|
||||
let path = env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&path).with_context(|| format!("failed to create {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
526
epiphany-core/src/bin/epiphany-agent-telemetry.rs
Normal file
526
epiphany-core/src/bin/epiphany-agent-telemetry.rs
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use chrono::Utc;
|
||||
use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const TEXTLIKE_KEYS: &[&str] = &[
|
||||
"activeTranscript",
|
||||
"body",
|
||||
"content",
|
||||
"input",
|
||||
"inputTranscript",
|
||||
"items",
|
||||
"message",
|
||||
"note",
|
||||
"output",
|
||||
"prompt",
|
||||
"raw",
|
||||
"rawResult",
|
||||
"reasoning",
|
||||
"result",
|
||||
"summary",
|
||||
"text",
|
||||
"turns",
|
||||
];
|
||||
|
||||
const SAFE_KEYS: &[&str] = &[
|
||||
"action",
|
||||
"artifactPath",
|
||||
"backendJobId",
|
||||
"bindingId",
|
||||
"changedFields",
|
||||
"cwd",
|
||||
"ephemeral",
|
||||
"expectedRevision",
|
||||
"id",
|
||||
"jobId",
|
||||
"kind",
|
||||
"level",
|
||||
"maxRuntimeSeconds",
|
||||
"method",
|
||||
"mode",
|
||||
"path",
|
||||
"recommendedAction",
|
||||
"recommendedSceneAction",
|
||||
"revision",
|
||||
"roleId",
|
||||
"source",
|
||||
"stateStatus",
|
||||
"status",
|
||||
"targetRole",
|
||||
"threadId",
|
||||
"turnId",
|
||||
"type",
|
||||
"verdict",
|
||||
];
|
||||
|
||||
const COMMAND_VERBS: &[&str] = &[
|
||||
"rg",
|
||||
"git",
|
||||
"Get-Content",
|
||||
"Get-ChildItem",
|
||||
"Select-String",
|
||||
"Test-Path",
|
||||
"New-Item",
|
||||
"Set-Content",
|
||||
"python",
|
||||
"cargo",
|
||||
"dotnet",
|
||||
"npm",
|
||||
"node",
|
||||
"apply_patch",
|
||||
];
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = parse_args()?;
|
||||
let telemetry = build_telemetry(&args.transcript)?;
|
||||
if let Some(output) = args.output {
|
||||
if let Some(parent) = output.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
fs::write(
|
||||
&output,
|
||||
format!("{}\n", serde_json::to_string_pretty(&telemetry)?),
|
||||
)
|
||||
.with_context(|| format!("failed to write {}", output.display()))?;
|
||||
}
|
||||
println!("{}", serde_json::to_string_pretty(&telemetry)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Args {
|
||||
transcript: PathBuf,
|
||||
output: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args> {
|
||||
let mut args = env::args().skip(1);
|
||||
let Some(transcript) = args.next() else {
|
||||
return Err(anyhow!(
|
||||
"usage: epiphany-agent-telemetry <transcript> [--output <path>]"
|
||||
));
|
||||
};
|
||||
let mut output = None;
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--output" => {
|
||||
output = Some(PathBuf::from(
|
||||
args.next()
|
||||
.ok_or_else(|| anyhow!("--output requires a path"))?,
|
||||
));
|
||||
}
|
||||
_ => return Err(anyhow!("unknown argument: {arg}")),
|
||||
}
|
||||
}
|
||||
Ok(Args {
|
||||
transcript: PathBuf::from(transcript),
|
||||
output,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_telemetry(transcript_path: &Path) -> Result<Value> {
|
||||
if !transcript_path.exists() {
|
||||
return Ok(json!({
|
||||
"transcriptPath": transcript_path,
|
||||
"generatedAt": Utc::now().to_rfc3339(),
|
||||
"status": "missing",
|
||||
"events": [],
|
||||
"counts": {},
|
||||
}));
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(transcript_path)
|
||||
.with_context(|| format!("failed to read {}", transcript_path.display()))?;
|
||||
let mut events = Vec::new();
|
||||
let mut method_counts: BTreeMap<String, u64> = BTreeMap::new();
|
||||
let mut direction_counts: BTreeMap<String, u64> = BTreeMap::new();
|
||||
let mut function_counts: BTreeMap<String, u64> = BTreeMap::new();
|
||||
let mut decode_errors = Vec::new();
|
||||
|
||||
for (index, line) in raw.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let record: Value = match serde_json::from_str(line) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
decode_errors.push(json!({
|
||||
"index": index,
|
||||
"error": error.to_string(),
|
||||
"chars": line.chars().count(),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let event = match record {
|
||||
Value::Object(map) => telemetry_event(index, &map),
|
||||
other => json!({
|
||||
"index": index,
|
||||
"payload": sealed_summary("record", &other),
|
||||
}),
|
||||
};
|
||||
let direction = event
|
||||
.get("direction")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
*direction_counts.entry(direction).or_default() += 1;
|
||||
if let Some(method) = event.get("method").and_then(Value::as_str) {
|
||||
*method_counts.entry(method.to_string()).or_default() += 1;
|
||||
}
|
||||
if let Some(names) = event.get("functionNames").and_then(Value::as_array) {
|
||||
for name in names.iter().filter_map(Value::as_str) {
|
||||
*function_counts.entry(name.to_string()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
let mut request_shape: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
|
||||
for event in &events {
|
||||
let Some(event_id) = event.get("id") else {
|
||||
continue;
|
||||
};
|
||||
let Some(direction) = event.get("direction").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if direction != "sent" && direction != "received" {
|
||||
continue;
|
||||
}
|
||||
let id = value_key(event_id);
|
||||
let entry = request_shape.entry(id).or_insert_with(|| {
|
||||
BTreeMap::from([("sent".to_string(), 0), ("received".to_string(), 0)])
|
||||
});
|
||||
*entry.entry(direction.to_string()).or_default() += 1;
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"transcriptPath": transcript_path,
|
||||
"generatedAt": Utc::now().to_rfc3339(),
|
||||
"status": "ok",
|
||||
"policy": {
|
||||
"directThoughtsSealed": true,
|
||||
"note": "Telemetry summarizes function/API shape only; text, raw results, and transcript payloads are sealed.",
|
||||
},
|
||||
"counts": {
|
||||
"events": events.len(),
|
||||
"directions": direction_counts,
|
||||
"methods": method_counts,
|
||||
"functionNames": function_counts,
|
||||
"decodeErrors": decode_errors.len(),
|
||||
},
|
||||
"requestShape": request_shape,
|
||||
"decodeErrors": decode_errors,
|
||||
"events": events,
|
||||
}))
|
||||
}
|
||||
|
||||
fn telemetry_event(index: usize, record: &Map<String, Value>) -> Value {
|
||||
let (direction, payload) = if let Some(sent) = record.get("sent") {
|
||||
("sent", sent)
|
||||
} else if let Some(received) = record.get("received") {
|
||||
("received", received)
|
||||
} else {
|
||||
("unknown", &Value::Object(record.clone()))
|
||||
};
|
||||
|
||||
let mut event = Map::new();
|
||||
event.insert("index".to_string(), json!(index));
|
||||
event.insert("direction".to_string(), json!(direction));
|
||||
|
||||
let Some(payload) = payload.as_object() else {
|
||||
event.insert("payload".to_string(), sealed_summary("payload", payload));
|
||||
return Value::Object(event);
|
||||
};
|
||||
|
||||
for key in ["id", "method"] {
|
||||
if let Some(value) = payload.get(key) {
|
||||
event.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if let Some(error) = payload.get("error") {
|
||||
event.insert("error".to_string(), summarize_value(error, ""));
|
||||
}
|
||||
if let Some(params) = payload.get("params") {
|
||||
event.insert("params".to_string(), summarize_value(params, "params"));
|
||||
}
|
||||
if let Some(result) = payload.get("result") {
|
||||
event.insert(
|
||||
"result".to_string(),
|
||||
summarize_value(result, "responseResult"),
|
||||
);
|
||||
}
|
||||
|
||||
let command_telemetry = collect_command_telemetry(&Value::Object(payload.clone()));
|
||||
if !command_telemetry.is_empty() {
|
||||
event.insert(
|
||||
"commandTelemetry".to_string(),
|
||||
Value::Array(command_telemetry.iter().take(16).cloned().collect()),
|
||||
);
|
||||
event.insert(
|
||||
"commandTelemetryCount".to_string(),
|
||||
json!(command_telemetry.len()),
|
||||
);
|
||||
}
|
||||
|
||||
let names = collect_strings(
|
||||
&Value::Object(payload.clone()),
|
||||
&["toolName", "tool", "functionName", "name"],
|
||||
);
|
||||
if !names.is_empty() {
|
||||
event.insert(
|
||||
"functionNames".to_string(),
|
||||
Value::Array(names.iter().take(16).map(|s| json!(s)).collect()),
|
||||
);
|
||||
event.insert("functionNameCount".to_string(), json!(names.len()));
|
||||
}
|
||||
|
||||
let paths = collect_strings(
|
||||
&Value::Object(payload.clone()),
|
||||
&["path", "cwd", "artifactPath"],
|
||||
);
|
||||
if !paths.is_empty() {
|
||||
event.insert(
|
||||
"paths".to_string(),
|
||||
Value::Array(paths.iter().take(16).map(|s| json!(s)).collect()),
|
||||
);
|
||||
event.insert("pathCount".to_string(), json!(paths.len()));
|
||||
}
|
||||
|
||||
Value::Object(event)
|
||||
}
|
||||
|
||||
fn summarize_value(value: &Value, key: &str) -> Value {
|
||||
if let Some(scalar) = scalar_summary(key, value) {
|
||||
return scalar;
|
||||
}
|
||||
if contains(TEXTLIKE_KEYS, key) {
|
||||
return sealed_summary(key, value);
|
||||
}
|
||||
match value {
|
||||
Value::Array(items) => json!({
|
||||
"kind": "list",
|
||||
"count": items.len(),
|
||||
"items": items.iter().take(8).map(|item| summarize_value(item, "")).collect::<Vec<_>>(),
|
||||
"truncated": items.len() > 8,
|
||||
}),
|
||||
Value::Object(map) => {
|
||||
let mut result = Map::new();
|
||||
for (child_key, child_value) in map {
|
||||
let summarized =
|
||||
if contains(TEXTLIKE_KEYS, child_key) && !contains(SAFE_KEYS, child_key) {
|
||||
sealed_summary(child_key, child_value)
|
||||
} else if contains(SAFE_KEYS, child_key)
|
||||
|| child_value.is_object()
|
||||
|| child_value.is_array()
|
||||
{
|
||||
summarize_value(child_value, child_key)
|
||||
} else if let Some(text) = child_value.as_str() {
|
||||
json!({"sealed": true, "kind": "text", "chars": text.chars().count()})
|
||||
} else {
|
||||
child_value.clone()
|
||||
};
|
||||
result.insert(child_key.clone(), summarized);
|
||||
}
|
||||
Value::Object(result)
|
||||
}
|
||||
other => json!({"sealed": true, "kind": value_kind(other)}),
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar_summary(key: &str, value: &Value) -> Option<Value> {
|
||||
match value {
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) => Some(value.clone()),
|
||||
Value::String(text) if contains(SAFE_KEYS, key) => Some(json!(text)),
|
||||
Value::String(text) => {
|
||||
Some(json!({"sealed": true, "kind": "text", "chars": text.chars().count()}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sealed_summary(key: &str, value: &Value) -> Value {
|
||||
let mut summary = Map::new();
|
||||
summary.insert("sealed".to_string(), json!(true));
|
||||
summary.insert("key".to_string(), json!(key));
|
||||
summary.insert(
|
||||
"reason".to_string(),
|
||||
json!("direct agent or transcript content is excluded from telemetry"),
|
||||
);
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
summary.insert("chars".to_string(), json!(text.chars().count()));
|
||||
}
|
||||
Value::Array(items) => {
|
||||
summary.insert("items".to_string(), json!(items.len()));
|
||||
}
|
||||
Value::Object(map) => {
|
||||
let mut keys: Vec<_> = map.keys().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
summary.insert(
|
||||
"keys".to_string(),
|
||||
json!(keys.into_iter().take(24).collect::<Vec<_>>()),
|
||||
);
|
||||
summary.insert("keyCount".to_string(), json!(map.len()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Value::Object(summary)
|
||||
}
|
||||
|
||||
fn collect_strings(value: &Value, key_names: &[&str]) -> Vec<String> {
|
||||
let mut found = BTreeSet::new();
|
||||
collect_strings_into(value, key_names, &mut found);
|
||||
found.into_iter().collect()
|
||||
}
|
||||
|
||||
fn collect_strings_into(value: &Value, key_names: &[&str], found: &mut BTreeSet<String>) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
for (key, item) in map {
|
||||
if contains(key_names, key) {
|
||||
if let Some(text) = item.as_str() {
|
||||
found.insert(text.to_string());
|
||||
}
|
||||
} else if item.is_object() || item.is_array() {
|
||||
collect_strings_into(item, key_names, found);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_strings_into(item, key_names, found);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_command_telemetry(value: &Value) -> Vec<Value> {
|
||||
let mut found = Vec::new();
|
||||
collect_command_telemetry_into(value, &mut found);
|
||||
found
|
||||
}
|
||||
|
||||
fn collect_command_telemetry_into(value: &Value, found: &mut Vec<Value>) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
if map.get("type").and_then(Value::as_str) == Some("commandExecution")
|
||||
&& let Some(command) = map.get("command").and_then(Value::as_str)
|
||||
{
|
||||
let mut info = summarize_command_text(command);
|
||||
if let Some(object) = info.as_object_mut() {
|
||||
object.insert(
|
||||
"cwd".to_string(),
|
||||
map.get("cwd").cloned().unwrap_or(Value::Null),
|
||||
);
|
||||
object.insert(
|
||||
"status".to_string(),
|
||||
map.get("status").cloned().unwrap_or(Value::Null),
|
||||
);
|
||||
object.insert(
|
||||
"exitCode".to_string(),
|
||||
map.get("exitCode").cloned().unwrap_or(Value::Null),
|
||||
);
|
||||
object.insert(
|
||||
"durationMs".to_string(),
|
||||
map.get("durationMs").cloned().unwrap_or(Value::Null),
|
||||
);
|
||||
}
|
||||
found.push(info);
|
||||
}
|
||||
for item in map
|
||||
.values()
|
||||
.filter(|item| item.is_object() || item.is_array())
|
||||
{
|
||||
collect_command_telemetry_into(item, found);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_command_telemetry_into(item, found);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_command_text(command: &str) -> Value {
|
||||
let compact = command
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let verbs = COMMAND_VERBS
|
||||
.iter()
|
||||
.filter(|verb| contains_token(command, verb))
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
let has_write_verb = verbs.iter().any(|verb| {
|
||||
["New-Item", "Set-Content", "apply_patch"]
|
||||
.iter()
|
||||
.any(|write| verb.eq_ignore_ascii_case(write))
|
||||
});
|
||||
json!({
|
||||
"chars": command.chars().count(),
|
||||
"lines": std::cmp::max(command.lines().count(), 1),
|
||||
"preview": compact.chars().take(240).collect::<String>(),
|
||||
"truncated": compact.chars().count() > 240,
|
||||
"verbs": verbs,
|
||||
"hasWriteVerb": has_write_verb,
|
||||
})
|
||||
}
|
||||
|
||||
fn contains_token(haystack: &str, needle: &str) -> bool {
|
||||
let haystack = haystack.to_lowercase();
|
||||
let needle = needle.to_lowercase();
|
||||
let bytes = haystack.as_bytes();
|
||||
let mut start = 0;
|
||||
while let Some(offset) = haystack[start..].find(&needle) {
|
||||
let idx = start + offset;
|
||||
let end = idx + needle.len();
|
||||
let before_ok = idx == 0 || !is_word(bytes[idx - 1] as char);
|
||||
let after_ok = end >= bytes.len() || !is_word(bytes[end] as char);
|
||||
if before_ok && after_ok {
|
||||
return true;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn value_key(value: &Value) -> String {
|
||||
value
|
||||
.as_str()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| value.to_string())
|
||||
}
|
||||
|
||||
fn value_kind(value: &Value) -> &'static str {
|
||||
match value {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "bool",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "list",
|
||||
Value::Object(_) => "dict",
|
||||
}
|
||||
}
|
||||
|
||||
fn contains(keys: &[&str], key: &str) -> bool {
|
||||
keys.iter().any(|candidate| candidate == &key)
|
||||
}
|
||||
|
||||
fn is_word(ch: char) -> bool {
|
||||
ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'
|
||||
}
|
||||
930
epiphany-core/src/bin/epiphany-character-loop.rs
Normal file
930
epiphany-core/src/bin/epiphany-character-loop.rs
Normal file
|
|
@ -0,0 +1,930 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use chrono::SecondsFormat;
|
||||
use epiphany_core::EpiphanyAgentMemoryEntry;
|
||||
use epiphany_core::GhostlightPerceivedStateOverlay;
|
||||
use epiphany_core::GhostlightTraitVector;
|
||||
use epiphany_core::HeartbeatAgentReaction;
|
||||
use epiphany_core::HeartbeatAgentThoughtAppraisal;
|
||||
use epiphany_core::HeartbeatAppraisalReview;
|
||||
use epiphany_core::HeartbeatCandidateImplications;
|
||||
use epiphany_core::HeartbeatEmotionalAppraisal;
|
||||
use epiphany_core::HeartbeatParticipant;
|
||||
use epiphany_core::HeartbeatParticipantLocalContext;
|
||||
use epiphany_core::HeartbeatPersonalityProjection;
|
||||
use epiphany_core::derive_agent_utterance_state;
|
||||
use epiphany_core::load_agent_memory_entry_for_role;
|
||||
use epiphany_core::organ_state_profile_for_role;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
const CHARACTER_TURN_SCHEMA_VERSION: &str = "epiphany.character_turn_packet.v0";
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = env::args().skip(1);
|
||||
let Some(command) = args.next() else {
|
||||
return usage();
|
||||
};
|
||||
let mut role = "persona".to_string();
|
||||
let mut agent_store = PathBuf::from("state/agents.cc");
|
||||
let mut artifact_dir = PathBuf::from(".epiphany-character-loop");
|
||||
let mut stimulus: Option<String> = None;
|
||||
let mut source = "epiphany/character-loop".to_string();
|
||||
let mut mode = "public-surface".to_string();
|
||||
let mut status = "ready".to_string();
|
||||
let mut mood = "attentive".to_string();
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--role" => role = next_value(&mut args, "--role")?,
|
||||
"--agent-store" => agent_store = next_path(&mut args, "--agent-store")?,
|
||||
"--artifact-dir" => artifact_dir = next_path(&mut args, "--artifact-dir")?,
|
||||
"--stimulus" => stimulus = Some(next_value(&mut args, "--stimulus")?),
|
||||
"--source" => source = next_value(&mut args, "--source")?,
|
||||
"--mode" => mode = next_value(&mut args, "--mode")?,
|
||||
"--status" => status = next_value(&mut args, "--status")?,
|
||||
"--mood" => mood = next_value(&mut args, "--mood")?,
|
||||
_ => return Err(anyhow!("unknown argument {arg:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
let result = match command.as_str() {
|
||||
"turn" => {
|
||||
let stimulus = read_required_text(stimulus, "stimulus")?;
|
||||
run_turn(
|
||||
&role,
|
||||
&agent_store,
|
||||
&artifact_dir,
|
||||
&stimulus,
|
||||
&source,
|
||||
&mode,
|
||||
&status,
|
||||
&mood,
|
||||
)?
|
||||
}
|
||||
"smoke" => run_smoke()?,
|
||||
_ => return usage(),
|
||||
};
|
||||
|
||||
let ok = result["ok"].as_bool().unwrap_or(false);
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
if !ok {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_turn(
|
||||
role: &str,
|
||||
agent_store: &Path,
|
||||
artifact_dir: &Path,
|
||||
stimulus: &str,
|
||||
source: &str,
|
||||
mode: &str,
|
||||
status: &str,
|
||||
mood: &str,
|
||||
) -> Result<Value> {
|
||||
if role.trim().is_empty() {
|
||||
return Err(anyhow!("--role must not be empty"));
|
||||
}
|
||||
ensure_content(stimulus, "stimulus")?;
|
||||
let entry = load_agent_memory_entry_for_role(agent_store, role)?.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"{role} agent memory is missing from {}",
|
||||
agent_store.display()
|
||||
)
|
||||
})?;
|
||||
let packet = character_turn_packet(&entry, stimulus, source, mode, status, mood);
|
||||
let path = artifact_dir.join(format!(
|
||||
"character-turn-{}-{}-{}.json",
|
||||
sanitize_file_stem(role),
|
||||
now_stamp(),
|
||||
short_id()
|
||||
));
|
||||
write_json(&path, &packet)?;
|
||||
Ok(serde_json::json!({
|
||||
"ok": true,
|
||||
"schemaVersion": CHARACTER_TURN_SCHEMA_VERSION,
|
||||
"roleId": role,
|
||||
"agentId": entry.agent.agent_id,
|
||||
"turnPath": path,
|
||||
"turn": packet,
|
||||
}))
|
||||
}
|
||||
|
||||
fn character_turn_packet(
|
||||
entry: &EpiphanyAgentMemoryEntry,
|
||||
stimulus: &str,
|
||||
source: &str,
|
||||
mode: &str,
|
||||
status: &str,
|
||||
mood: &str,
|
||||
) -> Value {
|
||||
let organ_state_profile = organ_state_profile_for_role(&entry.role_id);
|
||||
let participant = character_loop_participant(entry, status);
|
||||
let utterance_state =
|
||||
derive_agent_utterance_state(entry, Some(&participant), Some(mood), source);
|
||||
let projection_seed =
|
||||
deterministic_projection_seed(entry, stimulus, source, mode, status, mood);
|
||||
let appraisal_seed = deterministic_appraisal_seed(entry, &projection_seed, stimulus, source);
|
||||
let reaction_seed = deterministic_reaction_seed(&entry.role_id, &appraisal_seed);
|
||||
serde_json::json!({
|
||||
"schema_version": CHARACTER_TURN_SCHEMA_VERSION,
|
||||
"protocol": {
|
||||
"bundle": "epiphany.character_loop",
|
||||
"referenceLineage": "Ghostlight-style character-local cognition, rebuilt as Epiphany-native protocol.",
|
||||
"mode": mode,
|
||||
"roleId": entry.role_id,
|
||||
"agentId": entry.agent.agent_id,
|
||||
"organStateProfile": organ_state_profile,
|
||||
"turnKind": "stimulus_response",
|
||||
"responseContract": "Respond from projected local state and visible stimulus; do not leak hidden agent streams or invent omniscient truth.",
|
||||
},
|
||||
"source": source,
|
||||
"createdAt": now_iso(),
|
||||
"stimulus": {
|
||||
"kind": "operator_or_aquarium_input",
|
||||
"content": stimulus,
|
||||
"status": status,
|
||||
"mood": mood,
|
||||
},
|
||||
"utteranceState": utterance_state,
|
||||
"projectedLocalContext": {
|
||||
"identity": {
|
||||
"name": entry.agent.identity.name,
|
||||
"roles": entry.agent.identity.roles,
|
||||
"origin": entry.agent.identity.origin,
|
||||
"publicDescription": entry.agent.identity.public_description,
|
||||
},
|
||||
"world": {
|
||||
"worldId": entry.world.world_id,
|
||||
"setting": entry.world.setting,
|
||||
"time": entry.world.time.label,
|
||||
"canonContext": entry.world.canon_context,
|
||||
},
|
||||
"organStateProfile": organ_state_profile_for_role(&entry.role_id),
|
||||
"activeGoals": entry.agent.goals.iter().map(|goal| serde_json::json!({
|
||||
"goalId": goal.goal_id,
|
||||
"description": goal.description,
|
||||
"scope": goal.scope,
|
||||
"priority": goal.priority,
|
||||
"emotionalStake": goal.emotional_stake,
|
||||
"status": goal.status,
|
||||
})).collect::<Vec<_>>(),
|
||||
"values": entry.agent.canonical_state.values.iter().map(|value| serde_json::json!({
|
||||
"valueId": value.value_id,
|
||||
"label": value.label,
|
||||
"priority": value.priority,
|
||||
"unforgivableIfBetrayed": value.unforgivable_if_betrayed,
|
||||
})).collect::<Vec<_>>(),
|
||||
"perceivedStateOverlays": entry.agent.perceived_state_overlays.iter().take(6).map(perceived_overlay_projection).collect::<Vec<_>>(),
|
||||
"privateNoteCount": entry.agent.identity.private_notes.len(),
|
||||
},
|
||||
"projectionSeed": projection_seed,
|
||||
"appraisalSeed": appraisal_seed,
|
||||
"reactionSeed": reaction_seed,
|
||||
"allowedOutputs": [
|
||||
"bubble",
|
||||
"discordAquariumDraft",
|
||||
"roleFinding",
|
||||
"silence",
|
||||
"selfPatch"
|
||||
],
|
||||
"cognitionLanes": {
|
||||
"schema_version": "epiphany.cognition_lanes.v0",
|
||||
"analytic": {
|
||||
"description": "Literal, evidence-facing lane: what happened, what constraints apply, what action is justified.",
|
||||
"requiredThreadShape": {
|
||||
"topic": "string",
|
||||
"claim": "string",
|
||||
"evidenceRefs": "string[]",
|
||||
"confidence": "0..1",
|
||||
"counterweight": "string"
|
||||
}
|
||||
},
|
||||
"associative": {
|
||||
"description": "Pattern-facing lane: what this rhymes with, what older seam or emotional salience may matter, what should incubate.",
|
||||
"requiredThreadShape": {
|
||||
"topic": "string",
|
||||
"claim": "string",
|
||||
"sourceThemeIds": "string[]",
|
||||
"novelty": "0..1",
|
||||
"counterweight": "string"
|
||||
}
|
||||
},
|
||||
"bridge": {
|
||||
"schema_version": "epiphany.cognition_bridge.v0",
|
||||
"contract": "Synthesize or separate the lanes, name saturation and unresolved tension, then choose bubble, draft, finding, silence, or selfPatch. The bridge may steer attention; it may not mutate project truth."
|
||||
},
|
||||
"appraisal": {
|
||||
"schema_version": "epiphany.agent_thought_appraisal.v0",
|
||||
"contract": "Project the active thought cluster through this actor's own personality vectors before reacting. Interpretation, emotion, and reaction are participant-local; state mutation still requires reviewed selfPatch or project-state acceptance."
|
||||
}
|
||||
},
|
||||
"guardrails": [
|
||||
"Humans talk to Persona; other organs expose internals through Aquarium and typed artifacts rather than becoming direct chats.",
|
||||
"Use character-local projection: the actor receives its own organ or Persona state and visible stimulus, not omniscient hidden state.",
|
||||
"Run both cognition lanes before choosing an output: analytic keeps the promise honest, associative keeps the living signal from going flat.",
|
||||
"Appraise the thought cluster through this role's personality before reacting; do not use a global mood knob as a substitute for participant-local appraisal.",
|
||||
"If speech or action would be noise, return silence plus bounded rumination or selfPatch.",
|
||||
"Posting, state mutation, objective adoption, and semantic acceptance remain separate reviewed Epiphany surfaces."
|
||||
],
|
||||
"resultShape": {
|
||||
"observedStimulus": "string",
|
||||
"localInterpretation": "string",
|
||||
"candidateBubble": "string|null",
|
||||
"candidateDraft": "string|null",
|
||||
"analyticThread": "object",
|
||||
"associativeThread": "object",
|
||||
"bridgeSynthesis": "object",
|
||||
"appraisal": "object",
|
||||
"reaction": "object",
|
||||
"decision": "bubble|draft|finding|silence|blocked",
|
||||
"selfPatch": "optional bounded role memory mutation"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn character_loop_participant(
|
||||
entry: &EpiphanyAgentMemoryEntry,
|
||||
status: &str,
|
||||
) -> HeartbeatParticipant {
|
||||
HeartbeatParticipant {
|
||||
agent_id: entry.agent.agent_id.clone(),
|
||||
role_id: entry.role_id.clone(),
|
||||
display_name: entry.agent.identity.name.clone(),
|
||||
initiative_speed: 1.0,
|
||||
reaction_bias: 0.5,
|
||||
interrupt_threshold: 0.5,
|
||||
status: status.to_string(),
|
||||
personality_cooldown_multiplier: 1.0,
|
||||
mood_cooldown_multiplier: 1.0,
|
||||
initiative_heat_multiplier: 1.0,
|
||||
..HeartbeatParticipant::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct TraitSignal {
|
||||
group: &'static str,
|
||||
name: String,
|
||||
activation: f64,
|
||||
plasticity: f64,
|
||||
weight: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CharacterProjectionSeed {
|
||||
#[serde(rename = "schema_version")]
|
||||
schema_version: String,
|
||||
participant_agent_id: String,
|
||||
role_id: String,
|
||||
thought_cluster_ref: String,
|
||||
thought_tokens: Vec<String>,
|
||||
dominant_trait_matches: Vec<HeartbeatPersonalityProjection>,
|
||||
relationship_pressure: CharacterPressureSeed,
|
||||
perceived_overlay_pressure: CharacterPressureSeed,
|
||||
contract: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CharacterPressureSeed {
|
||||
strength: f64,
|
||||
summary: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CharacterReactionSeed {
|
||||
#[serde(rename = "schema_version")]
|
||||
schema_version: String,
|
||||
#[serde(flatten)]
|
||||
reaction: HeartbeatAgentReaction,
|
||||
contract: String,
|
||||
}
|
||||
|
||||
fn deterministic_projection_seed(
|
||||
entry: &EpiphanyAgentMemoryEntry,
|
||||
stimulus: &str,
|
||||
source: &str,
|
||||
mode: &str,
|
||||
status: &str,
|
||||
mood: &str,
|
||||
) -> CharacterProjectionSeed {
|
||||
let thought_tokens = stimulus_token_set(stimulus, source, mode, status, mood);
|
||||
let traits = collect_trait_signals(entry);
|
||||
let mut scored = traits
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let trait_tokens = summary_tokens(&format!("{} {}", item.group, item.name));
|
||||
let overlap = token_overlap(&trait_tokens, &thought_tokens);
|
||||
let projection = round3((item.weight * (0.55 + overlap * 1.8)).clamp(0.0, 1.0));
|
||||
HeartbeatPersonalityProjection {
|
||||
group: item.group.to_string(),
|
||||
name: item.name.clone(),
|
||||
activation: round3(item.activation),
|
||||
plasticity: round3(item.plasticity),
|
||||
token_overlap: round3(overlap),
|
||||
projection,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
scored.sort_by(|left, right| right.projection.total_cmp(&left.projection));
|
||||
scored.truncate(6);
|
||||
|
||||
let (relationship_pressure, relationship_summary) =
|
||||
strongest_relationship_pressure(entry, &thought_tokens);
|
||||
let (overlay_pressure, overlay_summary) = strongest_overlay_pressure(entry, &thought_tokens);
|
||||
|
||||
CharacterProjectionSeed {
|
||||
schema_version: "epiphany.personality_projection.v0".to_string(),
|
||||
participant_agent_id: entry.agent.agent_id.clone(),
|
||||
role_id: entry.role_id.clone(),
|
||||
thought_cluster_ref: format!("{}#stimulus", source),
|
||||
thought_tokens: thought_tokens.into_iter().collect::<Vec<_>>(),
|
||||
dominant_trait_matches: scored,
|
||||
relationship_pressure: CharacterPressureSeed {
|
||||
strength: round3(relationship_pressure),
|
||||
summary: relationship_summary,
|
||||
},
|
||||
perceived_overlay_pressure: CharacterPressureSeed {
|
||||
strength: round3(overlay_pressure),
|
||||
summary: overlay_summary,
|
||||
},
|
||||
contract:
|
||||
"Projection is participant-local and fallible. It is a seed for embodied response, not reviewed project truth."
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn deterministic_appraisal_seed(
|
||||
entry: &EpiphanyAgentMemoryEntry,
|
||||
projection_seed: &CharacterProjectionSeed,
|
||||
stimulus: &str,
|
||||
source: &str,
|
||||
) -> HeartbeatAgentThoughtAppraisal {
|
||||
let traits = collect_trait_signals(entry);
|
||||
let reactivity = average(traits.iter().take(8).map(|item| item.activation)).unwrap_or(0.5);
|
||||
let plasticity = average(traits.iter().take(8).map(|item| item.plasticity)).unwrap_or(0.5);
|
||||
let expressiveness = average(
|
||||
traits
|
||||
.iter()
|
||||
.filter(|item| item.group == "voice_style" || item.group == "presentation_strategy")
|
||||
.map(|item| item.activation),
|
||||
)
|
||||
.unwrap_or(reactivity);
|
||||
let guarded_baseline = average(
|
||||
traits
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
let name = item.name.as_str();
|
||||
name.contains("guard")
|
||||
|| name.contains("shame")
|
||||
|| name.contains("risk")
|
||||
|| name.contains("caution")
|
||||
|| name.contains("contingent")
|
||||
|| name.contains("defens")
|
||||
|| name.contains("susp")
|
||||
})
|
||||
.map(|item| item.activation),
|
||||
)
|
||||
.unwrap_or((1.0 - expressiveness * 0.45).clamp(0.0, 1.0));
|
||||
let alignment = projection_seed
|
||||
.dominant_trait_matches
|
||||
.first()
|
||||
.map(|item| item.projection)
|
||||
.unwrap_or(reactivity);
|
||||
let relationship_pressure = projection_seed.relationship_pressure.strength;
|
||||
let overlay_pressure = projection_seed.perceived_overlay_pressure.strength;
|
||||
let thought_pressure = (0.18
|
||||
+ (summary_tokens(stimulus).len() as f64 / 18.0).min(0.42)
|
||||
+ relationship_pressure * 0.22
|
||||
+ overlay_pressure * 0.18)
|
||||
.clamp(0.0, 1.0);
|
||||
let arousal =
|
||||
round3((thought_pressure * (0.35 + reactivity * 0.45 + plasticity * 0.2)).clamp(0.0, 1.0));
|
||||
let guardedness = round3(
|
||||
(guarded_baseline * 0.58
|
||||
+ relationship_pressure * 0.18
|
||||
+ overlay_pressure * 0.14
|
||||
+ (1.0 - alignment) * 0.10)
|
||||
.clamp(0.0, 1.0),
|
||||
);
|
||||
let curiosity =
|
||||
round3(((1.0 - guardedness) * 0.22 + alignment * 0.46 + plasticity * 0.32).clamp(0.0, 1.0));
|
||||
let urgency =
|
||||
round3((arousal * 0.58 + guardedness * 0.22 + thought_pressure * 0.20).clamp(0.0, 1.0));
|
||||
let valence = round3((0.52 + curiosity * 0.22 - guardedness * 0.20).clamp(0.0, 1.0));
|
||||
let label = interpretation_label("draft", arousal, guardedness, curiosity);
|
||||
HeartbeatAgentThoughtAppraisal {
|
||||
schema_version: "epiphany.agent_thought_appraisal.v0".to_string(),
|
||||
appraisal_id: format!("persona-appraisal-{}", short_id()),
|
||||
review_status: "generated_unreviewed".to_string(),
|
||||
participant_agent_id: entry.agent.agent_id.clone(),
|
||||
role_id: entry.role_id.clone(),
|
||||
current_character_state_ref: format!("state/agents.cc#{}", entry.role_id),
|
||||
thought_cluster_ref: format!("{}#stimulus", source),
|
||||
participant_local_context: HeartbeatParticipantLocalContext {
|
||||
display_name: entry.agent.identity.name.clone(),
|
||||
values: entry
|
||||
.agent
|
||||
.canonical_state
|
||||
.values
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|value| value.label.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
reactivity: round3(reactivity),
|
||||
plasticity: round3(plasticity),
|
||||
expressiveness: round3(expressiveness),
|
||||
guardedness: round3(guarded_baseline),
|
||||
},
|
||||
observable_thought_summary: stimulus.to_string(),
|
||||
personality_projection: projection_seed.dominant_trait_matches.clone(),
|
||||
interpretation: format!(
|
||||
"{} appraises the visible stimulus through its local personality, relationship memory, and perceived overlays; this is embodied response guidance, not omniscient truth.",
|
||||
entry.agent.identity.name
|
||||
),
|
||||
emotional_appraisal: HeartbeatEmotionalAppraisal {
|
||||
valence,
|
||||
arousal,
|
||||
urgency,
|
||||
curiosity,
|
||||
guardedness,
|
||||
thought_pressure: round3(thought_pressure),
|
||||
},
|
||||
interpretation_label: label.clone(),
|
||||
confidence_notes: "Deterministic Persona-local appraisal rebuilt from the same Ghostlight-style personality projection logic used by heartbeat. Useful for reaction shape, still reviewable and fallible.".to_string(),
|
||||
candidate_implications: HeartbeatCandidateImplications {
|
||||
reaction_mode: reaction_mode(&label, "draft").to_string(),
|
||||
reaction_intensity: round3(
|
||||
(urgency * 0.55 + arousal * 0.3 + curiosity * 0.15).clamp(0.0, 1.0),
|
||||
),
|
||||
should_speak: entry.role_id == "persona" && guardedness < 0.78,
|
||||
should_incubate: guardedness >= 0.55,
|
||||
},
|
||||
review: HeartbeatAppraisalReview {
|
||||
accepted_for_mutation: false,
|
||||
rationale:
|
||||
"Appraisal may steer expression and reaction; state mutation still requires explicit review."
|
||||
.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn deterministic_reaction_seed(
|
||||
role_id: &str,
|
||||
appraisal_seed: &HeartbeatAgentThoughtAppraisal,
|
||||
) -> CharacterReactionSeed {
|
||||
let arousal = appraisal_seed.emotional_appraisal.arousal;
|
||||
let guardedness = appraisal_seed.emotional_appraisal.guardedness;
|
||||
let curiosity = appraisal_seed.emotional_appraisal.curiosity;
|
||||
let mode = appraisal_seed.candidate_implications.reaction_mode.as_str();
|
||||
let intensity = appraisal_seed.candidate_implications.reaction_intensity;
|
||||
CharacterReactionSeed {
|
||||
schema_version: "epiphany.agent_reaction.v0".to_string(),
|
||||
reaction: HeartbeatAgentReaction {
|
||||
reaction_id: format!("reaction-{}-{}", role_id, short_id()),
|
||||
role_id: role_id.to_string(),
|
||||
participant_agent_id: appraisal_seed.participant_agent_id.clone(),
|
||||
appraisal_id: appraisal_seed.appraisal_id.clone(),
|
||||
mode: mode.to_string(),
|
||||
mood_label: mood_label(arousal, guardedness, curiosity).to_string(),
|
||||
intensity: round3(intensity),
|
||||
bridge_decision: "draft".to_string(),
|
||||
surface: if role_id == "persona" {
|
||||
"aquarium"
|
||||
} else {
|
||||
"internal"
|
||||
}
|
||||
.to_string(),
|
||||
recommended_use: reaction_recommended_use(role_id, mode).to_string(),
|
||||
},
|
||||
contract: "Reaction is derived from local appraisal and may color speech or silence; it still does not mutate durable state by itself.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_trait_signals(entry: &EpiphanyAgentMemoryEntry) -> Vec<TraitSignal> {
|
||||
let mut traits = Vec::new();
|
||||
collect_trait_group(
|
||||
&mut traits,
|
||||
"underlying_organization",
|
||||
&entry.agent.canonical_state.underlying_organization,
|
||||
);
|
||||
collect_trait_group(
|
||||
&mut traits,
|
||||
"stable_dispositions",
|
||||
&entry.agent.canonical_state.stable_dispositions,
|
||||
);
|
||||
collect_trait_group(
|
||||
&mut traits,
|
||||
"behavioral_dimensions",
|
||||
&entry.agent.canonical_state.behavioral_dimensions,
|
||||
);
|
||||
collect_trait_group(
|
||||
&mut traits,
|
||||
"presentation_strategy",
|
||||
&entry.agent.canonical_state.presentation_strategy,
|
||||
);
|
||||
collect_trait_group(
|
||||
&mut traits,
|
||||
"voice_style",
|
||||
&entry.agent.canonical_state.voice_style,
|
||||
);
|
||||
collect_trait_group(
|
||||
&mut traits,
|
||||
"situational_state",
|
||||
&entry.agent.canonical_state.situational_state,
|
||||
);
|
||||
traits.sort_by(|left, right| right.weight.total_cmp(&left.weight));
|
||||
traits
|
||||
}
|
||||
|
||||
fn collect_trait_group(
|
||||
traits: &mut Vec<TraitSignal>,
|
||||
group: &'static str,
|
||||
source: &BTreeMap<String, GhostlightTraitVector>,
|
||||
) {
|
||||
for (name, vector) in source {
|
||||
let activation = vector.current_activation.clamp(0.0, 1.0);
|
||||
let plasticity = vector.plasticity.clamp(0.0, 1.0);
|
||||
traits.push(TraitSignal {
|
||||
group,
|
||||
name: name.clone(),
|
||||
activation,
|
||||
plasticity,
|
||||
weight: round3(activation * (0.65 + plasticity * 0.35)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn strongest_relationship_pressure(
|
||||
entry: &EpiphanyAgentMemoryEntry,
|
||||
thought_tokens: &BTreeSet<String>,
|
||||
) -> (f64, String) {
|
||||
entry
|
||||
.agent
|
||||
.memories
|
||||
.relationship_summaries
|
||||
.iter()
|
||||
.map(|memory| {
|
||||
let overlap = token_overlap(&summary_tokens(&memory.summary), thought_tokens);
|
||||
let strength = overlap * (memory.salience * memory.confidence);
|
||||
(strength, memory.summary.clone())
|
||||
})
|
||||
.max_by(|left, right| left.0.total_cmp(&right.0))
|
||||
.map(|(strength, summary)| (round3(strength.clamp(0.0, 1.0)), summary))
|
||||
.unwrap_or((
|
||||
0.0,
|
||||
"No strong relationship pressure is active.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn strongest_overlay_pressure(
|
||||
entry: &EpiphanyAgentMemoryEntry,
|
||||
thought_tokens: &BTreeSet<String>,
|
||||
) -> (f64, String) {
|
||||
entry
|
||||
.agent
|
||||
.perceived_state_overlays
|
||||
.iter()
|
||||
.map(|overlay| {
|
||||
let tokens = overlay_tokens(overlay);
|
||||
let overlap = token_overlap(&tokens, thought_tokens);
|
||||
let summary = overlay_summary(overlay);
|
||||
(overlap, summary)
|
||||
})
|
||||
.max_by(|left, right| left.0.total_cmp(&right.0))
|
||||
.map(|(strength, summary)| (round3(strength.clamp(0.0, 1.0)), summary))
|
||||
.unwrap_or((
|
||||
0.0,
|
||||
"No perceived overlay is tugging on this stimulus.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn stimulus_token_set(
|
||||
stimulus: &str,
|
||||
source: &str,
|
||||
mode: &str,
|
||||
status: &str,
|
||||
mood: &str,
|
||||
) -> BTreeSet<String> {
|
||||
let mut tokens = BTreeSet::new();
|
||||
tokens.extend(summary_tokens(stimulus));
|
||||
tokens.extend(summary_tokens(source));
|
||||
tokens.extend(summary_tokens(mode));
|
||||
tokens.extend(summary_tokens(status));
|
||||
tokens.extend(summary_tokens(mood));
|
||||
tokens
|
||||
}
|
||||
|
||||
fn perceived_overlay_projection(overlay: &GhostlightPerceivedStateOverlay) -> Value {
|
||||
serde_json::json!({
|
||||
"overlayId": overlay.overlay_id,
|
||||
"label": overlay.label,
|
||||
"summary": overlay.summary,
|
||||
"source": overlay.source,
|
||||
"salience": overlay.salience,
|
||||
"confidence": overlay.confidence,
|
||||
"linkedMemoryIds": overlay.linked_memory_ids,
|
||||
})
|
||||
}
|
||||
|
||||
fn overlay_tokens(overlay: &GhostlightPerceivedStateOverlay) -> BTreeSet<String> {
|
||||
let mut tokens = BTreeSet::new();
|
||||
tokens.extend(summary_tokens(&overlay.overlay_id));
|
||||
tokens.extend(summary_tokens(&overlay.label));
|
||||
tokens.extend(summary_tokens(&overlay.summary));
|
||||
tokens.extend(summary_tokens(&overlay.source));
|
||||
for memory_id in &overlay.linked_memory_ids {
|
||||
tokens.extend(summary_tokens(memory_id));
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
fn overlay_summary(overlay: &GhostlightPerceivedStateOverlay) -> String {
|
||||
if !overlay.summary.trim().is_empty() {
|
||||
overlay.summary.clone()
|
||||
} else if !overlay.label.trim().is_empty() {
|
||||
overlay.label.clone()
|
||||
} else {
|
||||
"Perceived overlay relevance".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn summary_tokens(text: &str) -> BTreeSet<String> {
|
||||
text.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_' && ch != '-')
|
||||
.filter_map(|item| {
|
||||
let token = item.trim().to_ascii_lowercase().replace('_', "-");
|
||||
(token.len() >= 3).then_some(token)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn token_overlap(left: &BTreeSet<String>, right: &BTreeSet<String>) -> f64 {
|
||||
if left.is_empty() || right.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let shared = left.intersection(right).count() as f64;
|
||||
let denom = left.len().min(right.len()) as f64;
|
||||
if denom <= f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
(shared / denom).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn average<I>(values: I) -> Option<f64>
|
||||
where
|
||||
I: Iterator<Item = f64>,
|
||||
{
|
||||
let mut total = 0.0_f64;
|
||||
let mut count = 0_usize;
|
||||
for value in values {
|
||||
total += value;
|
||||
count += 1;
|
||||
}
|
||||
(count > 0).then_some(total / count as f64)
|
||||
}
|
||||
|
||||
fn interpretation_label(
|
||||
bridge_decision: &str,
|
||||
arousal: f64,
|
||||
guardedness: f64,
|
||||
curiosity: f64,
|
||||
) -> String {
|
||||
if guardedness > 0.72 && arousal > 0.35 {
|
||||
"protective_appraisal".to_string()
|
||||
} else if curiosity > 0.68 {
|
||||
"investigative_appraisal".to_string()
|
||||
} else if bridge_decision == "draft" {
|
||||
"expressive_appraisal".to_string()
|
||||
} else if arousal < 0.12 {
|
||||
"low_pressure_appraisal".to_string()
|
||||
} else {
|
||||
"incubating_appraisal".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn reaction_mode(label: &str, bridge_decision: &str) -> &'static str {
|
||||
match (label, bridge_decision) {
|
||||
("protective_appraisal", _) => "hold_and_verify",
|
||||
("investigative_appraisal", _) => "inspect",
|
||||
("expressive_appraisal", "draft") => "draft",
|
||||
("low_pressure_appraisal", _) => "sleep_ruminate",
|
||||
_ => "incubate",
|
||||
}
|
||||
}
|
||||
|
||||
fn mood_label(arousal: f64, guardedness: f64, curiosity: f64) -> &'static str {
|
||||
if guardedness > 0.72 && arousal > 0.35 {
|
||||
"wary"
|
||||
} else if curiosity > 0.68 && arousal > 0.25 {
|
||||
"keen"
|
||||
} else if arousal < 0.12 {
|
||||
"drowsy"
|
||||
} else if guardedness > curiosity {
|
||||
"watchful"
|
||||
} else {
|
||||
"interested"
|
||||
}
|
||||
}
|
||||
|
||||
fn reaction_recommended_use(role_id: &str, mode: &str) -> &'static str {
|
||||
match (role_id, mode) {
|
||||
("persona", "draft") => {
|
||||
"Prepare a reviewed Aquarium-facing draft; do not post automatically."
|
||||
}
|
||||
(_, "hold_and_verify") => "Bias toward verifier/modeler review before expression.",
|
||||
(_, "inspect") => {
|
||||
"Bias the next heartbeat toward a bounded retrieval or modeling inspection."
|
||||
}
|
||||
(_, "sleep_ruminate") => "Let this organ sleep-ruminate unless real work arrives.",
|
||||
_ => "Keep the thought incubating and visible in Aquarium.",
|
||||
}
|
||||
}
|
||||
|
||||
fn round3(value: f64) -> f64 {
|
||||
(value.clamp(0.0, 1.0) * 1000.0).round() / 1000.0
|
||||
}
|
||||
|
||||
fn run_smoke() -> Result<Value> {
|
||||
let temp_dir = scoped_temp_dir("epiphany-character-loop-smoke")?;
|
||||
let artifact_dir = temp_dir.join("artifacts");
|
||||
let result = run_turn(
|
||||
"persona",
|
||||
Path::new("state/agents.cc"),
|
||||
&artifact_dir,
|
||||
"Aquarium hover selected Persona and asked what the swarm is feeling.",
|
||||
"smoke/character-loop",
|
||||
"public-surface",
|
||||
"ready",
|
||||
"attentive",
|
||||
)?;
|
||||
let path = PathBuf::from(
|
||||
result["turnPath"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("smoke result missing turnPath"))?,
|
||||
);
|
||||
let packet: Value = serde_json::from_str(
|
||||
&fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read smoke artifact {}", path.display()))?,
|
||||
)?;
|
||||
let ok = packet["schema_version"] == CHARACTER_TURN_SCHEMA_VERSION
|
||||
&& packet["protocol"]["bundle"] == "epiphany.character_loop"
|
||||
&& packet["protocol"]["roleId"] == "persona"
|
||||
&& packet["protocol"]["organStateProfile"]["profileKind"] == "persona"
|
||||
&& packet["projectedLocalContext"]["organStateProfile"]["profileKind"] == "persona"
|
||||
&& packet["utteranceState"]["schemaVersion"] == "epiphany.agent_utterance_state.v0"
|
||||
&& packet["utteranceState"]["identity"]["name"]
|
||||
== packet["projectedLocalContext"]["identity"]["name"]
|
||||
&& packet["utteranceState"]["personalityVectors"]["voiceStyle"].is_object()
|
||||
&& packet["utteranceState"]["characterStateVector"]["compatibleHandoffSchema"]
|
||||
== "weksa.utterance_embedding_handoff.v0.1"
|
||||
&& packet["utteranceState"]["characterStateVector"]["values"]
|
||||
.as_array()
|
||||
.is_some_and(|values| values.len() == 64)
|
||||
&& packet["projectedLocalContext"]
|
||||
.get("semanticMemories")
|
||||
.is_none()
|
||||
&& packet["projectedLocalContext"]
|
||||
.get("episodicMemories")
|
||||
.is_none()
|
||||
&& packet["projectedLocalContext"]
|
||||
.get("relationshipMemories")
|
||||
.is_none()
|
||||
&& packet["projectionSeed"]["schema_version"] == "epiphany.personality_projection.v0"
|
||||
&& packet["appraisalSeed"]["schema_version"] == "epiphany.agent_thought_appraisal.v0"
|
||||
&& packet["reactionSeed"]["schema_version"] == "epiphany.agent_reaction.v0"
|
||||
&& packet["projectedLocalContext"]["identity"]["name"]
|
||||
.as_str()
|
||||
.is_some_and(|name| !name.trim().is_empty())
|
||||
&& packet["stimulus"]["content"]
|
||||
.as_str()
|
||||
.is_some_and(|text| text.contains("Aquarium"))
|
||||
&& packet["cognitionLanes"]["schema_version"] == "epiphany.cognition_lanes.v0"
|
||||
&& packet["cognitionLanes"]["bridge"]["schema_version"] == "epiphany.cognition_bridge.v0"
|
||||
&& packet["cognitionLanes"]["appraisal"]["schema_version"]
|
||||
== "epiphany.agent_thought_appraisal.v0";
|
||||
Ok(serde_json::json!({
|
||||
"ok": ok,
|
||||
"turnPath": path,
|
||||
"packet": {
|
||||
"schemaVersion": packet["schema_version"],
|
||||
"bundle": packet["protocol"]["bundle"],
|
||||
"roleId": packet["protocol"]["roleId"],
|
||||
"agentId": packet["protocol"]["agentId"],
|
||||
"organStateProfile": packet["protocol"]["organStateProfile"]["profileKind"],
|
||||
"projectionSchema": packet["projectionSeed"]["schema_version"],
|
||||
"appraisalSchema": packet["appraisalSeed"]["schema_version"],
|
||||
"reactionSchema": packet["reactionSeed"]["schema_version"],
|
||||
"utteranceStateSchema": packet["utteranceState"]["schemaVersion"],
|
||||
"identityName": packet["projectedLocalContext"]["identity"]["name"],
|
||||
"allowedOutputs": packet["allowedOutputs"],
|
||||
"cognitionLanes": packet["cognitionLanes"]["schema_version"],
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn read_required_text(content: Option<String>, label: &str) -> Result<String> {
|
||||
let Some(content) = content else {
|
||||
let mut stdin = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut stdin)
|
||||
.context("failed to read stdin")?;
|
||||
ensure_content(&stdin, label)?;
|
||||
return Ok(stdin);
|
||||
};
|
||||
let path = PathBuf::from(&content);
|
||||
if path.exists() {
|
||||
let text = fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read content file {}", path.display()))?;
|
||||
ensure_content(&text, label)?;
|
||||
return Ok(text);
|
||||
}
|
||||
ensure_content(&content, label)?;
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn ensure_content(content: &str, label: &str) -> Result<()> {
|
||||
if content.trim().is_empty() {
|
||||
return Err(anyhow!("{label} is empty"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_value(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<String> {
|
||||
args.next()
|
||||
.ok_or_else(|| anyhow!("missing value after {flag}"))
|
||||
}
|
||||
|
||||
fn next_path(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(next_value(args, flag)?))
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, payload: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
fs::write(
|
||||
path,
|
||||
format!("{}\n", serde_json::to_string_pretty(payload)?),
|
||||
)
|
||||
.with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn now_iso() -> String {
|
||||
chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
fn now_stamp() -> String {
|
||||
chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string()
|
||||
}
|
||||
|
||||
fn short_id() -> String {
|
||||
Uuid::new_v4()
|
||||
.to_string()
|
||||
.split('-')
|
||||
.next()
|
||||
.unwrap_or("id")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn sanitize_file_stem(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
ch
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn scoped_temp_dir(prefix: &str) -> Result<PathBuf> {
|
||||
let dir = env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn usage() -> Result<()> {
|
||||
Err(anyhow!(
|
||||
"usage: epiphany-character-loop <turn|smoke> [--role <role>] [--agent-store <path>] [--artifact-dir <path>] [--stimulus <text-or-path>] [--source <source>] [--mode <mode>]"
|
||||
))
|
||||
}
|
||||
109
epiphany-core/src/bin/epiphany-cultmesh-smoke.rs
Normal file
109
epiphany-core/src/bin/epiphany-cultmesh-smoke.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
use anyhow::Result;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_INTERNAL_TIER;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_INTERNAL_VERSE_ID;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_STATUS_SCHEMA_VERSION;
|
||||
use epiphany_core::EpiphanyCultMeshStatusEntry;
|
||||
use epiphany_core::default_epiphany_cultmesh_operator_status;
|
||||
use epiphany_core::epiphany_cultmesh_operator_snapshot_from_status_json;
|
||||
use epiphany_core::load_epiphany_cultmesh_operator_status;
|
||||
use epiphany_core::load_epiphany_cultmesh_status;
|
||||
use epiphany_core::load_latest_epiphany_cultmesh_operator_snapshot;
|
||||
use epiphany_core::write_epiphany_cultmesh_continuity_contracts;
|
||||
use epiphany_core::write_epiphany_cultmesh_eyes_contracts;
|
||||
use epiphany_core::write_epiphany_cultmesh_global_room_policies;
|
||||
use epiphany_core::write_epiphany_cultmesh_hands_contracts;
|
||||
use epiphany_core::write_epiphany_cultmesh_mind_contracts;
|
||||
use epiphany_core::write_epiphany_cultmesh_operator_snapshot;
|
||||
use epiphany_core::write_epiphany_cultmesh_operator_status;
|
||||
use epiphany_core::write_epiphany_cultmesh_soul_contracts;
|
||||
use epiphany_core::write_epiphany_cultmesh_status;
|
||||
use epiphany_core::write_epiphany_cultmesh_substrate_gate_contracts;
|
||||
use epiphany_core::write_epiphany_cultmesh_verse_policies;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut store = PathBuf::from(".epiphany-smoke");
|
||||
store.push("cultmesh");
|
||||
std::fs::create_dir_all(&store)?;
|
||||
store.push("epiphany-local.cc");
|
||||
|
||||
let status = EpiphanyCultMeshStatusEntry {
|
||||
schema_version: EPIPHANY_CULTMESH_STATUS_SCHEMA_VERSION.to_string(),
|
||||
runtime_id: "epiphany-cultmesh-smoke".to_string(),
|
||||
verse_id: EPIPHANY_CULTMESH_INTERNAL_VERSE_ID.to_string(),
|
||||
verse_tier: EPIPHANY_CULTMESH_INTERNAL_TIER.to_string(),
|
||||
app_id: "epiphany".to_string(),
|
||||
note: "Epiphany wrote this through CultMesh, not direct CultNet plumbing.".to_string(),
|
||||
};
|
||||
|
||||
write_epiphany_cultmesh_status(&store, status.clone())?;
|
||||
write_epiphany_cultmesh_verse_policies(&store, "epiphany-cultmesh-smoke")?;
|
||||
write_epiphany_cultmesh_global_room_policies(&store, "epiphany-cultmesh-smoke")?;
|
||||
write_epiphany_cultmesh_mind_contracts(&store, "epiphany-cultmesh-smoke")?;
|
||||
write_epiphany_cultmesh_substrate_gate_contracts(&store, "epiphany-cultmesh-smoke")?;
|
||||
write_epiphany_cultmesh_eyes_contracts(&store, "epiphany-cultmesh-smoke")?;
|
||||
write_epiphany_cultmesh_hands_contracts(&store, "epiphany-cultmesh-smoke")?;
|
||||
write_epiphany_cultmesh_soul_contracts(&store, "epiphany-cultmesh-smoke")?;
|
||||
write_epiphany_cultmesh_continuity_contracts(&store, "epiphany-cultmesh-smoke")?;
|
||||
let operator_status = default_epiphany_cultmesh_operator_status(
|
||||
"epiphany-cultmesh-smoke",
|
||||
"2026-05-27T00:00:00Z",
|
||||
);
|
||||
write_epiphany_cultmesh_operator_status(&store, operator_status.clone())?;
|
||||
let operator_snapshot = epiphany_cultmesh_operator_snapshot_from_status_json(
|
||||
"epiphany-cultmesh-smoke",
|
||||
"cultmesh-smoke-status",
|
||||
"2026-05-27T00:00:00Z",
|
||||
"status",
|
||||
".epiphany-smoke/cultmesh/status.json",
|
||||
&serde_json::json!({
|
||||
"threadId": "thread-smoke",
|
||||
"scene": {
|
||||
"scene": {
|
||||
"stateStatus": "missing",
|
||||
"availableActions": ["crrc", "roles"]
|
||||
}
|
||||
},
|
||||
"pressure": {
|
||||
"pressure": {
|
||||
"level": "low"
|
||||
}
|
||||
},
|
||||
"reorient": {
|
||||
"decision": {
|
||||
"action": "regather",
|
||||
"nextAction": "Regather source context."
|
||||
}
|
||||
},
|
||||
"crrc": {
|
||||
"recommendation": {
|
||||
"action": "regatherManually"
|
||||
}
|
||||
},
|
||||
"coordinator": {
|
||||
"action": "wait"
|
||||
}
|
||||
}),
|
||||
)?;
|
||||
write_epiphany_cultmesh_operator_snapshot(&store, operator_snapshot.clone())?;
|
||||
let loaded = load_epiphany_cultmesh_status(&store, "epiphany-cultmesh-smoke")?;
|
||||
if loaded != Some(status) {
|
||||
anyhow::bail!("CultMesh smoke failed to round-trip Epiphany status document");
|
||||
}
|
||||
let loaded_operator_status =
|
||||
load_epiphany_cultmesh_operator_status(&store, "epiphany-cultmesh-smoke")?;
|
||||
if loaded_operator_status != Some(operator_status) {
|
||||
anyhow::bail!("CultMesh smoke failed to round-trip Epiphany operator status document");
|
||||
}
|
||||
let loaded_operator_snapshot =
|
||||
load_latest_epiphany_cultmesh_operator_snapshot(&store, "epiphany-cultmesh-smoke")?;
|
||||
if loaded_operator_snapshot != Some(operator_snapshot) {
|
||||
anyhow::bail!("CultMesh smoke failed to round-trip Epiphany operator snapshot document");
|
||||
}
|
||||
|
||||
println!(
|
||||
"epiphany cultmesh smoke ok: {}",
|
||||
store.canonicalize()?.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
99
epiphany-core/src/bin/epiphany-cultmesh-status.rs
Normal file
99
epiphany-core/src/bin/epiphany-cultmesh-status.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use epiphany_core::default_epiphany_cultmesh_operator_status;
|
||||
use epiphany_core::load_epiphany_cultmesh_operator_status;
|
||||
use epiphany_core::write_epiphany_cultmesh_operator_status;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
match args.command.as_str() {
|
||||
"write" => {
|
||||
let status = default_epiphany_cultmesh_operator_status(
|
||||
args.runtime_id.clone(),
|
||||
Utc::now().to_rfc3339(),
|
||||
);
|
||||
let written = write_epiphany_cultmesh_operator_status(&args.store, status)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"status": "written",
|
||||
"store": args.store,
|
||||
"document": written,
|
||||
}))?
|
||||
);
|
||||
}
|
||||
"read" => {
|
||||
let loaded = load_epiphany_cultmesh_operator_status(&args.store, &args.runtime_id)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"status": if loaded.is_some() { "ready" } else { "missing" },
|
||||
"store": args.store,
|
||||
"document": loaded,
|
||||
}))?
|
||||
);
|
||||
}
|
||||
"smoke" => {
|
||||
let status = default_epiphany_cultmesh_operator_status(
|
||||
args.runtime_id.clone(),
|
||||
"2026-05-27T00:00:00Z",
|
||||
);
|
||||
write_epiphany_cultmesh_operator_status(&args.store, status.clone())?;
|
||||
let loaded = load_epiphany_cultmesh_operator_status(&args.store, &args.runtime_id)?;
|
||||
if loaded != Some(status) {
|
||||
anyhow::bail!("operator status did not round-trip through CultMesh");
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"status": "ok",
|
||||
"store": args.store,
|
||||
"runtimeId": args.runtime_id,
|
||||
}))?
|
||||
);
|
||||
}
|
||||
other => anyhow::bail!("unknown command {other:?}; use write, read, or smoke"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Args {
|
||||
command: String,
|
||||
store: PathBuf,
|
||||
runtime_id: String,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let mut values = env::args().skip(1);
|
||||
let command = values.next().unwrap_or_else(|| "read".to_string());
|
||||
let mut store = PathBuf::from(".epiphany-run/cultmesh/operator-status.cc");
|
||||
let mut runtime_id = "epiphany-local".to_string();
|
||||
|
||||
while let Some(arg) = values.next() {
|
||||
match arg.as_str() {
|
||||
"--store" => {
|
||||
store = PathBuf::from(values.next().context("missing --store value")?);
|
||||
}
|
||||
"--runtime-id" => {
|
||||
runtime_id = values.next().context("missing --runtime-id value")?;
|
||||
}
|
||||
_ => anyhow::bail!("unknown argument {arg:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent) = store.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
command,
|
||||
store,
|
||||
runtime_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
741
epiphany-core/src/bin/epiphany-eve-surface.rs
Normal file
741
epiphany-core/src/bin/epiphany-eve-surface.rs
Normal file
|
|
@ -0,0 +1,741 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_CONTINUITY_CONTRACT_SCHEMA_VERSION;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_EYES_CONTRACT_SCHEMA_VERSION;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_GLOBAL_ROOM_POLICY_SCHEMA_VERSION;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_GLOBAL_VERSE_ID;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_HANDS_CONTRACT_SCHEMA_VERSION;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_INTERNAL_VERSE_ID;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_LOCAL_AREA_VERSE_ID;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_MIND_CONTRACT_SCHEMA_VERSION;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_OPERATOR_RUN_INTENT_SCHEMA_VERSION;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_OPERATOR_RUN_RECEIPT_SCHEMA_VERSION;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_OPERATOR_SNAPSHOT_SCHEMA_VERSION;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_OPERATOR_STATUS_SCHEMA_VERSION;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_SOUL_CONTRACT_SCHEMA_VERSION;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_SUBSTRATE_GATE_CONTRACT_SCHEMA_VERSION;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_VERSE_POLICY_SCHEMA_VERSION;
|
||||
use epiphany_core::EpiphanyLocalVerseContext;
|
||||
use epiphany_core::GJALLAR_AFFORDANCE_SCHEMA_VERSION;
|
||||
use epiphany_core::query_epiphany_local_verse_context;
|
||||
use epiphany_core::seed_epiphany_local_verse_context;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const EVE_PROVIDER_ADVERTISEMENT_SCHEMA: &str = "gamecult.eve.provider_advertisement.v1";
|
||||
const EVE_SURFACE_SCHEMA: &str = "gamecult.eve.surface.v1";
|
||||
const EVE_COMMAND_SCHEMA: &str = "gamecult.eve.command.v1";
|
||||
const PROVIDER_ID: &str = "epiphany";
|
||||
const SERVICE_ID: &str = "epiphany.agent";
|
||||
const SURFACE_ID: &str = "epiphany.operator.surface";
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
match args.command.as_str() {
|
||||
"advertisement" => {
|
||||
let context = load_context(&args.store, &args.runtime_id)?;
|
||||
print_json(provider_advertisement(&args, context.as_ref()))?;
|
||||
}
|
||||
"surface" => {
|
||||
let context = load_context(&args.store, &args.runtime_id)?;
|
||||
print_json(surface_document(&args, context.as_ref()))?;
|
||||
}
|
||||
"bundle" => {
|
||||
let context = load_context(&args.store, &args.runtime_id)?;
|
||||
print_json(json!({
|
||||
"schema": "epiphany.eve_surface_export.v0",
|
||||
"providerAdvertisement": provider_advertisement(&args, context.as_ref()),
|
||||
"surface": surface_document(&args, context.as_ref()),
|
||||
}))?;
|
||||
}
|
||||
"smoke" => run_smoke(args)?,
|
||||
other => {
|
||||
anyhow::bail!("unknown command {other:?}; use advertisement, surface, bundle, or smoke")
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Args {
|
||||
command: String,
|
||||
store: PathBuf,
|
||||
runtime_id: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let mut values = env::args().skip(1);
|
||||
let command = values.next().unwrap_or_else(|| "bundle".to_string());
|
||||
let mut store = PathBuf::from(".epiphany-run/cultmesh/epiphany-local.cc");
|
||||
let mut runtime_id = "epiphany-local".to_string();
|
||||
let mut updated_at = Utc::now().to_rfc3339();
|
||||
|
||||
while let Some(arg) = values.next() {
|
||||
match arg.as_str() {
|
||||
"--store" => {
|
||||
store = PathBuf::from(values.next().context("missing --store value")?);
|
||||
}
|
||||
"--runtime-id" => {
|
||||
runtime_id = values.next().context("missing --runtime-id value")?;
|
||||
}
|
||||
"--updated-at" => {
|
||||
updated_at = values.next().context("missing --updated-at value")?;
|
||||
}
|
||||
_ => anyhow::bail!("unknown argument {arg:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
command,
|
||||
store,
|
||||
runtime_id,
|
||||
updated_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn load_context(store: &Path, runtime_id: &str) -> Result<Option<EpiphanyLocalVerseContext>> {
|
||||
if !store.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
query_epiphany_local_verse_context(store, runtime_id.to_string()).map(Some)
|
||||
}
|
||||
|
||||
fn provider_advertisement(args: &Args, context: Option<&EpiphanyLocalVerseContext>) -> Value {
|
||||
let state = if context.is_some() {
|
||||
"fresh"
|
||||
} else {
|
||||
"degraded"
|
||||
};
|
||||
json!({
|
||||
"schema": EVE_PROVIDER_ADVERTISEMENT_SCHEMA,
|
||||
"providerId": PROVIDER_ID,
|
||||
"serviceId": SERVICE_ID,
|
||||
"verseId": EPIPHANY_CULTMESH_LOCAL_AREA_VERSE_ID,
|
||||
"title": "Epiphany Operator",
|
||||
"kind": "service.operator",
|
||||
"updatedAt": args.updated_at,
|
||||
"freshness": {
|
||||
"state": state,
|
||||
"lastSeenAt": context
|
||||
.and_then(|context| context.operator_status.as_ref())
|
||||
.map(|status| status.generated_at_utc.as_str())
|
||||
.unwrap_or(args.updated_at.as_str()),
|
||||
"maxAgeMs": 15000,
|
||||
"note": if context.is_some() {
|
||||
"Advertisement is derived from typed Epiphany CultMesh state."
|
||||
} else {
|
||||
"CultMesh store was not found; advertisement lists intended read-only provider boundaries."
|
||||
}
|
||||
},
|
||||
"schemas": schema_catalog(context),
|
||||
"witnesses": witnesses(args, context),
|
||||
"surfaces": [{
|
||||
"surfaceId": SURFACE_ID,
|
||||
"schema": EVE_SURFACE_SCHEMA,
|
||||
"transport": "cultmesh-document",
|
||||
"key": "cultmesh://epiphany/operator/surface",
|
||||
"audience": "operator",
|
||||
"mode": "read-only",
|
||||
"styleProfile": "epiphany.operator",
|
||||
"commands": command_names(),
|
||||
"canonical": true
|
||||
}],
|
||||
"commands": commands(),
|
||||
"nestedVerses": nested_verses(context),
|
||||
"styleCapabilities": [{
|
||||
"styleProfile": "epiphany.operator",
|
||||
"tokenGroups": ["epiphany.authority", "epiphany.freshness", "epiphany.faculty"],
|
||||
"preferredLowerings": ["tui", "browser", "direct2d", "native-eve"],
|
||||
"lossiness": {
|
||||
"tui": "read-only dense operator panels; no graphical relationship map",
|
||||
"browser": "reference lowering until native Eve provider publication lands"
|
||||
}
|
||||
}],
|
||||
"contacts": [{
|
||||
"id": "huginn",
|
||||
"role": "Persona and .cc inspection runtime steward",
|
||||
"boundary": "Coordinates schema migration, projection health, CultMesh publication, and Eve DSL inspection; does not own Epiphany runtime truth."
|
||||
}],
|
||||
"stewardship": {
|
||||
"persona": {
|
||||
"schema": "gamecult.persona_state.v0",
|
||||
"owner": "public Persona or Persona projection",
|
||||
"runtimeSteward": "Huginn",
|
||||
"boundary": "Private work-organ state remains epiphany.work_organ_state.v0 or organ-specific typed state; Persona is not a dumping ground for runtime authority."
|
||||
},
|
||||
"jsonCompatibility": ".epiphany-run JSON is a compatibility witness only, never the owner of accepted Epiphany state."
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn surface_document(args: &Args, context: Option<&EpiphanyLocalVerseContext>) -> Value {
|
||||
json!({
|
||||
"type": "surface-state",
|
||||
"schema": EVE_SURFACE_SCHEMA,
|
||||
"providerId": PROVIDER_ID,
|
||||
"providerKind": "service.operator",
|
||||
"title": "Epiphany Operator Surface",
|
||||
"version": 1,
|
||||
"updatedAt": args.updated_at,
|
||||
"surface": {
|
||||
"root": {
|
||||
"id": "epiphany.operator.root",
|
||||
"kind": "surface",
|
||||
"props": {
|
||||
"authority": "provider-owned-read-only",
|
||||
"runtimeId": args.runtime_id,
|
||||
"verseId": EPIPHANY_CULTMESH_LOCAL_AREA_VERSE_ID
|
||||
},
|
||||
"children": [
|
||||
operator_status_panel(context),
|
||||
local_verse_panel(context),
|
||||
daemon_affordance_panel(context),
|
||||
runtime_spine_panel(context),
|
||||
memory_graph_panel(context),
|
||||
persona_panel()
|
||||
]
|
||||
},
|
||||
"styles": {
|
||||
"tokens": {
|
||||
"epiphany.authority.accepted": "#8fbf9f",
|
||||
"epiphany.authority.witness": "#c8a24d",
|
||||
"epiphany.authority.denied": "#bf6b6b"
|
||||
}
|
||||
}
|
||||
},
|
||||
"commands": commands()
|
||||
})
|
||||
}
|
||||
|
||||
fn operator_status_panel(context: Option<&EpiphanyLocalVerseContext>) -> Value {
|
||||
let status = context.and_then(|context| context.operator_status.as_ref());
|
||||
json!({
|
||||
"id": "epiphany.operator.status",
|
||||
"kind": "inspector.kv",
|
||||
"props": {
|
||||
"title": "Operator Status",
|
||||
"authority": "Epiphany CultMesh operator status",
|
||||
"freshness": status.map(|status| status.generated_at_utc.as_str()).unwrap_or("missing"),
|
||||
"rows": [
|
||||
{"label": "runtime", "value": status.map(|status| status.runtime_id.as_str()).unwrap_or("missing")},
|
||||
{"label": "state", "value": status.map(|status| status.status.as_str()).unwrap_or("missing")},
|
||||
{"label": "bridge", "value": status.map(|status| status.codex_bridge_role.as_str()).unwrap_or("compatibility transport")},
|
||||
{"label": "authority", "value": status.map(|status| status.epiphany_authority_role.as_str()).unwrap_or("Epiphany owns runtime truth")},
|
||||
{"label": "prompt", "value": status.map(|status| status.prompt_authority.as_str()).unwrap_or("prompt authority is separated from runtime state")}
|
||||
]
|
||||
},
|
||||
"children": []
|
||||
})
|
||||
}
|
||||
|
||||
fn local_verse_panel(context: Option<&EpiphanyLocalVerseContext>) -> Value {
|
||||
let verse_rows: Vec<Value> = context
|
||||
.map(|context| {
|
||||
context
|
||||
.verse_policies
|
||||
.iter()
|
||||
.map(|policy| {
|
||||
json!({
|
||||
"label": policy.verse_id,
|
||||
"value": format!("{}; private={}; untrustedIngress={}; yggdrasil={}", policy.tier, policy.private_state_allowed, policy.untrusted_ingress_allowed, policy.yggdrasil_tunnel_allowed)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"id": "epiphany.local_verse.context",
|
||||
"kind": "inspector.kv",
|
||||
"props": {
|
||||
"title": "Local Verse Context",
|
||||
"authority": "CultMesh Verse policies and contract summaries",
|
||||
"freshness": context.map(|context| context.store_path.as_str()).unwrap_or("store missing"),
|
||||
"rows": verse_rows
|
||||
},
|
||||
"children": []
|
||||
})
|
||||
}
|
||||
|
||||
fn daemon_affordance_panel(context: Option<&EpiphanyLocalVerseContext>) -> Value {
|
||||
let rows: Vec<Value> = context
|
||||
.map(|context| {
|
||||
context
|
||||
.daemon_affordances
|
||||
.iter()
|
||||
.take(12)
|
||||
.map(|affordance| {
|
||||
json!({
|
||||
"label": affordance.affordance_id,
|
||||
"value": format!(
|
||||
"{}:{}; authority={}; status={}; source={}",
|
||||
affordance.surface_kind,
|
||||
affordance.action,
|
||||
affordance.authority,
|
||||
affordance.status,
|
||||
affordance.source_record
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"id": "epiphany.daemon.affordances",
|
||||
"kind": "inspector.kv",
|
||||
"props": {
|
||||
"title": "Daemon Affordances",
|
||||
"authority": "Gjallar affordance records derived from Odin-owned sight",
|
||||
"freshness": context
|
||||
.and_then(|context| context.daemon_affordances.first())
|
||||
.map(|affordance| affordance.observed_at.as_str())
|
||||
.unwrap_or("missing"),
|
||||
"rows": rows,
|
||||
"emptyState": "No Gjallar affordances loaded; import an Odin/Gjallar CultMesh store rather than probing sockets or calling MCP."
|
||||
},
|
||||
"children": []
|
||||
})
|
||||
}
|
||||
|
||||
fn runtime_spine_panel(context: Option<&EpiphanyLocalVerseContext>) -> Value {
|
||||
let snapshot = context.and_then(|context| context.latest_operator_snapshot.as_ref());
|
||||
let intent = context.and_then(|context| context.latest_operator_run_intent.as_ref());
|
||||
let receipt = context.and_then(|context| context.latest_operator_run_receipt.as_ref());
|
||||
json!({
|
||||
"id": "epiphany.runtime.spine",
|
||||
"kind": "inspector.kv",
|
||||
"props": {
|
||||
"title": "Runtime Spine",
|
||||
"authority": "runtime-spine documents plus operator run intent/receipt projections",
|
||||
"rows": [
|
||||
{"label": "snapshot", "value": snapshot.map(|snapshot| snapshot.snapshot_id.as_str()).unwrap_or("missing")},
|
||||
{"label": "snapshotStatus", "value": snapshot.map(|snapshot| snapshot.status.as_str()).unwrap_or("missing")},
|
||||
{"label": "lastIntent", "value": intent.map(|intent| intent.run_id.as_str()).unwrap_or("missing")},
|
||||
{"label": "lastReceipt", "value": receipt.map(|receipt| receipt.run_id.as_str()).unwrap_or("missing")}
|
||||
]
|
||||
},
|
||||
"children": []
|
||||
})
|
||||
}
|
||||
|
||||
fn memory_graph_panel(context: Option<&EpiphanyLocalVerseContext>) -> Value {
|
||||
let graph_contracts = context
|
||||
.map(|context| {
|
||||
context
|
||||
.contract_summaries
|
||||
.iter()
|
||||
.filter(|contract| {
|
||||
contract.document_type.contains("memory")
|
||||
|| contract.document_type.contains("state")
|
||||
|| contract.authority == "mind"
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
json!({
|
||||
"id": "epiphany.memory.graph",
|
||||
"kind": "inspector.kv",
|
||||
"props": {
|
||||
"title": "Memory Graph",
|
||||
"authority": "Mind and memory graph typed state; private graph details stay behind organ contracts",
|
||||
"rows": [
|
||||
{"label": "profileAvailability", "value": if graph_contracts > 0 { "contracted" } else { "not advertised in loaded store" }},
|
||||
{"label": "thinStateWarning", "value": "surface reports availability and freshness only; it does not leak private graph payloads"},
|
||||
{"label": "contractCount", "value": graph_contracts}
|
||||
]
|
||||
},
|
||||
"children": []
|
||||
})
|
||||
}
|
||||
|
||||
fn persona_panel() -> Value {
|
||||
json!({
|
||||
"id": "epiphany.persona.projection",
|
||||
"kind": "inspector.kv",
|
||||
"props": {
|
||||
"title": "Persona Projection",
|
||||
"authority": "gamecult.persona_state.v0 for public Persona/Persona projections only",
|
||||
"rows": [
|
||||
{"label": "schema", "value": "gamecult.persona_state.v0"},
|
||||
{"label": "steward", "value": "Huginn coordinates Persona-state and .cc inspection runtime stewardship"},
|
||||
{"label": "boundary", "value": "work organs stay in organ state; Persona does not own runtime truth"}
|
||||
]
|
||||
},
|
||||
"children": []
|
||||
})
|
||||
}
|
||||
|
||||
fn schema_catalog(context: Option<&EpiphanyLocalVerseContext>) -> Vec<Value> {
|
||||
let mut schemas = vec![
|
||||
schema_entry(
|
||||
EPIPHANY_CULTMESH_OPERATOR_STATUS_SCHEMA_VERSION,
|
||||
"epiphany",
|
||||
"accepted",
|
||||
"cultcache-cc",
|
||||
"cultmesh://epiphany/operator-status",
|
||||
true,
|
||||
),
|
||||
schema_entry(
|
||||
EPIPHANY_CULTMESH_OPERATOR_SNAPSHOT_SCHEMA_VERSION,
|
||||
"epiphany",
|
||||
"accepted",
|
||||
"cultcache-cc",
|
||||
"cultmesh://epiphany/operator-snapshot/latest",
|
||||
true,
|
||||
),
|
||||
schema_entry(
|
||||
EPIPHANY_CULTMESH_OPERATOR_RUN_INTENT_SCHEMA_VERSION,
|
||||
"epiphany",
|
||||
"command-intent",
|
||||
"cultcache-cc",
|
||||
"cultmesh://epiphany/operator-run-intent/latest",
|
||||
true,
|
||||
),
|
||||
schema_entry(
|
||||
EPIPHANY_CULTMESH_OPERATOR_RUN_RECEIPT_SCHEMA_VERSION,
|
||||
"epiphany",
|
||||
"receipt",
|
||||
"cultcache-cc",
|
||||
"cultmesh://epiphany/operator-run-receipt/latest",
|
||||
true,
|
||||
),
|
||||
schema_entry(
|
||||
EPIPHANY_CULTMESH_VERSE_POLICY_SCHEMA_VERSION,
|
||||
"epiphany",
|
||||
"accepted",
|
||||
"cultcache-cc",
|
||||
"cultmesh://epiphany/verse-policy/{verseId}",
|
||||
true,
|
||||
),
|
||||
schema_entry(
|
||||
EPIPHANY_CULTMESH_GLOBAL_ROOM_POLICY_SCHEMA_VERSION,
|
||||
"epiphany",
|
||||
"accepted",
|
||||
"cultcache-cc",
|
||||
"cultmesh://epiphany/global-room/{roomId}",
|
||||
true,
|
||||
),
|
||||
schema_entry(
|
||||
EPIPHANY_CULTMESH_MIND_CONTRACT_SCHEMA_VERSION,
|
||||
"mind",
|
||||
"accepted",
|
||||
"cultcache-cc",
|
||||
"cultmesh://epiphany/contracts/mind/{contractId}",
|
||||
true,
|
||||
),
|
||||
schema_entry(
|
||||
EPIPHANY_CULTMESH_SUBSTRATE_GATE_CONTRACT_SCHEMA_VERSION,
|
||||
"substrateGate",
|
||||
"accepted",
|
||||
"cultcache-cc",
|
||||
"cultmesh://epiphany/contracts/substrate-gate/{contractId}",
|
||||
true,
|
||||
),
|
||||
schema_entry(
|
||||
EPIPHANY_CULTMESH_EYES_CONTRACT_SCHEMA_VERSION,
|
||||
"eyes",
|
||||
"accepted",
|
||||
"cultcache-cc",
|
||||
"cultmesh://epiphany/contracts/eyes/{contractId}",
|
||||
true,
|
||||
),
|
||||
schema_entry(
|
||||
EPIPHANY_CULTMESH_HANDS_CONTRACT_SCHEMA_VERSION,
|
||||
"hands",
|
||||
"accepted",
|
||||
"cultcache-cc",
|
||||
"cultmesh://epiphany/contracts/hands/{contractId}",
|
||||
true,
|
||||
),
|
||||
schema_entry(
|
||||
EPIPHANY_CULTMESH_SOUL_CONTRACT_SCHEMA_VERSION,
|
||||
"soul",
|
||||
"accepted",
|
||||
"cultcache-cc",
|
||||
"cultmesh://epiphany/contracts/soul/{contractId}",
|
||||
true,
|
||||
),
|
||||
schema_entry(
|
||||
EPIPHANY_CULTMESH_CONTINUITY_CONTRACT_SCHEMA_VERSION,
|
||||
"continuity",
|
||||
"accepted",
|
||||
"cultcache-cc",
|
||||
"cultmesh://epiphany/contracts/continuity/{contractId}",
|
||||
true,
|
||||
),
|
||||
schema_entry(
|
||||
"epiphany.work_organ_state.v0",
|
||||
"epiphany",
|
||||
"accepted",
|
||||
"cultcache-cc",
|
||||
"cultmesh://epiphany/organs/{organId}/state",
|
||||
true,
|
||||
),
|
||||
schema_entry(
|
||||
"gamecult.persona_state.v0",
|
||||
"public Persona owner; Huginn steward",
|
||||
"accepted-projection",
|
||||
"external-authority-projection",
|
||||
"cultmesh://huginn/persona/{personaId}",
|
||||
true,
|
||||
),
|
||||
schema_entry(
|
||||
GJALLAR_AFFORDANCE_SCHEMA_VERSION,
|
||||
"gjallar",
|
||||
"imported-affordance",
|
||||
"cultcache-cc",
|
||||
"cultmesh://odin/gjallar/affordances",
|
||||
true,
|
||||
),
|
||||
];
|
||||
if let Some(context) = context {
|
||||
for contract in &context.contract_summaries {
|
||||
schemas.push(schema_entry(
|
||||
&contract.document_type,
|
||||
&contract.authority,
|
||||
"contracted",
|
||||
"cultcache-cc",
|
||||
&format!("cultmesh://epiphany/contracts/{}", contract.contract_id),
|
||||
true,
|
||||
));
|
||||
}
|
||||
}
|
||||
schemas
|
||||
}
|
||||
|
||||
fn schema_entry(
|
||||
schema: &str,
|
||||
owner: &str,
|
||||
authority: &str,
|
||||
storage: &str,
|
||||
cult_mesh_key: &str,
|
||||
portable: bool,
|
||||
) -> Value {
|
||||
json!({
|
||||
"schema": schema,
|
||||
"owner": owner,
|
||||
"authority": authority,
|
||||
"storage": storage,
|
||||
"cultMeshKey": cult_mesh_key,
|
||||
"portable": portable
|
||||
})
|
||||
}
|
||||
|
||||
fn witnesses(args: &Args, context: Option<&EpiphanyLocalVerseContext>) -> Vec<Value> {
|
||||
vec![
|
||||
json!({
|
||||
"id": "epiphany.local-verse.store",
|
||||
"kind": "cc-store",
|
||||
"path": args.store,
|
||||
"schemas": [
|
||||
EPIPHANY_CULTMESH_VERSE_POLICY_SCHEMA_VERSION,
|
||||
EPIPHANY_CULTMESH_OPERATOR_STATUS_SCHEMA_VERSION,
|
||||
EPIPHANY_CULTMESH_OPERATOR_SNAPSHOT_SCHEMA_VERSION,
|
||||
GJALLAR_AFFORDANCE_SCHEMA_VERSION
|
||||
],
|
||||
"redaction": "private worker transcripts and raw model payloads are not exposed",
|
||||
"freshness": {
|
||||
"state": if context.is_some() { "fresh" } else { "missing" },
|
||||
"updatedAt": context
|
||||
.and_then(|context| context.operator_status.as_ref())
|
||||
.map(|status| status.generated_at_utc.as_str())
|
||||
.unwrap_or(args.updated_at.as_str())
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"id": "epiphany.run.compatibility",
|
||||
"kind": "json-compatibility-witness",
|
||||
"path": ".epiphany-run",
|
||||
"schemas": [EPIPHANY_CULTMESH_OPERATOR_SNAPSHOT_SCHEMA_VERSION],
|
||||
"redaction": "operator-safe distillation only; JSON files are not accepted state owners",
|
||||
"freshness": {
|
||||
"state": "witness-only",
|
||||
"updatedAt": args.updated_at
|
||||
}
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn nested_verses(context: Option<&EpiphanyLocalVerseContext>) -> Vec<Value> {
|
||||
let mut verses = vec![
|
||||
nested_verse(
|
||||
EPIPHANY_CULTMESH_INTERNAL_VERSE_ID,
|
||||
EPIPHANY_CULTMESH_LOCAL_AREA_VERSE_ID,
|
||||
"private-runtime",
|
||||
"Epiphany internal organs own private runtime truth; Eve receives operator-safe projection only.",
|
||||
vec![SURFACE_ID],
|
||||
vec![
|
||||
"epiphany.work_organ_state.v0",
|
||||
EPIPHANY_CULTMESH_OPERATOR_STATUS_SCHEMA_VERSION,
|
||||
],
|
||||
),
|
||||
nested_verse(
|
||||
EPIPHANY_CULTMESH_GLOBAL_VERSE_ID,
|
||||
EPIPHANY_CULTMESH_LOCAL_AREA_VERSE_ID,
|
||||
"public-persona",
|
||||
"Public Persona/Persona projection crosses this boundary through Persona state and Mind adoption gates.",
|
||||
vec![SURFACE_ID],
|
||||
vec!["gamecult.persona_state.v0"],
|
||||
),
|
||||
];
|
||||
|
||||
if let Some(context) = context {
|
||||
for contract in &context.contract_summaries {
|
||||
let verse_id = format!("epiphany.faculty.{}", contract.authority);
|
||||
verses.push(nested_verse(
|
||||
&verse_id,
|
||||
contract.verse_id.as_str(),
|
||||
"faculty",
|
||||
&format!(
|
||||
"{} owns {} operations; commands remain provider-accepted intent, not renderer callbacks.",
|
||||
contract.authority, contract.document_type
|
||||
),
|
||||
vec![SURFACE_ID],
|
||||
vec![contract.document_type.as_str()],
|
||||
));
|
||||
}
|
||||
}
|
||||
verses
|
||||
}
|
||||
|
||||
fn nested_verse(
|
||||
verse_id: &str,
|
||||
parent_verse_id: &str,
|
||||
kind: &str,
|
||||
authority_boundary: &str,
|
||||
surface_ids: Vec<&str>,
|
||||
state_schemas: Vec<&str>,
|
||||
) -> Value {
|
||||
json!({
|
||||
"verseId": verse_id,
|
||||
"parentVerseId": parent_verse_id,
|
||||
"kind": kind,
|
||||
"authorityBoundary": authority_boundary,
|
||||
"surfaceIds": surface_ids,
|
||||
"stateSchemas": state_schemas,
|
||||
"carryRules": {
|
||||
"style": "inherit epiphany.operator tokens unless provider republishes",
|
||||
"identity": "Persona projections require gamecult.persona_state.v0 provenance",
|
||||
"commands": "provider-accepted-only"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn commands() -> Vec<Value> {
|
||||
vec![
|
||||
command_entry(
|
||||
"epiphany.operator_run.request",
|
||||
"operator-or-coordinator",
|
||||
"accepted-denied-or-reconciled",
|
||||
),
|
||||
command_entry(
|
||||
"epiphany.job_interrupt.request",
|
||||
"operator-or-coordinator",
|
||||
"accepted-denied-or-reconciled",
|
||||
),
|
||||
command_entry(
|
||||
"epiphany.role_launch.request",
|
||||
"coordinator",
|
||||
"accepted-denied-or-reconciled",
|
||||
),
|
||||
command_entry(
|
||||
"epiphany.persona_projection.request",
|
||||
"huginn-stewarded-persona-owner",
|
||||
"accepted-denied-or-reconciled",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn command_entry(command: &str, authority: &str, result: &str) -> Value {
|
||||
json!({
|
||||
"command": command,
|
||||
"surfaceId": SURFACE_ID,
|
||||
"transport": "cultmesh-command",
|
||||
"schema": EVE_COMMAND_SCHEMA,
|
||||
"authority": authority,
|
||||
"result": result,
|
||||
"boundary": "This read-only export does not execute commands; Eve must send command intent through the provider's typed command boundary."
|
||||
})
|
||||
}
|
||||
|
||||
fn command_names() -> Vec<&'static str> {
|
||||
vec![
|
||||
"epiphany.operator_run.request",
|
||||
"epiphany.job_interrupt.request",
|
||||
"epiphany.role_launch.request",
|
||||
"epiphany.persona_projection.request",
|
||||
]
|
||||
}
|
||||
|
||||
fn run_smoke(args: Args) -> Result<()> {
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"epiphany-eve-surface-smoke-{}",
|
||||
Utc::now().timestamp_nanos_opt().unwrap_or_default()
|
||||
));
|
||||
fs::create_dir_all(&temp_root)?;
|
||||
let store = temp_root.join("epiphany-local.cc");
|
||||
seed_epiphany_local_verse_context(&store, args.runtime_id.clone(), "2026-06-03T00:00:00Z")?;
|
||||
let smoke_args = Args {
|
||||
command: "bundle".to_string(),
|
||||
store: store.clone(),
|
||||
runtime_id: args.runtime_id,
|
||||
updated_at: "2026-06-03T00:00:00Z".to_string(),
|
||||
};
|
||||
let context = load_context(&store, &smoke_args.runtime_id)?
|
||||
.context("smoke failed to load seeded local Verse context")?;
|
||||
let advertisement = provider_advertisement(&smoke_args, Some(&context));
|
||||
let surface = surface_document(&smoke_args, Some(&context));
|
||||
|
||||
require_eq(&advertisement, "/schema", EVE_PROVIDER_ADVERTISEMENT_SCHEMA)?;
|
||||
require_eq(&surface, "/schema", EVE_SURFACE_SCHEMA)?;
|
||||
require_contains(&advertisement, "gamecult.persona_state.v0")?;
|
||||
require_contains(&advertisement, "epiphany.work_organ_state.v0")?;
|
||||
require_contains(
|
||||
&advertisement,
|
||||
".epiphany-run JSON is a compatibility witness only",
|
||||
)?;
|
||||
require_contains(&advertisement, "Huginn")?;
|
||||
require_contains(&surface, "Persona Projection")?;
|
||||
require_contains(&surface, "Operator Status")?;
|
||||
|
||||
print_json(json!({
|
||||
"status": "ok",
|
||||
"store": store,
|
||||
"providerSchema": EVE_PROVIDER_ADVERTISEMENT_SCHEMA,
|
||||
"surfaceSchema": EVE_SURFACE_SCHEMA,
|
||||
"schemas": advertisement["schemas"].as_array().map(|schemas| schemas.len()).unwrap_or_default(),
|
||||
"nestedVerses": advertisement["nestedVerses"].as_array().map(|verses| verses.len()).unwrap_or_default(),
|
||||
"commands": advertisement["commands"].as_array().map(|commands| commands.len()).unwrap_or_default()
|
||||
}))
|
||||
}
|
||||
|
||||
fn require_eq(value: &Value, pointer: &str, expected: &str) -> Result<()> {
|
||||
let actual = value
|
||||
.pointer(pointer)
|
||||
.and_then(Value::as_str)
|
||||
.with_context(|| format!("missing {pointer}"))?;
|
||||
if actual != expected {
|
||||
anyhow::bail!("{pointer} expected {expected:?}, got {actual:?}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_contains(value: &Value, needle: &str) -> Result<()> {
|
||||
let rendered = serde_json::to_string(value)?;
|
||||
if !rendered.contains(needle) {
|
||||
anyhow::bail!("export did not contain {needle:?}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_json(value: Value) -> Result<()> {
|
||||
println!("{}", serde_json::to_string_pretty(&value)?);
|
||||
Ok(())
|
||||
}
|
||||
145
epiphany-core/src/bin/epiphany-hands-action-smoke.rs
Normal file
145
epiphany-core/src/bin/epiphany-hands-action-smoke.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
use anyhow::Result;
|
||||
use epiphany_core::HANDS_ACTION_INTENT_SCHEMA_VERSION;
|
||||
use epiphany_core::HandsActionIntent;
|
||||
use epiphany_core::RuntimeSpineInitOptions;
|
||||
use epiphany_core::hands_action_review_for_intent;
|
||||
use epiphany_core::hands_command_receipt_for_review;
|
||||
use epiphany_core::hands_commit_receipt_for_review;
|
||||
use epiphany_core::hands_patch_receipt_for_review;
|
||||
use epiphany_core::initialize_runtime_spine;
|
||||
use epiphany_core::put_hands_action_intent;
|
||||
use epiphany_core::put_hands_action_review;
|
||||
use epiphany_core::put_hands_command_receipt;
|
||||
use epiphany_core::put_hands_commit_receipt;
|
||||
use epiphany_core::put_hands_patch_receipt;
|
||||
use epiphany_core::runtime_hands_action_intent;
|
||||
use epiphany_core::runtime_hands_action_review;
|
||||
use epiphany_core::runtime_hands_command_receipt;
|
||||
use epiphany_core::runtime_hands_commit_receipt;
|
||||
use epiphany_core::runtime_hands_patch_receipt;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let store = PathBuf::from(".epiphany-smoke")
|
||||
.join("runtime-spine")
|
||||
.join("hands-action-smoke.cc");
|
||||
|
||||
initialize_runtime_spine(
|
||||
&store,
|
||||
RuntimeSpineInitOptions {
|
||||
runtime_id: "epiphany-hands-action-smoke".to_string(),
|
||||
display_name: "Epiphany Hands Action Smoke".to_string(),
|
||||
created_at: "2026-06-02T00:00:00Z".to_string(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let intent = HandsActionIntent {
|
||||
schema_version: HANDS_ACTION_INTENT_SCHEMA_VERSION.to_string(),
|
||||
intent_id: "hands-intent-smoke".to_string(),
|
||||
runtime_job_id: "job-implementation-smoke".to_string(),
|
||||
binding_id: "implementation-worker".to_string(),
|
||||
role: "epiphany-hands".to_string(),
|
||||
authority_scope: "epiphany.role.implementation".to_string(),
|
||||
requested_action: "patch".to_string(),
|
||||
requested_paths: vec!["tools/epiphany_local_run.ps1".to_string()],
|
||||
substrate_gate_grant_receipt_id: "substrate-grant-smoke".to_string(),
|
||||
requested_at: "2026-06-02T00:00:10Z".to_string(),
|
||||
contract: "Smoke intent proves Hands action starts as typed runtime-spine state."
|
||||
.to_string(),
|
||||
};
|
||||
put_hands_action_intent(&store, &intent)?;
|
||||
|
||||
let review = hands_action_review_for_intent(
|
||||
"hands-review-smoke".to_string(),
|
||||
&intent,
|
||||
"approved".to_string(),
|
||||
vec!["patch".to_string()],
|
||||
vec![
|
||||
"Substrate Gate grant id is named; this smoke does not execute the patch.".to_string(),
|
||||
],
|
||||
"2026-06-02T00:00:20Z".to_string(),
|
||||
);
|
||||
put_hands_action_review(&store, &review)?;
|
||||
|
||||
let patch = hands_patch_receipt_for_review(
|
||||
"hands-patch-smoke".to_string(),
|
||||
&intent,
|
||||
&review,
|
||||
vec!["tools/epiphany_local_run.ps1".to_string()],
|
||||
"Recorded a typed patch receipt for the bounded Hands action path.".to_string(),
|
||||
"2026-06-02T00:00:30Z".to_string(),
|
||||
);
|
||||
put_hands_patch_receipt(&store, &patch)?;
|
||||
|
||||
let command = hands_command_receipt_for_review(
|
||||
"hands-command-smoke".to_string(),
|
||||
&intent,
|
||||
&review,
|
||||
"cargo test --manifest-path .\\epiphany-core\\Cargo.toml hands_gateway".to_string(),
|
||||
"0".to_string(),
|
||||
".epiphany-smoke/runtime-spine/hands-action-smoke.stdout.log".to_string(),
|
||||
".epiphany-smoke/runtime-spine/hands-action-smoke.stderr.log".to_string(),
|
||||
"Recorded a typed command receipt for the bounded Hands action path.".to_string(),
|
||||
"2026-06-02T00:00:40Z".to_string(),
|
||||
);
|
||||
put_hands_command_receipt(&store, &command)?;
|
||||
|
||||
let commit = hands_commit_receipt_for_review(
|
||||
"hands-commit-smoke".to_string(),
|
||||
&intent,
|
||||
&review,
|
||||
"dry-run-no-commit".to_string(),
|
||||
"main".to_string(),
|
||||
vec!["tools/epiphany_local_run.ps1".to_string()],
|
||||
"Recorded a typed commit receipt shape for the bounded Hands action path.".to_string(),
|
||||
"2026-06-02T00:00:50Z".to_string(),
|
||||
);
|
||||
put_hands_commit_receipt(&store, &commit)?;
|
||||
|
||||
let stored_intent =
|
||||
runtime_hands_action_intent(&store, "hands-intent-smoke")?.expect("stored Hands intent");
|
||||
let stored_review =
|
||||
runtime_hands_action_review(&store, "hands-review-smoke")?.expect("stored Hands review");
|
||||
let stored_patch =
|
||||
runtime_hands_patch_receipt(&store, "hands-patch-smoke")?.expect("stored Hands patch");
|
||||
let stored_command = runtime_hands_command_receipt(&store, "hands-command-smoke")?
|
||||
.expect("stored Hands command");
|
||||
let stored_commit =
|
||||
runtime_hands_commit_receipt(&store, "hands-commit-smoke")?.expect("stored Hands commit");
|
||||
|
||||
if stored_review.intent_id != stored_intent.intent_id {
|
||||
anyhow::bail!("Hands review lost its intent edge");
|
||||
}
|
||||
if stored_patch.intent_id != stored_intent.intent_id
|
||||
|| stored_patch.review_id != stored_review.review_id
|
||||
{
|
||||
anyhow::bail!("Hands patch receipt lost its intent/review edge");
|
||||
}
|
||||
if stored_command.intent_id != stored_intent.intent_id
|
||||
|| stored_command.review_id != stored_review.review_id
|
||||
{
|
||||
anyhow::bail!("Hands command receipt lost its intent/review edge");
|
||||
}
|
||||
if stored_commit.intent_id != stored_intent.intent_id
|
||||
|| stored_commit.review_id != stored_review.review_id
|
||||
{
|
||||
anyhow::bail!("Hands commit receipt lost its intent/review edge");
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"status": "ok",
|
||||
"store": store,
|
||||
"intentId": stored_intent.intent_id,
|
||||
"reviewId": stored_review.review_id,
|
||||
"patchReceiptId": stored_patch.receipt_id,
|
||||
"commandReceiptId": stored_command.receipt_id,
|
||||
"commitReceiptId": stored_commit.receipt_id,
|
||||
"changedPaths": stored_patch.changed_paths,
|
||||
}))?
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
870
epiphany-core/src/bin/epiphany-heartbeat-store.rs
Normal file
870
epiphany-core/src/bin/epiphany-heartbeat-store.rs
Normal file
|
|
@ -0,0 +1,870 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use epiphany_core::GhostlightSceneParticipantSeed;
|
||||
use epiphany_core::HeartbeatCompleteOptions;
|
||||
use epiphany_core::HeartbeatHeatUpdateOptions;
|
||||
use epiphany_core::HeartbeatPumpOptions;
|
||||
use epiphany_core::HeartbeatQueueMentionOptions;
|
||||
use epiphany_core::HeartbeatTickOptions;
|
||||
use epiphany_core::VoidRoutineOptions;
|
||||
use epiphany_core::apply_agent_self_patch_document;
|
||||
use epiphany_core::complete_heartbeat_store;
|
||||
use epiphany_core::heartbeat_status_projection;
|
||||
use epiphany_core::initialize_ghostlight_scene_heartbeat_store;
|
||||
use epiphany_core::initialize_heartbeat_store;
|
||||
use epiphany_core::load_heartbeat_state_entry;
|
||||
use epiphany_core::pump_heartbeat_store;
|
||||
use epiphany_core::queue_heartbeat_pending_mention_store;
|
||||
use epiphany_core::run_void_routine_store;
|
||||
use epiphany_core::tick_heartbeat_store;
|
||||
use epiphany_core::update_heartbeat_heat_store;
|
||||
use epiphany_core::validate_agent_memory_store;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = env::args().skip(1);
|
||||
let Some(command) = args.next() else {
|
||||
return usage();
|
||||
};
|
||||
let mut store_path: Option<PathBuf> = None;
|
||||
let mut artifact_dir: Option<PathBuf> = None;
|
||||
let mut target_heartbeat_rate = 1.0_f64;
|
||||
let mut min_heartbeat_rate = 0.05_f64;
|
||||
let mut max_heartbeat_rate = 4.0_f64;
|
||||
let mut min_concurrency = 1_usize;
|
||||
let mut max_concurrency = 8_usize;
|
||||
let mut max_ticks = 8_usize;
|
||||
let mut coordinator_action: Option<String> = None;
|
||||
let mut target_role: Option<String> = None;
|
||||
let mut urgency = 0.75_f64;
|
||||
let mut urgency_explicit = false;
|
||||
let mut schedule_id = "epiphany-heartbeat".to_string();
|
||||
let mut source_scene_ref = "epiphany/coordinator".to_string();
|
||||
let mut defer_completion = false;
|
||||
let mut role: Option<String> = None;
|
||||
let mut action_id: Option<String> = None;
|
||||
let mut limit = 8_usize;
|
||||
let mut agent_store: Option<PathBuf> = None;
|
||||
let mut apply_rumination = false;
|
||||
let mut profile = "epiphany".to_string();
|
||||
let mut scene_id = "ghostlight.scene".to_string();
|
||||
let mut scene_participants = Vec::<GhostlightSceneParticipantSeed>::new();
|
||||
let mut source = "epiphany/void-routine".to_string();
|
||||
let mut no_dream = false;
|
||||
let mut heat_scope = "global".to_string();
|
||||
let mut heat_selector = String::new();
|
||||
let mut heat_multiplier = 1.0_f64;
|
||||
let mut heat_id: Option<String> = None;
|
||||
let mut heat_label: Option<String> = None;
|
||||
let mut heat_reason: Option<String> = None;
|
||||
let mut heat_expires_after: Option<f64> = None;
|
||||
let mut heat_clear = false;
|
||||
let mut channel_id: Option<String> = None;
|
||||
let mut message_id: Option<String> = None;
|
||||
let mut author_id: Option<String> = None;
|
||||
let mut author_name: Option<String> = None;
|
||||
let mut content: Option<String> = None;
|
||||
let mut visible_prompt: Option<String> = None;
|
||||
let mut reply_to_message_id: Option<String> = None;
|
||||
let mut source_surface = "operator".to_string();
|
||||
let mut mention_id: Option<String> = None;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--store" => store_path = Some(next_path(&mut args, "--store")?),
|
||||
"--artifact-dir" => artifact_dir = Some(next_path(&mut args, "--artifact-dir")?),
|
||||
"--target-heartbeat-rate" => {
|
||||
target_heartbeat_rate = next_value(&mut args, "--target-heartbeat-rate")?.parse()?
|
||||
}
|
||||
"--min-heartbeat-rate" => {
|
||||
min_heartbeat_rate = next_value(&mut args, "--min-heartbeat-rate")?.parse()?
|
||||
}
|
||||
"--max-heartbeat-rate" => {
|
||||
max_heartbeat_rate = next_value(&mut args, "--max-heartbeat-rate")?.parse()?
|
||||
}
|
||||
"--min-concurrency" => {
|
||||
min_concurrency = next_value(&mut args, "--min-concurrency")?.parse()?
|
||||
}
|
||||
"--max-concurrency" => {
|
||||
max_concurrency = next_value(&mut args, "--max-concurrency")?.parse()?
|
||||
}
|
||||
"--max-ticks" => max_ticks = next_value(&mut args, "--max-ticks")?.parse()?,
|
||||
"--coordinator-action" => {
|
||||
coordinator_action = Some(next_value(&mut args, "--coordinator-action")?)
|
||||
}
|
||||
"--target-role" => target_role = Some(next_value(&mut args, "--target-role")?),
|
||||
"--urgency" => {
|
||||
urgency = next_value(&mut args, "--urgency")?.parse()?;
|
||||
urgency_explicit = true;
|
||||
}
|
||||
"--schedule-id" => schedule_id = next_value(&mut args, "--schedule-id")?,
|
||||
"--source-scene-ref" => source_scene_ref = next_value(&mut args, "--source-scene-ref")?,
|
||||
"--defer-completion" => defer_completion = true,
|
||||
"--role" => role = Some(next_value(&mut args, "--role")?),
|
||||
"--action-id" => action_id = Some(next_value(&mut args, "--action-id")?),
|
||||
"--limit" => limit = next_value(&mut args, "--limit")?.parse()?,
|
||||
"--agent-store" => agent_store = Some(next_path(&mut args, "--agent-store")?),
|
||||
"--apply-rumination" => apply_rumination = true,
|
||||
"--profile" => profile = next_value(&mut args, "--profile")?,
|
||||
"--scene-id" => scene_id = next_value(&mut args, "--scene-id")?,
|
||||
"--scene-participant" => scene_participants.push(parse_scene_participant(
|
||||
&next_value(&mut args, "--scene-participant")?,
|
||||
)?),
|
||||
"--source" => source = next_value(&mut args, "--source")?,
|
||||
"--no-dream" => no_dream = true,
|
||||
"--scope" => heat_scope = next_value(&mut args, "--scope")?,
|
||||
"--selector" => heat_selector = next_value(&mut args, "--selector")?,
|
||||
"--multiplier" => heat_multiplier = next_value(&mut args, "--multiplier")?.parse()?,
|
||||
"--id" => heat_id = Some(next_value(&mut args, "--id")?),
|
||||
"--label" => heat_label = Some(next_value(&mut args, "--label")?),
|
||||
"--reason" => heat_reason = Some(next_value(&mut args, "--reason")?),
|
||||
"--expires-after" => {
|
||||
heat_expires_after = Some(next_value(&mut args, "--expires-after")?.parse()?)
|
||||
}
|
||||
"--clear" => heat_clear = true,
|
||||
"--channel-id" => channel_id = Some(next_value(&mut args, "--channel-id")?),
|
||||
"--message-id" => message_id = Some(next_value(&mut args, "--message-id")?),
|
||||
"--author-id" => author_id = Some(next_value(&mut args, "--author-id")?),
|
||||
"--author-name" => author_name = Some(next_value(&mut args, "--author-name")?),
|
||||
"--content" => content = Some(next_value(&mut args, "--content")?),
|
||||
"--visible-prompt" => visible_prompt = Some(next_value(&mut args, "--visible-prompt")?),
|
||||
"--reply-to-message-id" => {
|
||||
reply_to_message_id = Some(next_value(&mut args, "--reply-to-message-id")?)
|
||||
}
|
||||
"--source-surface" => source_surface = next_value(&mut args, "--source-surface")?,
|
||||
"--mention-id" => mention_id = Some(next_value(&mut args, "--mention-id")?),
|
||||
_ => return Err(anyhow!("unknown argument {arg:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
match command.as_str() {
|
||||
"init" => {
|
||||
let store_path = store_path.ok_or_else(|| anyhow!("init requires --store"))?;
|
||||
let state = match profile.as_str() {
|
||||
"epiphany" => initialize_heartbeat_store(&store_path, target_heartbeat_rate)?,
|
||||
"ghostlight-scene" => initialize_ghostlight_scene_heartbeat_store(
|
||||
&store_path,
|
||||
target_heartbeat_rate,
|
||||
scene_id.clone(),
|
||||
scene_participants,
|
||||
)?,
|
||||
other => return Err(anyhow!("unknown heartbeat profile {other:?}")),
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"command": "init",
|
||||
"profile": profile,
|
||||
"storeFile": store_path,
|
||||
"schemaVersion": state.schema_version,
|
||||
"participants": state.participants.len(),
|
||||
"history": state.history.len(),
|
||||
})
|
||||
);
|
||||
}
|
||||
"tick" => {
|
||||
let store_path = store_path.ok_or_else(|| anyhow!("tick requires --store"))?;
|
||||
let artifact_dir =
|
||||
artifact_dir.ok_or_else(|| anyhow!("tick requires --artifact-dir"))?;
|
||||
if let Some(agent_store) = &agent_store {
|
||||
let errors = validate_agent_memory_store(agent_store)?;
|
||||
if !errors.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"agent memory validation failed: {}",
|
||||
errors.join("; ")
|
||||
));
|
||||
}
|
||||
}
|
||||
let tick_schedule_id = schedule_id.clone();
|
||||
let mut result = tick_heartbeat_store(
|
||||
&store_path,
|
||||
&artifact_dir,
|
||||
HeartbeatTickOptions {
|
||||
target_heartbeat_rate,
|
||||
coordinator_action,
|
||||
target_role,
|
||||
urgency,
|
||||
schedule_id: tick_schedule_id,
|
||||
source_scene_ref,
|
||||
defer_completion,
|
||||
agent_store: agent_store.clone(),
|
||||
},
|
||||
)?;
|
||||
if apply_rumination {
|
||||
let agent_store = agent_store
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("--apply-rumination requires --agent-store"))?;
|
||||
apply_rumination_patch(&mut result, agent_store, &artifact_dir, &schedule_id)?;
|
||||
}
|
||||
println!("{}", result);
|
||||
}
|
||||
"complete" => {
|
||||
let store_path = store_path.ok_or_else(|| anyhow!("complete requires --store"))?;
|
||||
let artifact_dir =
|
||||
artifact_dir.ok_or_else(|| anyhow!("complete requires --artifact-dir"))?;
|
||||
let role = role.ok_or_else(|| anyhow!("complete requires --role"))?;
|
||||
let result = complete_heartbeat_store(
|
||||
&store_path,
|
||||
&artifact_dir,
|
||||
HeartbeatCompleteOptions { role, action_id },
|
||||
)?;
|
||||
println!("{}", result);
|
||||
}
|
||||
"pump" => {
|
||||
let store_path = store_path.ok_or_else(|| anyhow!("pump requires --store"))?;
|
||||
let artifact_dir =
|
||||
artifact_dir.ok_or_else(|| anyhow!("pump requires --artifact-dir"))?;
|
||||
if let Some(agent_store) = &agent_store {
|
||||
let errors = validate_agent_memory_store(agent_store)?;
|
||||
if !errors.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"agent memory validation failed: {}",
|
||||
errors.join("; ")
|
||||
));
|
||||
}
|
||||
}
|
||||
let result = pump_heartbeat_store(
|
||||
&store_path,
|
||||
&artifact_dir,
|
||||
HeartbeatPumpOptions {
|
||||
base_heartbeat_rate: target_heartbeat_rate,
|
||||
min_heartbeat_rate,
|
||||
max_heartbeat_rate,
|
||||
min_concurrency,
|
||||
max_concurrency,
|
||||
max_ticks,
|
||||
external_urgency: if urgency_explicit { urgency } else { 0.0 },
|
||||
coordinator_action,
|
||||
target_role,
|
||||
schedule_id,
|
||||
source_scene_ref,
|
||||
agent_store,
|
||||
},
|
||||
)?;
|
||||
println!("{}", result);
|
||||
}
|
||||
"heat" => {
|
||||
let store_path = store_path.ok_or_else(|| anyhow!("heat requires --store"))?;
|
||||
let result = update_heartbeat_heat_store(
|
||||
&store_path,
|
||||
HeartbeatHeatUpdateOptions {
|
||||
scope: heat_scope,
|
||||
selector: heat_selector,
|
||||
multiplier: heat_multiplier,
|
||||
id: heat_id,
|
||||
label: heat_label,
|
||||
reason: heat_reason,
|
||||
expires_after_scene_clock: heat_expires_after,
|
||||
clear: heat_clear,
|
||||
},
|
||||
)?;
|
||||
println!("{}", result);
|
||||
}
|
||||
"queue-mention" => {
|
||||
let store_path = store_path.ok_or_else(|| anyhow!("queue-mention requires --store"))?;
|
||||
let target_role_id = role.unwrap_or_else(|| "persona".to_string());
|
||||
let result = queue_heartbeat_pending_mention_store(
|
||||
&store_path,
|
||||
HeartbeatQueueMentionOptions {
|
||||
target_role_id,
|
||||
source_surface,
|
||||
channel_id: channel_id
|
||||
.ok_or_else(|| anyhow!("queue-mention requires --channel-id"))?,
|
||||
message_id: message_id
|
||||
.ok_or_else(|| anyhow!("queue-mention requires --message-id"))?,
|
||||
author_id: author_id
|
||||
.ok_or_else(|| anyhow!("queue-mention requires --author-id"))?,
|
||||
author_name,
|
||||
content: content.ok_or_else(|| anyhow!("queue-mention requires --content"))?,
|
||||
visible_prompt: visible_prompt
|
||||
.ok_or_else(|| anyhow!("queue-mention requires --visible-prompt"))?,
|
||||
reply_to_message_id,
|
||||
queued_at: None,
|
||||
mention_id,
|
||||
},
|
||||
)?;
|
||||
println!("{}", result);
|
||||
}
|
||||
"status" => {
|
||||
let store_path = store_path.ok_or_else(|| anyhow!("status requires --store"))?;
|
||||
if let Some(artifact_dir) = artifact_dir {
|
||||
println!(
|
||||
"{}",
|
||||
heartbeat_status_projection(
|
||||
&store_path,
|
||||
artifact_dir,
|
||||
target_heartbeat_rate,
|
||||
limit
|
||||
)?
|
||||
);
|
||||
} else {
|
||||
let state = load_heartbeat_state_entry(&store_path)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"command": "status",
|
||||
"store": store_path,
|
||||
"present": state.is_some(),
|
||||
"schemaVersion": state.as_ref().map(|value| value.schema_version.as_str()),
|
||||
"participants": state.as_ref().map(|value| value.participants.len()),
|
||||
"history": state.as_ref().map(|value| value.history.len()),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
"routine" => {
|
||||
let store_path = store_path.ok_or_else(|| anyhow!("routine requires --store"))?;
|
||||
let artifact_dir =
|
||||
artifact_dir.ok_or_else(|| anyhow!("routine requires --artifact-dir"))?;
|
||||
if let Some(agent_store) = &agent_store {
|
||||
let errors = validate_agent_memory_store(agent_store)?;
|
||||
if !errors.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"agent memory validation failed: {}",
|
||||
errors.join("; ")
|
||||
));
|
||||
}
|
||||
}
|
||||
let result = run_void_routine_store(
|
||||
&store_path,
|
||||
&artifact_dir,
|
||||
VoidRoutineOptions {
|
||||
agent_store,
|
||||
source,
|
||||
allow_dream: !no_dream,
|
||||
},
|
||||
)?;
|
||||
println!("{}", result);
|
||||
}
|
||||
"smoke" => {
|
||||
let agent_store = agent_store.unwrap_or_else(|| PathBuf::from("state/agents.cc"));
|
||||
let result = run_smoke(&agent_store)?;
|
||||
let ok = result["ok"].as_bool().unwrap_or(false);
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
if !ok {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
_ => return usage(),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_path(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(next_value(args, name)?))
|
||||
}
|
||||
|
||||
fn next_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
args.next()
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
|
||||
fn usage() -> Result<()> {
|
||||
Err(anyhow!(
|
||||
"usage: epiphany-heartbeat-store init --store <path> [--profile epiphany|ghostlight-scene] [--scene-id <id>] [--scene-participant <id|name|speed|reaction|threshold|constraint;constraint>]\n epiphany-heartbeat-store tick --store <path> --artifact-dir <path> [--coordinator-action <action>] [--agent-store <path> --apply-rumination] [--defer-completion]\n epiphany-heartbeat-store pump --store <path> --artifact-dir <path> [--agent-store <path>] [--urgency <0..1>] [--min-heartbeat-rate <n>] [--max-heartbeat-rate <n>] [--min-concurrency <n>] [--max-concurrency <n>] [--max-ticks <n>]\n epiphany-heartbeat-store heat --store <path> [--scope global|all|agent|role|arena|participant_kind|group] [--selector <id>] [--multiplier <n>] [--id <id>] [--label <text>] [--reason <text>] [--expires-after <scene-clock-delta>] [--clear]\n epiphany-heartbeat-store complete --store <path> --artifact-dir <path> --role <role> [--action-id <id>]\n epiphany-heartbeat-store queue-mention --store <path> [--role persona] --channel-id <id> --message-id <id> --author-id <id> --content <text> --visible-prompt <text> [--author-name <name>] [--reply-to-message-id <id>] [--source-surface <name>]\n epiphany-heartbeat-store status --store <path> [--artifact-dir <path>]\n epiphany-heartbeat-store routine --store <path> --artifact-dir <path> [--agent-store <path>] [--source <source>] [--no-dream]\n epiphany-heartbeat-store smoke [--agent-store <path>]"
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_scene_participant(raw: &str) -> Result<GhostlightSceneParticipantSeed> {
|
||||
let parts = raw.split('|').collect::<Vec<_>>();
|
||||
if parts.len() != 6 {
|
||||
return Err(anyhow!(
|
||||
"--scene-participant must be id|name|speed|reaction|threshold|constraint;constraint"
|
||||
));
|
||||
}
|
||||
Ok(GhostlightSceneParticipantSeed {
|
||||
agent_id: parts[0].trim().to_string(),
|
||||
display_name: parts[1].trim().to_string(),
|
||||
initiative_speed: parts[2].trim().parse()?,
|
||||
reaction_bias: parts[3].trim().parse()?,
|
||||
interrupt_threshold: parts[4].trim().parse()?,
|
||||
constraints: parts[5]
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn run_smoke(agent_store: &Path) -> Result<serde_json::Value> {
|
||||
let temp_dir = scoped_temp_dir("epiphany-heartbeat-smoke")?;
|
||||
let temp_agent_store = temp_dir.join("agents.cc");
|
||||
let store_path = temp_dir.join("heartbeats.cc");
|
||||
let artifact_dir = temp_dir.join("artifacts");
|
||||
fs::copy(agent_store, &temp_agent_store).with_context(|| {
|
||||
format!(
|
||||
"failed to copy {} to {}",
|
||||
agent_store.display(),
|
||||
temp_agent_store.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let initial_errors = validate_agent_memory_store(&temp_agent_store)?;
|
||||
if !initial_errors.is_empty() {
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return Ok(serde_json::json!({
|
||||
"ok": false,
|
||||
"phase": "agent-memory-validate",
|
||||
"validationErrors": initial_errors,
|
||||
}));
|
||||
}
|
||||
|
||||
initialize_heartbeat_store(&store_path, 1.0)?;
|
||||
let heat = update_heartbeat_heat_store(
|
||||
&store_path,
|
||||
HeartbeatHeatUpdateOptions {
|
||||
scope: "role".to_string(),
|
||||
selector: "implementation".to_string(),
|
||||
multiplier: 4.0,
|
||||
id: Some("smoke-implementation-heat".to_string()),
|
||||
label: Some("Smoke implementation heat".to_string()),
|
||||
reason: Some(
|
||||
"Prove heartbeat initiative heat changes the live turn surface.".to_string(),
|
||||
),
|
||||
expires_after_scene_clock: Some(20.0),
|
||||
clear: false,
|
||||
},
|
||||
)?;
|
||||
let work = tick_heartbeat_store(
|
||||
&store_path,
|
||||
&artifact_dir,
|
||||
HeartbeatTickOptions {
|
||||
target_heartbeat_rate: 1.0,
|
||||
coordinator_action: Some("continueImplementation".to_string()),
|
||||
target_role: None,
|
||||
urgency: 0.95,
|
||||
schedule_id: "smoke-work".to_string(),
|
||||
source_scene_ref: "smoke/coordinator".to_string(),
|
||||
defer_completion: true,
|
||||
agent_store: Some(temp_agent_store.clone()),
|
||||
},
|
||||
)?;
|
||||
let blocked_repeat = tick_heartbeat_store(
|
||||
&store_path,
|
||||
&artifact_dir,
|
||||
HeartbeatTickOptions {
|
||||
target_heartbeat_rate: 1.0,
|
||||
coordinator_action: Some("continueImplementation".to_string()),
|
||||
target_role: None,
|
||||
urgency: 0.95,
|
||||
schedule_id: "smoke-work".to_string(),
|
||||
source_scene_ref: "smoke/coordinator".to_string(),
|
||||
defer_completion: true,
|
||||
agent_store: Some(temp_agent_store.clone()),
|
||||
},
|
||||
)
|
||||
.err()
|
||||
.map(|error| error.to_string());
|
||||
|
||||
let action_id = work["event"]["actionId"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("smoke work event has no actionId"))?
|
||||
.to_string();
|
||||
let completed = complete_heartbeat_store(
|
||||
&store_path,
|
||||
&artifact_dir,
|
||||
HeartbeatCompleteOptions {
|
||||
role: "implementation".to_string(),
|
||||
action_id: Some(action_id),
|
||||
},
|
||||
)?;
|
||||
let mut idle = tick_heartbeat_store(
|
||||
&store_path,
|
||||
&artifact_dir,
|
||||
HeartbeatTickOptions {
|
||||
target_heartbeat_rate: 1.0,
|
||||
coordinator_action: None,
|
||||
target_role: None,
|
||||
urgency: 0.0,
|
||||
schedule_id: "smoke-idle".to_string(),
|
||||
source_scene_ref: "smoke/idle".to_string(),
|
||||
defer_completion: false,
|
||||
agent_store: Some(temp_agent_store.clone()),
|
||||
},
|
||||
)?;
|
||||
|
||||
if let Some(patch) = idle["rumination"]["selfPatch"].as_object() {
|
||||
let role_id = idle["rumination"]["roleId"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("idle rumination has no roleId"))?
|
||||
.to_string();
|
||||
let patch = serde_json::from_value(serde_json::Value::Object(patch.clone()))
|
||||
.context("idle rumination selfPatch is not a typed AgentSelfPatch")?;
|
||||
let applied = apply_agent_self_patch_document(&role_id, patch, &temp_agent_store)?;
|
||||
idle["rumination"]["result"] = serde_json::to_value(applied)?;
|
||||
idle["rumination"]["applied"] = serde_json::Value::Bool(true);
|
||||
write_rumination_artifact(&artifact_dir, "smoke-idle", &idle["rumination"])?;
|
||||
}
|
||||
|
||||
let validation_errors = validate_agent_memory_store(&temp_agent_store)?;
|
||||
let status = heartbeat_status_projection(&store_path, &artifact_dir, 1.0, 8)?;
|
||||
let routine = run_void_routine_store(
|
||||
&store_path,
|
||||
&artifact_dir,
|
||||
VoidRoutineOptions {
|
||||
agent_store: Some(temp_agent_store.clone()),
|
||||
source: "smoke/void-routine".to_string(),
|
||||
allow_dream: true,
|
||||
},
|
||||
)?;
|
||||
let routine_status = heartbeat_status_projection(&store_path, &artifact_dir, 1.0, 8)?;
|
||||
let relaxed_store_path = temp_dir.join("relaxed-heartbeats.cc");
|
||||
initialize_heartbeat_store(&relaxed_store_path, 1.0)?;
|
||||
let relaxed_pump = pump_heartbeat_store(
|
||||
&relaxed_store_path,
|
||||
&artifact_dir,
|
||||
HeartbeatPumpOptions {
|
||||
base_heartbeat_rate: 1.0,
|
||||
min_heartbeat_rate: 0.05,
|
||||
max_heartbeat_rate: 4.0,
|
||||
min_concurrency: 1,
|
||||
max_concurrency: 8,
|
||||
max_ticks: 8,
|
||||
external_urgency: 0.0,
|
||||
coordinator_action: None,
|
||||
target_role: None,
|
||||
schedule_id: "smoke-relaxed-pump".to_string(),
|
||||
source_scene_ref: "smoke/relaxed".to_string(),
|
||||
agent_store: Some(temp_agent_store.clone()),
|
||||
},
|
||||
)?;
|
||||
let alarmed_store_path = temp_dir.join("alarmed-heartbeats.cc");
|
||||
initialize_heartbeat_store(&alarmed_store_path, 1.0)?;
|
||||
let alarmed_pump = pump_heartbeat_store(
|
||||
&alarmed_store_path,
|
||||
&artifact_dir,
|
||||
HeartbeatPumpOptions {
|
||||
base_heartbeat_rate: 1.0,
|
||||
min_heartbeat_rate: 0.05,
|
||||
max_heartbeat_rate: 4.0,
|
||||
min_concurrency: 1,
|
||||
max_concurrency: 8,
|
||||
max_ticks: 8,
|
||||
external_urgency: 1.0,
|
||||
coordinator_action: None,
|
||||
target_role: None,
|
||||
schedule_id: "smoke-alarmed-pump".to_string(),
|
||||
source_scene_ref: "smoke/alarmed".to_string(),
|
||||
agent_store: Some(temp_agent_store.clone()),
|
||||
},
|
||||
)?;
|
||||
let initiative_errors = validate_schedule_shape(&work["schedule"])
|
||||
.into_iter()
|
||||
.chain(validate_schedule_shape(&idle["schedule"]))
|
||||
.collect::<Vec<_>>();
|
||||
let ok = work["event"]["selectedRole"] == "implementation"
|
||||
&& work["event"]["turnStatus"] == "running"
|
||||
&& blocked_repeat
|
||||
.as_deref()
|
||||
.is_some_and(|message| message.contains("already has running heartbeat turn"))
|
||||
&& completed["event"]["turnStatus"] == "completed"
|
||||
&& idle["event"]["actionType"] == "ruminate_memory"
|
||||
&& idle["event"]["turnStatus"] == "completed"
|
||||
&& idle["event"]["nextReadyAt"].as_f64().unwrap_or_default()
|
||||
> completed["event"]["nextReadyAt"]
|
||||
.as_f64()
|
||||
.unwrap_or_default()
|
||||
&& idle["rumination"]["result"]["status"] == "accepted"
|
||||
&& validation_errors.is_empty()
|
||||
&& initiative_errors.is_empty()
|
||||
&& heat["heat"]["multipliers"]
|
||||
.as_array()
|
||||
.is_some_and(|multipliers| {
|
||||
multipliers.iter().any(|multiplier| {
|
||||
multiplier["id"] == "smoke-implementation-heat"
|
||||
&& multiplier["scope"] == "role"
|
||||
&& multiplier["selector"] == "implementation"
|
||||
&& multiplier["multiplier"].as_f64() == Some(4.0)
|
||||
})
|
||||
})
|
||||
&& work["schedule"]["action_catalog"]
|
||||
.as_array()
|
||||
.and_then(|actions| actions.first())
|
||||
.and_then(|action| action["initiative_heat_multiplier"].as_f64())
|
||||
== Some(4.0)
|
||||
&& artifact_dir.join("smoke-work.initiative.json").exists()
|
||||
&& artifact_dir.join("smoke-work.completion.json").exists()
|
||||
&& artifact_dir.join("smoke-idle.rumination.json").exists()
|
||||
&& status["participants"].as_array().map(Vec::len) == Some(8)
|
||||
&& routine["routine"]["schema_version"] == "epiphany.void_routine.v0"
|
||||
&& routine["routine"]["memoryResonance"]["schema_version"]
|
||||
== "epiphany.memory_resonance.v0"
|
||||
&& routine["routine"]["incubation"]["schema_version"] == "epiphany.incubation.v0"
|
||||
&& routine["routine"]["thoughtLanes"]["schema_version"] == "epiphany.cognition_lanes.v0"
|
||||
&& routine["routine"]["bridge"]["schema_version"] == "epiphany.cognition_bridge.v0"
|
||||
&& routine["routine"]["candidateInterventions"]["schema_version"]
|
||||
== "epiphany.candidate_interventions.v0"
|
||||
&& routine["routine"]["appraisals"]["schema_version"]
|
||||
== "epiphany.agent_thought_appraisals.v0"
|
||||
&& routine["routine"]["reactions"]["schema_version"] == "epiphany.agent_reactions.v0"
|
||||
&& routine["routine"]["incubation"]["sourceCoverage"]["schema_version"]
|
||||
== "epiphany.source_coverage.v0"
|
||||
&& routine["routine"]["incubation"]["themes"]
|
||||
.as_array()
|
||||
.and_then(|themes| themes.first())
|
||||
.is_some_and(|theme| {
|
||||
theme
|
||||
.get("noveltyToSelf")
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.is_some()
|
||||
&& theme
|
||||
.get("noveltyToRoom")
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.is_some()
|
||||
&& theme
|
||||
.get("saturationScore")
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.is_some()
|
||||
&& theme
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some()
|
||||
})
|
||||
&& routine["routine"]["bridge"]["sourceCoverage"]["schema_version"]
|
||||
== "epiphany.source_coverage.v0"
|
||||
&& routine["routine"]["bridge"]["refractoryTopics"].is_array()
|
||||
&& routine["routine"]["bridge"]["decision"]["reason"]
|
||||
.as_str()
|
||||
.is_some_and(|reason| !reason.is_empty())
|
||||
&& routine_status["cognitionQuarantine"]["schema_version"]
|
||||
== "epiphany.agent_heartbeat_cognition.v0"
|
||||
&& routine_status["cognitionQuarantine"]["latestRunId"].as_str()
|
||||
== routine["routine"]["runId"].as_str()
|
||||
&& routine_status["participants"]
|
||||
.as_array()
|
||||
.and_then(|participants| {
|
||||
let hands = participants
|
||||
.iter()
|
||||
.find(|item| item["roleId"] == "implementation")?;
|
||||
let self_lane = participants
|
||||
.iter()
|
||||
.find(|item| item["roleId"] == "coordinator")?;
|
||||
let hands_personality = hands["personalityCooldownMultiplier"]
|
||||
.as_f64()
|
||||
.unwrap_or(1.0);
|
||||
let self_personality = self_lane["personalityCooldownMultiplier"]
|
||||
.as_f64()
|
||||
.unwrap_or(1.0);
|
||||
let hands_effective = hands["effectiveCooldownMultiplier"].as_f64().unwrap_or(1.0);
|
||||
let self_effective = self_lane["effectiveCooldownMultiplier"]
|
||||
.as_f64()
|
||||
.unwrap_or(1.0);
|
||||
Some(hands_personality < self_personality && hands_effective < self_effective)
|
||||
})
|
||||
== Some(true)
|
||||
&& routine_status["sleepCycle"]["schema_version"] == "epiphany.sleep_cycle.v0";
|
||||
let ok = ok
|
||||
&& relaxed_pump["pump"]["launched"].as_u64() == Some(0)
|
||||
&& relaxed_pump["pump"]["pacing"]["targetConcurrency"].as_u64() == Some(0)
|
||||
&& alarmed_pump["pump"]["launched"].as_u64().unwrap_or(0) >= 6
|
||||
&& alarmed_pump["pump"]["pacing"]["targetConcurrency"].as_u64() == Some(8);
|
||||
|
||||
let result = serde_json::json!({
|
||||
"ok": ok,
|
||||
"workEvent": work["event"],
|
||||
"heat": heat["heat"],
|
||||
"blockedRepeat": blocked_repeat,
|
||||
"completionEvent": completed["event"],
|
||||
"idleEvent": idle["event"],
|
||||
"idleRumination": idle["rumination"],
|
||||
"voidRoutine": routine["routine"],
|
||||
"personalityTiming": routine_status["participants"],
|
||||
"relaxedPump": relaxed_pump["pump"],
|
||||
"alarmedPump": alarmed_pump["pump"],
|
||||
"validationErrors": validation_errors,
|
||||
"initiativeErrors": initiative_errors,
|
||||
});
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn apply_rumination_patch(
|
||||
result: &mut serde_json::Value,
|
||||
agent_store: &Path,
|
||||
artifact_dir: &Path,
|
||||
schedule_id: &str,
|
||||
) -> Result<()> {
|
||||
if result["rumination"].is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(patch) = result["rumination"]["selfPatch"].as_object() else {
|
||||
return Ok(());
|
||||
};
|
||||
let role_id = result["rumination"]["roleId"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("rumination has no roleId"))?
|
||||
.to_string();
|
||||
let patch = serde_json::from_value(serde_json::Value::Object(patch.clone()))
|
||||
.context("rumination selfPatch is not a typed AgentSelfPatch")?;
|
||||
let applied = apply_agent_self_patch_document(&role_id, patch, agent_store)?;
|
||||
result["rumination"]["result"] = serde_json::to_value(applied)?;
|
||||
result["rumination"]["applied"] = serde_json::Value::Bool(true);
|
||||
write_rumination_artifact(artifact_dir, schedule_id, &result["rumination"])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_rumination_artifact(
|
||||
artifact_dir: &Path,
|
||||
schedule_id: &str,
|
||||
value: &serde_json::Value,
|
||||
) -> Result<()> {
|
||||
fs::create_dir_all(artifact_dir)
|
||||
.with_context(|| format!("failed to create {}", artifact_dir.display()))?;
|
||||
let path = artifact_dir.join(format!("{schedule_id}.rumination.json"));
|
||||
fs::write(&path, format!("{}\n", serde_json::to_string_pretty(value)?))
|
||||
.with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn validate_schedule_shape(schedule: &serde_json::Value) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
let required = [
|
||||
"schema_version",
|
||||
"schedule_id",
|
||||
"source_scene_ref",
|
||||
"scene_clock",
|
||||
"participants",
|
||||
"action_catalog",
|
||||
"reaction_windows",
|
||||
"selection_policy",
|
||||
"next_actor_selection",
|
||||
"review_notes",
|
||||
];
|
||||
for key in required {
|
||||
if schedule.get(key).is_none() {
|
||||
errors.push(format!("initiative schedule missing {key}"));
|
||||
}
|
||||
}
|
||||
if schedule["schema_version"] != "ghostlight.initiative_schedule.v0" {
|
||||
errors.push("initiative schedule has wrong schema_version".to_string());
|
||||
}
|
||||
if !schedule["scene_clock"].is_number()
|
||||
|| schedule["scene_clock"]
|
||||
.as_f64()
|
||||
.is_some_and(|clock| clock < 0.0)
|
||||
{
|
||||
errors.push("initiative schedule scene_clock must be non-negative number".to_string());
|
||||
}
|
||||
for participant in schedule["participants"].as_array().into_iter().flatten() {
|
||||
if ![
|
||||
"active",
|
||||
"blocked",
|
||||
"withdrawn",
|
||||
"incapacitated",
|
||||
"offscreen",
|
||||
]
|
||||
.contains(&participant["status"].as_str().unwrap_or_default())
|
||||
{
|
||||
errors.push(format!(
|
||||
"participant {:?} has invalid status",
|
||||
participant["agent_id"]
|
||||
));
|
||||
}
|
||||
for key in [
|
||||
"initiative_speed",
|
||||
"next_ready_at",
|
||||
"reaction_bias",
|
||||
"interrupt_threshold",
|
||||
"current_load",
|
||||
] {
|
||||
if !participant[key].is_number() {
|
||||
errors.push(format!(
|
||||
"participant {:?} {key} must be numeric",
|
||||
participant["agent_id"]
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for action in schedule["action_catalog"].as_array().into_iter().flatten() {
|
||||
if ![
|
||||
"speak",
|
||||
"silence",
|
||||
"move",
|
||||
"gesture",
|
||||
"touch_object",
|
||||
"block_object",
|
||||
"use_object",
|
||||
"show_object",
|
||||
"withhold_object",
|
||||
"transfer_object",
|
||||
"spend_resource",
|
||||
"attack",
|
||||
"wait",
|
||||
"mixed",
|
||||
"role_work",
|
||||
"ruminate_memory",
|
||||
"scene_turn",
|
||||
"persona_turn",
|
||||
]
|
||||
.contains(&action["action_type"].as_str().unwrap_or_default())
|
||||
{
|
||||
errors.push(format!(
|
||||
"action {:?} has invalid action_type",
|
||||
action["action_id"]
|
||||
));
|
||||
}
|
||||
if !["micro", "short", "standard", "major", "committed"]
|
||||
.contains(&action["action_scale"].as_str().unwrap_or_default())
|
||||
{
|
||||
errors.push(format!(
|
||||
"action {:?} has invalid action_scale",
|
||||
action["action_id"]
|
||||
));
|
||||
}
|
||||
}
|
||||
let selection = &schedule["next_actor_selection"];
|
||||
if ![
|
||||
"scheduled_turn",
|
||||
"reaction_interrupt",
|
||||
"coordinator_override",
|
||||
]
|
||||
.contains(&selection["selection_kind"].as_str().unwrap_or_default())
|
||||
{
|
||||
errors.push("next_actor_selection has invalid selection_kind".to_string());
|
||||
}
|
||||
for snapshot in selection["readiness_snapshot"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if let Some(object) = snapshot.as_object() {
|
||||
let extra_keys = object
|
||||
.keys()
|
||||
.filter(|key| {
|
||||
![
|
||||
"agent_id",
|
||||
"arena",
|
||||
"participant_kind",
|
||||
"next_ready_at",
|
||||
"initiative_frozen",
|
||||
"freeze_reason",
|
||||
"reaction_readiness",
|
||||
"eligible_for_reaction",
|
||||
]
|
||||
.contains(&key.as_str())
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !extra_keys.is_empty() {
|
||||
errors.push(format!(
|
||||
"readiness snapshot has Ghostlight-incompatible keys: {extra_keys:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
fn scoped_temp_dir(prefix: &str) -> Result<PathBuf> {
|
||||
let path = env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&path).with_context(|| format!("failed to create {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
582
epiphany-core/src/bin/epiphany-memory-graph.rs
Normal file
582
epiphany-core/src/bin/epiphany-memory-graph.rs
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use epiphany_core::EpiphanyMemoryAnchor;
|
||||
use epiphany_core::EpiphanyMemoryContextQuery;
|
||||
use epiphany_core::EpiphanyMemoryDomain;
|
||||
use epiphany_core::EpiphanyMemoryEdge;
|
||||
use epiphany_core::EpiphanyMemoryEdgeKind;
|
||||
use epiphany_core::EpiphanyMemoryFreshnessStatus;
|
||||
use epiphany_core::EpiphanyMemoryGraphSnapshot;
|
||||
use epiphany_core::EpiphanyMemoryLifecycle;
|
||||
use epiphany_core::EpiphanyMemoryNode;
|
||||
use epiphany_core::EpiphanyMemoryNodeKind;
|
||||
use epiphany_core::EpiphanyMemoryProfile;
|
||||
use epiphany_core::EpiphanyMemorySummary;
|
||||
use epiphany_core::agent_memory_role_ids;
|
||||
use epiphany_core::compose_memory_graph_snapshots;
|
||||
use epiphany_core::derive_memory_graph_freshness;
|
||||
use epiphany_core::load_agent_memory_entry_for_role;
|
||||
use epiphany_core::load_heartbeat_cognition_entry;
|
||||
use epiphany_core::load_memory_graph_snapshot;
|
||||
use epiphany_core::load_thread_state;
|
||||
use epiphany_core::memory_graph_domain_id;
|
||||
use epiphany_core::memory_graph_edge_id;
|
||||
use epiphany_core::memory_graph_from_agent_memories;
|
||||
use epiphany_core::memory_graph_from_epiphany_graphs;
|
||||
use epiphany_core::memory_graph_from_heartbeat_cognition;
|
||||
use epiphany_core::memory_graph_node_id;
|
||||
use epiphany_core::plan_memory_graph_context_cut;
|
||||
use epiphany_core::validate_memory_graph_snapshot;
|
||||
use epiphany_core::write_memory_graph_snapshot;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = env::args().skip(1);
|
||||
let Some(command) = args.next() else {
|
||||
print_usage();
|
||||
std::process::exit(2);
|
||||
};
|
||||
|
||||
match command.as_str() {
|
||||
"status" => {
|
||||
let store = require_path_arg(&mut args, "--store")?;
|
||||
let output = memory_graph_status(&store)?;
|
||||
print_json(&output)?;
|
||||
}
|
||||
"validate" => {
|
||||
let store = require_path_arg(&mut args, "--store")?;
|
||||
let snapshot = load_memory_graph_snapshot(&store)?
|
||||
.ok_or_else(|| anyhow!("memory graph store {} is missing", store.display()))?;
|
||||
let errors = validate_memory_graph_snapshot(&snapshot);
|
||||
print_json(&serde_json::json!({
|
||||
"ok": errors.is_empty(),
|
||||
"store": store,
|
||||
"graphId": snapshot.graph_id,
|
||||
"errors": errors.iter().map(|error| serde_json::json!({
|
||||
"path": error.path,
|
||||
"message": error.message,
|
||||
})).collect::<Vec<_>>(),
|
||||
}))?;
|
||||
if !errors.is_empty() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
"context" => {
|
||||
let store = require_path_arg(&mut args, "--store")?;
|
||||
let query = read_context_query(args)?;
|
||||
let snapshot = load_memory_graph_snapshot(&store)?
|
||||
.ok_or_else(|| anyhow!("memory graph store {} is missing", store.display()))?;
|
||||
let packet = plan_memory_graph_context_cut(&snapshot, &query);
|
||||
print_json(&packet)?;
|
||||
}
|
||||
"compose" => {
|
||||
let compose = read_compose_args(args)?;
|
||||
let output = compose_memory_graph_store(compose)?;
|
||||
print_json(&output)?;
|
||||
}
|
||||
"refresh" => {
|
||||
let refresh = read_refresh_args(args)?;
|
||||
let output = refresh_memory_graph_store(refresh)?;
|
||||
print_json(&output)?;
|
||||
}
|
||||
"smoke" => {
|
||||
let store = optional_path_arg(&mut args, "--store")?
|
||||
.unwrap_or_else(|| scoped_temp_store("epiphany-memory-graph-smoke"));
|
||||
let output = run_smoke(store)?;
|
||||
let ok = output["ok"].as_bool().unwrap_or(false);
|
||||
print_json(&output)?;
|
||||
if !ok {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
print_usage();
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn memory_graph_status(store: &PathBuf) -> Result<serde_json::Value> {
|
||||
let Some(snapshot) = load_memory_graph_snapshot(store)? else {
|
||||
return Ok(serde_json::json!({
|
||||
"present": false,
|
||||
"store": store,
|
||||
}));
|
||||
};
|
||||
let errors = validate_memory_graph_snapshot(&snapshot);
|
||||
let freshness = snapshot
|
||||
.freshness
|
||||
.clone()
|
||||
.unwrap_or_else(|| derive_memory_graph_freshness(&snapshot, &[]));
|
||||
Ok(serde_json::json!({
|
||||
"present": true,
|
||||
"store": store,
|
||||
"graphId": snapshot.graph_id,
|
||||
"domains": snapshot.domains.len(),
|
||||
"nodes": snapshot.nodes.len(),
|
||||
"edges": snapshot.edges.len(),
|
||||
"summaries": snapshot.summaries.len(),
|
||||
"freshness": freshness,
|
||||
"valid": errors.is_empty(),
|
||||
"errors": errors.iter().map(|error| serde_json::json!({
|
||||
"path": error.path,
|
||||
"message": error.message,
|
||||
})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn read_context_query(args: impl Iterator<Item = String>) -> Result<EpiphanyMemoryContextQuery> {
|
||||
let mut query = EpiphanyMemoryContextQuery {
|
||||
id: "memory-graph-context-query".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut args = args.peekable();
|
||||
while let Some(flag) = args.next() {
|
||||
match flag.as_str() {
|
||||
"--query-id" => query.id = next_value(&mut args, "--query-id")?,
|
||||
"--profile" => {
|
||||
query.profile = Some(parse_profile(&next_value(&mut args, "--profile")?)?)
|
||||
}
|
||||
"--domain-id" => query.domain_ids.push(next_value(&mut args, "--domain-id")?),
|
||||
"--node-id" => query.node_ids.push(next_value(&mut args, "--node-id")?),
|
||||
"--edge-id" => query.edge_ids.push(next_value(&mut args, "--edge-id")?),
|
||||
"--text" => query.text = Some(next_value(&mut args, "--text")?),
|
||||
"--budget" => {
|
||||
let value = next_value(&mut args, "--budget")?;
|
||||
query.budget = Some(
|
||||
value
|
||||
.parse::<u32>()
|
||||
.with_context(|| format!("invalid --budget {value:?}"))?,
|
||||
);
|
||||
}
|
||||
_ => return Err(anyhow!("unexpected context argument {flag:?}")),
|
||||
}
|
||||
}
|
||||
Ok(query)
|
||||
}
|
||||
|
||||
struct ComposeArgs {
|
||||
store: PathBuf,
|
||||
graph_id: String,
|
||||
sources: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
struct RefreshArgs {
|
||||
store: PathBuf,
|
||||
graph_id: String,
|
||||
sources: Vec<PathBuf>,
|
||||
agent_store: Option<PathBuf>,
|
||||
agent_roles: Vec<String>,
|
||||
heartbeat_store: Option<PathBuf>,
|
||||
thread_state_store: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn read_compose_args(args: impl Iterator<Item = String>) -> Result<ComposeArgs> {
|
||||
let mut store = None;
|
||||
let mut graph_id = "epiphany-memory-graph".to_string();
|
||||
let mut sources = Vec::new();
|
||||
let mut args = args.peekable();
|
||||
while let Some(flag) = args.next() {
|
||||
match flag.as_str() {
|
||||
"--store" => store = Some(PathBuf::from(next_value(&mut args, "--store")?)),
|
||||
"--graph-id" => graph_id = next_value(&mut args, "--graph-id")?,
|
||||
"--source" => sources.push(PathBuf::from(next_value(&mut args, "--source")?)),
|
||||
_ => return Err(anyhow!("unexpected compose argument {flag:?}")),
|
||||
}
|
||||
}
|
||||
if sources.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"compose requires at least one --source memory graph store"
|
||||
));
|
||||
}
|
||||
Ok(ComposeArgs {
|
||||
store: store.ok_or_else(|| anyhow!("compose requires --store <path>"))?,
|
||||
graph_id,
|
||||
sources,
|
||||
})
|
||||
}
|
||||
|
||||
fn compose_memory_graph_store(args: ComposeArgs) -> Result<serde_json::Value> {
|
||||
let mut snapshots = Vec::new();
|
||||
let mut source_status = Vec::new();
|
||||
for source in &args.sources {
|
||||
let snapshot = load_memory_graph_snapshot(source)?
|
||||
.ok_or_else(|| anyhow!("memory graph source {} is missing", source.display()))?;
|
||||
source_status.push(serde_json::json!({
|
||||
"source": source,
|
||||
"graphId": snapshot.graph_id,
|
||||
"domains": snapshot.domains.len(),
|
||||
"nodes": snapshot.nodes.len(),
|
||||
"edges": snapshot.edges.len(),
|
||||
"summaries": snapshot.summaries.len(),
|
||||
}));
|
||||
snapshots.push(snapshot);
|
||||
}
|
||||
|
||||
let snapshot = compose_memory_graph_snapshots(args.graph_id, snapshots).map_err(|errors| {
|
||||
anyhow!(
|
||||
"composed memory graph is invalid: {}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| format!("{}: {}", error.path, error.message))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
)
|
||||
})?;
|
||||
write_memory_graph_snapshot(&args.store, &snapshot)?;
|
||||
Ok(serde_json::json!({
|
||||
"ok": true,
|
||||
"store": args.store,
|
||||
"graphId": snapshot.graph_id,
|
||||
"sources": source_status,
|
||||
"domains": snapshot.domains.len(),
|
||||
"nodes": snapshot.nodes.len(),
|
||||
"edges": snapshot.edges.len(),
|
||||
"summaries": snapshot.summaries.len(),
|
||||
"freshness": snapshot.freshness,
|
||||
}))
|
||||
}
|
||||
|
||||
fn read_refresh_args(args: impl Iterator<Item = String>) -> Result<RefreshArgs> {
|
||||
let mut store = None;
|
||||
let mut graph_id = "epiphany-memory-graph".to_string();
|
||||
let mut sources = Vec::new();
|
||||
let mut agent_store = None;
|
||||
let mut agent_roles = Vec::new();
|
||||
let mut heartbeat_store = None;
|
||||
let mut thread_state_store = None;
|
||||
let mut args = args.peekable();
|
||||
while let Some(flag) = args.next() {
|
||||
match flag.as_str() {
|
||||
"--store" => store = Some(PathBuf::from(next_value(&mut args, "--store")?)),
|
||||
"--graph-id" => graph_id = next_value(&mut args, "--graph-id")?,
|
||||
"--source" => sources.push(PathBuf::from(next_value(&mut args, "--source")?)),
|
||||
"--agent-store" => {
|
||||
agent_store = Some(PathBuf::from(next_value(&mut args, "--agent-store")?))
|
||||
}
|
||||
"--agent-role" => agent_roles.push(next_value(&mut args, "--agent-role")?),
|
||||
"--heartbeat-store" => {
|
||||
heartbeat_store = Some(PathBuf::from(next_value(&mut args, "--heartbeat-store")?))
|
||||
}
|
||||
"--thread-state-store" => {
|
||||
thread_state_store = Some(PathBuf::from(next_value(
|
||||
&mut args,
|
||||
"--thread-state-store",
|
||||
)?))
|
||||
}
|
||||
_ => return Err(anyhow!("unexpected refresh argument {flag:?}")),
|
||||
}
|
||||
}
|
||||
if sources.is_empty()
|
||||
&& agent_store.is_none()
|
||||
&& heartbeat_store.is_none()
|
||||
&& thread_state_store.is_none()
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"refresh requires --source, --thread-state-store, --agent-store, or --heartbeat-store"
|
||||
));
|
||||
}
|
||||
Ok(RefreshArgs {
|
||||
store: store.ok_or_else(|| anyhow!("refresh requires --store <path>"))?,
|
||||
graph_id,
|
||||
sources,
|
||||
agent_store,
|
||||
agent_roles,
|
||||
heartbeat_store,
|
||||
thread_state_store,
|
||||
})
|
||||
}
|
||||
|
||||
fn refresh_memory_graph_store(args: RefreshArgs) -> Result<serde_json::Value> {
|
||||
let mut snapshots = Vec::new();
|
||||
let mut source_status = Vec::new();
|
||||
|
||||
for source in &args.sources {
|
||||
let snapshot = load_memory_graph_snapshot(source)?
|
||||
.ok_or_else(|| anyhow!("memory graph source {} is missing", source.display()))?;
|
||||
source_status.push(snapshot_status("source_store", source, &snapshot));
|
||||
snapshots.push(snapshot);
|
||||
}
|
||||
|
||||
if let Some(agent_store) = &args.agent_store {
|
||||
let roles = if args.agent_roles.is_empty() {
|
||||
agent_memory_role_ids()
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
args.agent_roles.clone()
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
for role in &roles {
|
||||
let entry = load_agent_memory_entry_for_role(agent_store, role)?.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"agent memory store {} is missing role {role:?}",
|
||||
agent_store.display()
|
||||
)
|
||||
})?;
|
||||
entries.push(entry);
|
||||
}
|
||||
let snapshot = memory_graph_from_agent_memories("agent-profile", &entries);
|
||||
source_status.push(serde_json::json!({
|
||||
"kind": "agent_store",
|
||||
"source": agent_store,
|
||||
"roles": roles,
|
||||
"graphId": snapshot.graph_id,
|
||||
"domains": snapshot.domains.len(),
|
||||
"nodes": snapshot.nodes.len(),
|
||||
"edges": snapshot.edges.len(),
|
||||
"summaries": snapshot.summaries.len(),
|
||||
}));
|
||||
snapshots.push(snapshot);
|
||||
}
|
||||
|
||||
if let Some(thread_state_store) = &args.thread_state_store {
|
||||
let state = load_thread_state(thread_state_store)?.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"thread state store {} is missing",
|
||||
thread_state_store.display()
|
||||
)
|
||||
})?;
|
||||
let snapshot = memory_graph_from_epiphany_graphs("repo-profile", &state.graphs);
|
||||
source_status.push(serde_json::json!({
|
||||
"kind": "thread_state_store",
|
||||
"source": thread_state_store,
|
||||
"revision": state.revision,
|
||||
"graphId": snapshot.graph_id,
|
||||
"domains": snapshot.domains.len(),
|
||||
"nodes": snapshot.nodes.len(),
|
||||
"edges": snapshot.edges.len(),
|
||||
"summaries": snapshot.summaries.len(),
|
||||
}));
|
||||
snapshots.push(snapshot);
|
||||
}
|
||||
|
||||
if let Some(heartbeat_store) = &args.heartbeat_store {
|
||||
let cognition = load_heartbeat_cognition_entry(heartbeat_store)?.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"heartbeat store {} has no cognition entry",
|
||||
heartbeat_store.display()
|
||||
)
|
||||
})?;
|
||||
let snapshot = memory_graph_from_heartbeat_cognition("heartbeat-profile", &cognition);
|
||||
source_status.push(serde_json::json!({
|
||||
"kind": "heartbeat_store",
|
||||
"source": heartbeat_store,
|
||||
"graphId": snapshot.graph_id,
|
||||
"domains": snapshot.domains.len(),
|
||||
"nodes": snapshot.nodes.len(),
|
||||
"edges": snapshot.edges.len(),
|
||||
"summaries": snapshot.summaries.len(),
|
||||
}));
|
||||
snapshots.push(snapshot);
|
||||
}
|
||||
|
||||
let snapshot = compose_memory_graph_snapshots(args.graph_id, snapshots).map_err(|errors| {
|
||||
anyhow!(
|
||||
"refreshed memory graph is invalid: {}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| format!("{}: {}", error.path, error.message))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
)
|
||||
})?;
|
||||
write_memory_graph_snapshot(&args.store, &snapshot)?;
|
||||
Ok(serde_json::json!({
|
||||
"ok": true,
|
||||
"store": args.store,
|
||||
"graphId": snapshot.graph_id,
|
||||
"sources": source_status,
|
||||
"domains": snapshot.domains.len(),
|
||||
"nodes": snapshot.nodes.len(),
|
||||
"edges": snapshot.edges.len(),
|
||||
"summaries": snapshot.summaries.len(),
|
||||
"freshness": snapshot.freshness,
|
||||
}))
|
||||
}
|
||||
|
||||
fn snapshot_status(
|
||||
kind: &str,
|
||||
source: &PathBuf,
|
||||
snapshot: &EpiphanyMemoryGraphSnapshot,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"kind": kind,
|
||||
"source": source,
|
||||
"graphId": snapshot.graph_id,
|
||||
"domains": snapshot.domains.len(),
|
||||
"nodes": snapshot.nodes.len(),
|
||||
"edges": snapshot.edges.len(),
|
||||
"summaries": snapshot.summaries.len(),
|
||||
})
|
||||
}
|
||||
|
||||
fn require_path_arg(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(require_string_arg(args, name)?))
|
||||
}
|
||||
|
||||
fn require_string_arg(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
let Some(flag) = args.next() else {
|
||||
return Err(anyhow!("missing {name}"));
|
||||
};
|
||||
if flag != name {
|
||||
return Err(anyhow!("expected {name}, got {flag}"));
|
||||
}
|
||||
args.next()
|
||||
.with_context(|| format!("missing value for {name}"))
|
||||
}
|
||||
|
||||
fn optional_path_arg(
|
||||
args: &mut impl Iterator<Item = String>,
|
||||
name: &str,
|
||||
) -> Result<Option<PathBuf>> {
|
||||
let values = args.collect::<Vec<_>>();
|
||||
if values.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if values.len() != 2 || values[0] != name {
|
||||
return Err(anyhow!("expected optional {name} <path>, got {values:?}"));
|
||||
}
|
||||
Ok(Some(PathBuf::from(&values[1])))
|
||||
}
|
||||
|
||||
fn next_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
args.next()
|
||||
.with_context(|| format!("missing value for {name}"))
|
||||
}
|
||||
|
||||
fn parse_profile(value: &str) -> Result<EpiphanyMemoryProfile> {
|
||||
match value {
|
||||
"repo_architecture" => Ok(EpiphanyMemoryProfile::RepoArchitecture),
|
||||
"repo_dataflow" => Ok(EpiphanyMemoryProfile::RepoDataflow),
|
||||
"role_self" => Ok(EpiphanyMemoryProfile::RoleSelf),
|
||||
"short_term" => Ok(EpiphanyMemoryProfile::ShortTerm),
|
||||
"incubation" => Ok(EpiphanyMemoryProfile::Incubation),
|
||||
"agency_pressure" => Ok(EpiphanyMemoryProfile::AgencyPressure),
|
||||
"candidate_intervention" => Ok(EpiphanyMemoryProfile::CandidateIntervention),
|
||||
"identity" => Ok(EpiphanyMemoryProfile::Identity),
|
||||
"evidence" => Ok(EpiphanyMemoryProfile::Evidence),
|
||||
_ => Err(anyhow!("unknown memory graph profile {value:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_json<T: serde::Serialize>(value: &T) -> Result<()> {
|
||||
println!("{}", serde_json::to_string_pretty(value)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: epiphany-memory-graph <status|validate|context|compose|refresh|smoke> --store <path> ..."
|
||||
);
|
||||
}
|
||||
|
||||
fn scoped_temp_store(prefix: &str) -> PathBuf {
|
||||
env::temp_dir().join(format!("{prefix}-{}.cc", uuid::Uuid::new_v4()))
|
||||
}
|
||||
|
||||
fn run_smoke(store: PathBuf) -> Result<serde_json::Value> {
|
||||
let snapshot = smoke_snapshot();
|
||||
write_memory_graph_snapshot(&store, &snapshot)?;
|
||||
let status = memory_graph_status(&store)?;
|
||||
let packet = plan_memory_graph_context_cut(
|
||||
&snapshot,
|
||||
&EpiphanyMemoryContextQuery {
|
||||
id: "smoke-query".to_string(),
|
||||
profile: Some(EpiphanyMemoryProfile::RepoArchitecture),
|
||||
text: Some("shared graph law".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"ok": status["valid"].as_bool().unwrap_or(false)
|
||||
&& packet.summaries.len() == 1
|
||||
&& packet.nodes.is_empty(),
|
||||
"store": store,
|
||||
"status": status,
|
||||
"contextPacket": packet,
|
||||
}))
|
||||
}
|
||||
|
||||
fn smoke_snapshot() -> EpiphanyMemoryGraphSnapshot {
|
||||
let domain_id = memory_graph_domain_id(
|
||||
EpiphanyMemoryProfile::RepoArchitecture,
|
||||
"crate",
|
||||
"epiphany-core",
|
||||
);
|
||||
let node_id = memory_graph_node_id(
|
||||
&domain_id,
|
||||
"module",
|
||||
"epiphany-core/src/memory_graph.rs",
|
||||
Some("memory_graph"),
|
||||
);
|
||||
EpiphanyMemoryGraphSnapshot {
|
||||
graph_id: "memory-graph-smoke".to_string(),
|
||||
domains: vec![EpiphanyMemoryDomain {
|
||||
id: domain_id.clone(),
|
||||
profile: EpiphanyMemoryProfile::RepoArchitecture,
|
||||
title: "epiphany-core".to_string(),
|
||||
lifecycle: EpiphanyMemoryLifecycle::Accepted,
|
||||
..Default::default()
|
||||
}],
|
||||
nodes: vec![EpiphanyMemoryNode {
|
||||
id: node_id.clone(),
|
||||
domain_id: domain_id.clone(),
|
||||
profile: EpiphanyMemoryProfile::RepoArchitecture,
|
||||
kind: EpiphanyMemoryNodeKind::Module,
|
||||
title: "memory_graph".to_string(),
|
||||
claim: "Shared memory graph law owns repo and agent memory graph invariants."
|
||||
.to_string(),
|
||||
question: "Which profile-specific producers are allowed after the shared store?"
|
||||
.to_string(),
|
||||
tension: String::new(),
|
||||
action_implication: "Add profile producers only after the shared typed store works."
|
||||
.to_string(),
|
||||
anchors: vec![EpiphanyMemoryAnchor {
|
||||
id: "anchor-memory-graph-rs".to_string(),
|
||||
kind: "source".to_string(),
|
||||
target: "epiphany-core/src/memory_graph.rs".to_string(),
|
||||
source_hash: Some("smoke-hash".to_string()),
|
||||
..Default::default()
|
||||
}],
|
||||
source_hashes: vec!["smoke-hash".to_string()],
|
||||
lifecycle: EpiphanyMemoryLifecycle::Accepted,
|
||||
confidence: 90,
|
||||
salience: 80,
|
||||
..Default::default()
|
||||
}],
|
||||
edges: vec![EpiphanyMemoryEdge {
|
||||
id: memory_graph_edge_id(&node_id, &node_id, "verifies", ["smoke"]),
|
||||
source_id: node_id.clone(),
|
||||
target_id: node_id.clone(),
|
||||
kind: EpiphanyMemoryEdgeKind::Verifies,
|
||||
profile: EpiphanyMemoryProfile::RepoArchitecture,
|
||||
claim: "The smoke packet verifies the memory graph context cut can use summaries."
|
||||
.to_string(),
|
||||
lifecycle: EpiphanyMemoryLifecycle::Accepted,
|
||||
confidence: 80,
|
||||
..Default::default()
|
||||
}],
|
||||
summaries: vec![EpiphanyMemorySummary {
|
||||
id: "summary-memory-graph-smoke".to_string(),
|
||||
domain_id,
|
||||
covers_node_ids: vec![node_id],
|
||||
target: "memory_graph".to_string(),
|
||||
claim: "The shared graph skeleton can persist and return context without Qdrant."
|
||||
.to_string(),
|
||||
question: "Which producer should populate it first?".to_string(),
|
||||
tension: String::new(),
|
||||
action_implication: "Use this store before scanner or sleep-runner work.".to_string(),
|
||||
anchor_count: 1,
|
||||
source_hashes: vec!["smoke-hash".to_string()],
|
||||
freshness: EpiphanyMemoryFreshnessStatus::Ready,
|
||||
confidence: 95,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
444
epiphany-core/src/bin/epiphany-mvp-coordinator-smoke.rs
Normal file
444
epiphany-core/src/bin/epiphany-mvp-coordinator-smoke.rs
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
const DEFAULT_APP_SERVER: &str = r"C:\Users\Meta\.cargo-target-codex\debug\codex-app-server.exe";
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
let result = run_smoke(&args)?;
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
app_server: PathBuf,
|
||||
artifact_root: PathBuf,
|
||||
coordinator_exe: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let mut parsed = Self {
|
||||
app_server: PathBuf::from(DEFAULT_APP_SERVER),
|
||||
artifact_root: root.join(".epiphany-dogfood").join("coordinator-smoke"),
|
||||
coordinator_exe: None,
|
||||
};
|
||||
let mut args = env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--app-server" => parsed.app_server = take_path(&mut args, "--app-server")?,
|
||||
"--artifact-root" => {
|
||||
parsed.artifact_root = take_path(&mut args, "--artifact-root")?;
|
||||
}
|
||||
"--coordinator-exe" => {
|
||||
parsed.coordinator_exe = Some(take_path(&mut args, "--coordinator-exe")?);
|
||||
}
|
||||
other => return Err(anyhow!("unknown argument: {other}")),
|
||||
}
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_smoke(args: &Args) -> Result<Value> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let app_server = absolute_path(&args.app_server)?;
|
||||
ensure_default_app_server_built(&root, &app_server)?;
|
||||
if !app_server.exists() {
|
||||
return Err(anyhow!(
|
||||
"codex app-server binary not found: {}",
|
||||
app_server.display()
|
||||
));
|
||||
}
|
||||
let artifact_root = absolute_path(&args.artifact_root)?;
|
||||
reset_artifact_root(&root, &artifact_root)?;
|
||||
let coordinator = match &args.coordinator_exe {
|
||||
Some(path) => absolute_path(path)?,
|
||||
None => sibling_exe("epiphany-mvp-coordinator")?,
|
||||
};
|
||||
ensure_coordinator_built(&root, &coordinator)?;
|
||||
|
||||
let cold = run_coordinator(
|
||||
&root,
|
||||
&coordinator,
|
||||
&app_server,
|
||||
&artifact_root,
|
||||
RunOptions {
|
||||
name: "cold",
|
||||
mode: "plan",
|
||||
bootstrap_smoke_state: false,
|
||||
bootstrap_local_state: false,
|
||||
simulate_high_pressure: false,
|
||||
dry_compact: false,
|
||||
max_steps: 1,
|
||||
},
|
||||
)?;
|
||||
require(
|
||||
cold.pointer("/finalAction/action").and_then(Value::as_str) == Some("prepareCheckpoint"),
|
||||
"cold start should stop at prepareCheckpoint",
|
||||
)?;
|
||||
require_artifacts(&cold)?;
|
||||
require_coordination_guards(&cold)?;
|
||||
require_operator_safe(&cold, "$")?;
|
||||
|
||||
let local = run_coordinator(
|
||||
&root,
|
||||
&coordinator,
|
||||
&app_server,
|
||||
&artifact_root,
|
||||
RunOptions {
|
||||
name: "local-bootstrap",
|
||||
mode: "plan",
|
||||
bootstrap_smoke_state: false,
|
||||
bootstrap_local_state: true,
|
||||
simulate_high_pressure: false,
|
||||
dry_compact: false,
|
||||
max_steps: 1,
|
||||
},
|
||||
)?;
|
||||
require(
|
||||
local.pointer("/finalAction/action").and_then(Value::as_str) != Some("prepareCheckpoint"),
|
||||
"local bootstrap should provide enough checkpoint state to move past prepareCheckpoint",
|
||||
)?;
|
||||
require_artifacts(&local)?;
|
||||
require_coordination_guards(&local)?;
|
||||
require_operator_safe(&local, "$")?;
|
||||
|
||||
let pressure = run_coordinator(
|
||||
&root,
|
||||
&coordinator,
|
||||
&app_server,
|
||||
&artifact_root,
|
||||
RunOptions {
|
||||
name: "pressure",
|
||||
mode: "run",
|
||||
bootstrap_smoke_state: true,
|
||||
bootstrap_local_state: false,
|
||||
simulate_high_pressure: true,
|
||||
dry_compact: true,
|
||||
max_steps: 1,
|
||||
},
|
||||
)?;
|
||||
require(
|
||||
pressure.pointer("/steps/0/action").and_then(Value::as_str)
|
||||
== Some("compactRehydrateReorient"),
|
||||
"simulated high pressure should select compact/rehydrate/reorient",
|
||||
)?;
|
||||
require(
|
||||
pressure
|
||||
.pointer("/steps/0/events/0/type")
|
||||
.and_then(Value::as_str)
|
||||
== Some("dryCompact"),
|
||||
"dry compact smoke should record the compaction action",
|
||||
)?;
|
||||
require_artifacts(&pressure)?;
|
||||
require_coordination_guards(&pressure)?;
|
||||
require_operator_safe(&pressure, "$")?;
|
||||
|
||||
let rejected = Command::new(&coordinator)
|
||||
.current_dir(&root)
|
||||
.arg("--artifact-dir")
|
||||
.arg(artifact_root.join("rejected-private-store-completion"))
|
||||
.arg("--test-complete-backend")
|
||||
.output()
|
||||
.context("failed to run rejected completion check")?;
|
||||
let rejected_stderr = String::from_utf8_lossy(&rejected.stderr);
|
||||
require(
|
||||
!rejected.status.success() && rejected_stderr.contains("CultNet job-result API"),
|
||||
"native coordinator should reject direct backend-completion mutation",
|
||||
)?;
|
||||
|
||||
let result = json!({
|
||||
"artifactRoot": artifact_root,
|
||||
"coldAction": cold["finalAction"]["action"],
|
||||
"localBootstrapAction": local["finalAction"]["action"],
|
||||
"pressureAction": pressure["steps"][0]["action"],
|
||||
"directBackendCompletionRejected": true,
|
||||
"note": "Native smoke does not fake specialist completion by mutating Codex state storage; full completion smoke needs live workers while execution is cauterized into CultNet.",
|
||||
});
|
||||
write_json(
|
||||
&artifact_root.join("coordinator-smoke-summary.json"),
|
||||
&result,
|
||||
)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
struct RunOptions {
|
||||
name: &'static str,
|
||||
mode: &'static str,
|
||||
bootstrap_smoke_state: bool,
|
||||
bootstrap_local_state: bool,
|
||||
simulate_high_pressure: bool,
|
||||
dry_compact: bool,
|
||||
max_steps: usize,
|
||||
}
|
||||
|
||||
fn run_coordinator(
|
||||
root: &Path,
|
||||
coordinator: &Path,
|
||||
app_server: &Path,
|
||||
artifact_root: &Path,
|
||||
options: RunOptions,
|
||||
) -> Result<Value> {
|
||||
let artifact_dir = artifact_root.join(options.name);
|
||||
let mut command = Command::new(coordinator);
|
||||
command
|
||||
.current_dir(root)
|
||||
.arg("--app-server")
|
||||
.arg(app_server)
|
||||
.arg("--artifact-dir")
|
||||
.arg(&artifact_dir)
|
||||
.arg("--runtime-store")
|
||||
.arg(artifact_dir.join("runtime-spine.cc"))
|
||||
.arg("--codex-home")
|
||||
.arg(artifact_dir.join("codex-home"))
|
||||
.arg("--cwd")
|
||||
.arg(root)
|
||||
.arg("--mode")
|
||||
.arg(options.mode)
|
||||
.arg("--max-steps")
|
||||
.arg(options.max_steps.to_string())
|
||||
.arg("--poll-seconds")
|
||||
.arg("0.1")
|
||||
.arg("--timeout-seconds")
|
||||
.arg("3");
|
||||
if options.bootstrap_smoke_state {
|
||||
command.arg("--bootstrap-smoke-state");
|
||||
}
|
||||
if options.bootstrap_local_state {
|
||||
command
|
||||
.arg("--bootstrap-local-state")
|
||||
.arg("--bootstrap-objective")
|
||||
.arg("Smoke the local MVP bootstrap checkpoint.");
|
||||
}
|
||||
if options.simulate_high_pressure {
|
||||
command.arg("--simulate-high-pressure");
|
||||
}
|
||||
if options.dry_compact {
|
||||
command.arg("--dry-compact");
|
||||
}
|
||||
let output = command
|
||||
.output()
|
||||
.context("failed to run native coordinator")?;
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"native coordinator failed: {}{}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
));
|
||||
}
|
||||
serde_json::from_slice(&output.stdout).context("failed to decode coordinator summary")
|
||||
}
|
||||
|
||||
fn require_artifacts(summary: &Value) -> Result<()> {
|
||||
let artifact_dir = PathBuf::from(
|
||||
summary
|
||||
.get("artifactDir")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("summary missing artifactDir"))?,
|
||||
);
|
||||
for name in [
|
||||
"coordinator-summary.json",
|
||||
"coordinator-steps.jsonl",
|
||||
"coordinator-final-status.json",
|
||||
"coordinator-final-status.txt",
|
||||
"coordinator-final-action.txt",
|
||||
"epiphany-transcript.jsonl",
|
||||
"epiphany-server.stderr.log",
|
||||
"agent-function-telemetry.json",
|
||||
"runtime-spine-status.json",
|
||||
] {
|
||||
require(
|
||||
artifact_dir.join(name).exists(),
|
||||
&format!("missing coordinator artifact {name}"),
|
||||
)?;
|
||||
}
|
||||
require(
|
||||
artifact_dir.join("runtime-spine.cc").exists(),
|
||||
"missing native runtime spine store",
|
||||
)?;
|
||||
let runtime_status: Value = serde_json::from_str(&fs::read_to_string(
|
||||
artifact_dir.join("runtime-spine-status.json"),
|
||||
)?)?;
|
||||
require(
|
||||
runtime_status["present"].as_bool() == Some(true),
|
||||
"native runtime spine should be present",
|
||||
)?;
|
||||
require(
|
||||
runtime_status["sessions"].as_u64().unwrap_or(0) >= 1,
|
||||
"native runtime spine should record a session",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_coordination_guards(summary: &Value) -> Result<()> {
|
||||
let guards = summary
|
||||
.get("coordinationGuards")
|
||||
.ok_or_else(|| anyhow!("summary missing coordinationGuards"))?;
|
||||
require(
|
||||
guards
|
||||
.get("implementationCommitRequiresModelingRefresh")
|
||||
.and_then(Value::as_bool)
|
||||
== Some(false),
|
||||
"fresh coordinator smoke should expose clear Hands/Proprioception refresh debt",
|
||||
)?;
|
||||
require(
|
||||
guards
|
||||
.get("implementationLaunchSuppressedForModelingRefresh")
|
||||
.and_then(Value::as_bool)
|
||||
== Some(false),
|
||||
"fresh coordinator smoke should expose implementation launch suppression state",
|
||||
)?;
|
||||
require(
|
||||
guards
|
||||
.get("note")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|note| note.contains("Hands/Proprioception refresh debt")),
|
||||
"coordination guard note should name the Hands/Proprioception debt",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_operator_safe(value: &Value, path: &str) -> Result<()> {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
if let Some(raw_result) = map.get("rawResult") {
|
||||
require(
|
||||
raw_result.get("sealed").and_then(Value::as_bool) == Some(true),
|
||||
&format!("{path}.rawResult should be sealed in operator-facing artifacts"),
|
||||
)?;
|
||||
}
|
||||
for (key, item) in map {
|
||||
require_operator_safe(item, &format!("{path}.{key}"))?;
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
require_operator_safe(item, &format!("{path}[{index}]"))?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reset_artifact_root(root: &Path, path: &Path) -> Result<()> {
|
||||
let dogfood_root = root.join(".epiphany-dogfood").canonicalize().or_else(|_| {
|
||||
let dogfood_root = root.join(".epiphany-dogfood");
|
||||
fs::create_dir_all(&dogfood_root)?;
|
||||
dogfood_root.canonicalize()
|
||||
})?;
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
fs::create_dir_all(parent)?;
|
||||
let resolved = if path.exists() {
|
||||
path.canonicalize()?
|
||||
} else {
|
||||
parent.canonicalize()?.join(path.file_name().unwrap())
|
||||
};
|
||||
if resolved == dogfood_root || !resolved.starts_with(&dogfood_root) {
|
||||
return Err(anyhow!(
|
||||
"refusing to delete non-dogfood artifact root: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if path.exists() {
|
||||
fs::remove_dir_all(path)?;
|
||||
}
|
||||
fs::create_dir_all(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sibling_exe(name: &str) -> Result<PathBuf> {
|
||||
let mut exe = env::current_exe().context("failed to resolve current executable")?;
|
||||
exe.set_file_name(format!("{name}{}", env::consts::EXE_SUFFIX));
|
||||
Ok(exe)
|
||||
}
|
||||
|
||||
fn ensure_coordinator_built(root: &Path, coordinator: &Path) -> Result<()> {
|
||||
let status = Command::new("cargo")
|
||||
.current_dir(root)
|
||||
.arg("build")
|
||||
.arg("--manifest-path")
|
||||
.arg(root.join("epiphany-core").join("Cargo.toml"))
|
||||
.arg("--bin")
|
||||
.arg("epiphany-mvp-coordinator")
|
||||
.status()
|
||||
.context("failed to build epiphany-mvp-coordinator")?;
|
||||
require(
|
||||
status.success() && coordinator.exists(),
|
||||
&format!(
|
||||
"native coordinator binary was not built: {}",
|
||||
coordinator.display()
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_default_app_server_built(root: &Path, app_server: &Path) -> Result<()> {
|
||||
let default_app_server = PathBuf::from(DEFAULT_APP_SERVER);
|
||||
if app_server != default_app_server {
|
||||
return Ok(());
|
||||
}
|
||||
let status = Command::new("cargo")
|
||||
.current_dir(root)
|
||||
.env("CARGO_TARGET_DIR", r"C:\Users\Meta\.cargo-target-codex")
|
||||
.arg("build")
|
||||
.arg("--manifest-path")
|
||||
.arg(
|
||||
root.join("vendor")
|
||||
.join("codex")
|
||||
.join("codex-rs")
|
||||
.join("Cargo.toml"),
|
||||
)
|
||||
.arg("-p")
|
||||
.arg("codex-app-server")
|
||||
.arg("--bin")
|
||||
.arg("codex-app-server")
|
||||
.status()
|
||||
.context("failed to build default codex-app-server")?;
|
||||
require(
|
||||
status.success() && app_server.exists(),
|
||||
&format!(
|
||||
"default codex-app-server binary was not built: {}",
|
||||
app_server.display()
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn absolute_path(path: &Path) -> Result<PathBuf> {
|
||||
if path.is_absolute() {
|
||||
Ok(path.to_path_buf())
|
||||
} else {
|
||||
Ok(env::current_dir()?.join(path))
|
||||
}
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require(condition: bool, message: &str) -> Result<()> {
|
||||
if condition {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("{message}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn take_path(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
args.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
425
epiphany-core/src/bin/epiphany-mvp-status-smoke.rs
Normal file
425
epiphany-core/src/bin/epiphany-mvp-status-smoke.rs
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
const DEFAULT_APP_SERVER: &str = r"C:\Users\Meta\.cargo-target-codex\debug\codex-app-server.exe";
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
let result = run_smoke(&args)?;
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
app_server: PathBuf,
|
||||
codex_home: PathBuf,
|
||||
result: PathBuf,
|
||||
transcript: PathBuf,
|
||||
stderr: PathBuf,
|
||||
rendered: PathBuf,
|
||||
status_exe: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let mut parsed = Self {
|
||||
app_server: PathBuf::from(DEFAULT_APP_SERVER),
|
||||
codex_home: root.join(".epiphany-smoke").join("mvp-status-codex-home"),
|
||||
result: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("mvp-status-smoke-result.json"),
|
||||
transcript: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("mvp-status-smoke-transcript.jsonl"),
|
||||
stderr: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("mvp-status-smoke-server.stderr.log"),
|
||||
rendered: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("mvp-status-smoke-rendered.txt"),
|
||||
status_exe: None,
|
||||
};
|
||||
let mut args = env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--app-server" => parsed.app_server = take_path(&mut args, "--app-server")?,
|
||||
"--codex-home" => parsed.codex_home = take_path(&mut args, "--codex-home")?,
|
||||
"--result" => parsed.result = take_path(&mut args, "--result")?,
|
||||
"--transcript" => parsed.transcript = take_path(&mut args, "--transcript")?,
|
||||
"--stderr" => parsed.stderr = take_path(&mut args, "--stderr")?,
|
||||
"--rendered" => parsed.rendered = take_path(&mut args, "--rendered")?,
|
||||
"--status-exe" => parsed.status_exe = Some(take_path(&mut args, "--status-exe")?),
|
||||
other => return Err(anyhow!("unknown argument: {other}")),
|
||||
}
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_smoke(args: &Args) -> Result<Value> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let app_server = absolute_path(&args.app_server)?;
|
||||
if !app_server.exists() {
|
||||
return Err(anyhow!(
|
||||
"codex app-server binary not found: {}",
|
||||
app_server.display()
|
||||
));
|
||||
}
|
||||
let codex_home = absolute_path(&args.codex_home)?;
|
||||
let result_path = absolute_path(&args.result)?;
|
||||
let transcript_path = absolute_path(&args.transcript)?;
|
||||
let stderr_path = absolute_path(&args.stderr)?;
|
||||
let rendered_path = absolute_path(&args.rendered)?;
|
||||
reset_smoke_paths(
|
||||
&root,
|
||||
&[
|
||||
codex_home.clone(),
|
||||
result_path.clone(),
|
||||
transcript_path.clone(),
|
||||
stderr_path.clone(),
|
||||
rendered_path.clone(),
|
||||
],
|
||||
)?;
|
||||
let status_exe = match &args.status_exe {
|
||||
Some(path) => absolute_path(path)?,
|
||||
None => sibling_exe("epiphany-mvp-status")?,
|
||||
};
|
||||
ensure_status_built(&root, &status_exe)?;
|
||||
|
||||
let status = run_status_json(
|
||||
&root,
|
||||
&status_exe,
|
||||
&app_server,
|
||||
&codex_home,
|
||||
&result_path,
|
||||
&transcript_path,
|
||||
&stderr_path,
|
||||
)?;
|
||||
let rendered = run_status_rendered(
|
||||
&root,
|
||||
&status_exe,
|
||||
&app_server,
|
||||
&codex_home,
|
||||
&transcript_path.with_file_name("mvp-status-smoke-render-transcript.jsonl"),
|
||||
&stderr_path.with_file_name("mvp-status-smoke-render-server.stderr.log"),
|
||||
)?;
|
||||
if let Some(parent) = rendered_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(&rendered_path, &rendered)?;
|
||||
|
||||
validate_status(&status, &rendered)?;
|
||||
let result = json!({
|
||||
"threadId": status["threadId"],
|
||||
"recommendation": status["crrc"]["recommendation"],
|
||||
"roles": status["roles"],
|
||||
"roleResults": status["roleResults"],
|
||||
"planning": status["planning"],
|
||||
"heartbeat": status["heartbeat"],
|
||||
"persona": status["persona"],
|
||||
"stateStatus": status["scene"]["scene"]["stateStatus"],
|
||||
"availableActions": status["scene"]["scene"]["availableActions"],
|
||||
"rendered": rendered,
|
||||
});
|
||||
write_json(&result_path, &result)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn run_status_json(
|
||||
root: &Path,
|
||||
status_exe: &Path,
|
||||
app_server: &Path,
|
||||
codex_home: &Path,
|
||||
result_path: &Path,
|
||||
transcript_path: &Path,
|
||||
stderr_path: &Path,
|
||||
) -> Result<Value> {
|
||||
let output = Command::new(status_exe)
|
||||
.current_dir(root)
|
||||
.arg("--app-server")
|
||||
.arg(app_server)
|
||||
.arg("--codex-home")
|
||||
.arg(codex_home)
|
||||
.arg("--cwd")
|
||||
.arg(root)
|
||||
.arg("--transcript")
|
||||
.arg(transcript_path)
|
||||
.arg("--stderr")
|
||||
.arg(stderr_path)
|
||||
.arg("--result")
|
||||
.arg(result_path)
|
||||
.arg("--json")
|
||||
.output()
|
||||
.context("failed to run native status JSON command")?;
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"native status JSON command failed: {}{}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
));
|
||||
}
|
||||
serde_json::from_slice(&output.stdout).context("failed to decode native status JSON")
|
||||
}
|
||||
|
||||
fn run_status_rendered(
|
||||
root: &Path,
|
||||
status_exe: &Path,
|
||||
app_server: &Path,
|
||||
codex_home: &Path,
|
||||
transcript_path: &Path,
|
||||
stderr_path: &Path,
|
||||
) -> Result<String> {
|
||||
let output = Command::new(status_exe)
|
||||
.current_dir(root)
|
||||
.arg("--app-server")
|
||||
.arg(app_server)
|
||||
.arg("--codex-home")
|
||||
.arg(codex_home)
|
||||
.arg("--cwd")
|
||||
.arg(root)
|
||||
.arg("--transcript")
|
||||
.arg(transcript_path)
|
||||
.arg("--stderr")
|
||||
.arg(stderr_path)
|
||||
.output()
|
||||
.context("failed to run native status rendered command")?;
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"native status rendered command failed: {}{}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
));
|
||||
}
|
||||
String::from_utf8(output.stdout).context("rendered status was not UTF-8")
|
||||
}
|
||||
|
||||
fn validate_status(status: &Value, rendered: &str) -> Result<()> {
|
||||
require(
|
||||
status
|
||||
.pointer("/scene/scene/stateStatus")
|
||||
.and_then(Value::as_str)
|
||||
== Some("missing"),
|
||||
"fresh status smoke should honestly report missing Epiphany state",
|
||||
)?;
|
||||
require(
|
||||
status
|
||||
.pointer("/crrc/recommendation/action")
|
||||
.and_then(Value::as_str)
|
||||
== Some("regatherManually"),
|
||||
"fresh status smoke should recommend manual regather without state",
|
||||
)?;
|
||||
require(
|
||||
array_contains(status.pointer("/scene/scene/availableActions"), "crrc"),
|
||||
"status view should expose the CRRC action in the scene",
|
||||
)?;
|
||||
require(
|
||||
array_contains(status.pointer("/scene/scene/availableActions"), "roles"),
|
||||
"status view should expose the role ownership action in the scene",
|
||||
)?;
|
||||
require(
|
||||
rendered.contains("Epiphany MVP Status") && rendered.contains("Recommendation"),
|
||||
"rendered status should include the operator view headings",
|
||||
)?;
|
||||
require(
|
||||
rendered.contains("regatherManually"),
|
||||
"rendered status should expose the CRRC recommendation",
|
||||
)?;
|
||||
require(
|
||||
status
|
||||
.pointer("/coordinator/sourceSignals/implementationCommitRequiresModelingRefresh")
|
||||
.and_then(Value::as_bool)
|
||||
== Some(false)
|
||||
&& rendered.contains("Hands/Proprioception refresh debt: clear"),
|
||||
"status artifacts should expose Hands/Proprioception refresh debt",
|
||||
)?;
|
||||
let lane_ids = status
|
||||
.pointer("/roles/roles")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| anyhow!("roles surface missing roles"))?
|
||||
.iter()
|
||||
.filter_map(|lane| lane.get("id").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>();
|
||||
require(
|
||||
lane_ids
|
||||
== [
|
||||
"implementation",
|
||||
"imagination",
|
||||
"research",
|
||||
"modeling",
|
||||
"verification",
|
||||
"reorientation",
|
||||
],
|
||||
"roles surface should expose the six MVP role lanes",
|
||||
)?;
|
||||
require(
|
||||
status
|
||||
.pointer("/roles/note")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|note| note.starts_with("Role ownership is derived read-only")),
|
||||
"roles surface should declare read-only derived ownership",
|
||||
)?;
|
||||
require(
|
||||
status
|
||||
.pointer("/planning/stateStatus")
|
||||
.and_then(Value::as_str)
|
||||
== Some("missing")
|
||||
&& status
|
||||
.pointer("/planning/summary/captureCount")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(0),
|
||||
"fresh status smoke should expose empty planning state honestly",
|
||||
)?;
|
||||
require(
|
||||
rendered.contains("Planning") && rendered.contains("captures: 0"),
|
||||
"rendered status should expose the planning section",
|
||||
)?;
|
||||
require(
|
||||
rendered.contains("Role Lanes") && rendered.contains("Verification / Review"),
|
||||
"rendered status should expose the role lane section",
|
||||
)?;
|
||||
require(
|
||||
rendered.contains("Role Findings")
|
||||
&& status
|
||||
.pointer("/roleResults/imagination/status")
|
||||
.and_then(Value::as_str)
|
||||
== Some("missingState")
|
||||
&& status
|
||||
.pointer("/roleResults/research/status")
|
||||
.and_then(Value::as_str)
|
||||
== Some("missingState")
|
||||
&& status
|
||||
.pointer("/roleResults/modeling/status")
|
||||
.and_then(Value::as_str)
|
||||
== Some("missingState")
|
||||
&& status
|
||||
.pointer("/roleResults/verification/status")
|
||||
.and_then(Value::as_str)
|
||||
== Some("missingState"),
|
||||
"rendered status should expose fixed role result read-back status",
|
||||
)?;
|
||||
require(
|
||||
status
|
||||
.pointer("/heartbeat/schema_version")
|
||||
.and_then(Value::as_str)
|
||||
== Some("epiphany.agent_heartbeat_status.v0"),
|
||||
"status view should expose heartbeat initiative status for Aquarium",
|
||||
)?;
|
||||
let persona_actions = status
|
||||
.pointer("/persona/availableActions")
|
||||
.and_then(Value::as_array);
|
||||
require(
|
||||
persona_actions.is_some_and(|items| {
|
||||
items
|
||||
.iter()
|
||||
.any(|item| item.as_str() == Some("personaBubble"))
|
||||
&& items
|
||||
.iter()
|
||||
.any(|item| item.as_str() == Some("characterTurn"))
|
||||
}),
|
||||
"status view should expose Persona bubble and character-turn actions for Aquarium",
|
||||
)?;
|
||||
require(
|
||||
rendered.contains("Heartbeat") && rendered.contains("Persona"),
|
||||
"rendered status should expose heartbeat and Persona sections",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reset_smoke_paths(root: &Path, paths: &[PathBuf]) -> Result<()> {
|
||||
let smoke_root = root.join(".epiphany-smoke").canonicalize().or_else(|_| {
|
||||
let smoke_root = root.join(".epiphany-smoke");
|
||||
fs::create_dir_all(&smoke_root)?;
|
||||
smoke_root.canonicalize()
|
||||
})?;
|
||||
for path in paths {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let resolved = path.canonicalize()?;
|
||||
if resolved == smoke_root || !resolved.starts_with(&smoke_root) {
|
||||
return Err(anyhow!(
|
||||
"refusing to delete non-smoke path: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(path)?;
|
||||
} else {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_status_built(root: &Path, status_exe: &Path) -> Result<()> {
|
||||
if status_exe.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let status = Command::new("cargo")
|
||||
.current_dir(root)
|
||||
.arg("build")
|
||||
.arg("--manifest-path")
|
||||
.arg(root.join("epiphany-core").join("Cargo.toml"))
|
||||
.arg("--bin")
|
||||
.arg("epiphany-mvp-status")
|
||||
.status()
|
||||
.context("failed to build epiphany-mvp-status")?;
|
||||
require(
|
||||
status.success() && status_exe.exists(),
|
||||
&format!(
|
||||
"native status binary was not built: {}",
|
||||
status_exe.display()
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn sibling_exe(name: &str) -> Result<PathBuf> {
|
||||
let mut exe = env::current_exe().context("failed to resolve current executable")?;
|
||||
exe.set_file_name(format!("{name}{}", env::consts::EXE_SUFFIX));
|
||||
Ok(exe)
|
||||
}
|
||||
|
||||
fn array_contains(value: Option<&Value>, needle: &str) -> bool {
|
||||
value
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|items| items.iter().any(|item| item.as_str() == Some(needle)))
|
||||
}
|
||||
|
||||
fn absolute_path(path: &Path) -> Result<PathBuf> {
|
||||
if path.is_absolute() {
|
||||
Ok(path.to_path_buf())
|
||||
} else {
|
||||
Ok(env::current_dir()?.join(path))
|
||||
}
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require(condition: bool, message: &str) -> Result<()> {
|
||||
if condition {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("{message}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn take_path(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
args.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
1690
epiphany-core/src/bin/epiphany-mvp-status.rs
Normal file
1690
epiphany-core/src/bin/epiphany-mvp-status.rs
Normal file
File diff suppressed because it is too large
Load diff
258
epiphany-core/src/bin/epiphany-operator-run.rs
Normal file
258
epiphany-core/src/bin/epiphany-operator-run.rs
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_INTERNAL_VERSE_ID;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_OPERATOR_RUN_INTENT_SCHEMA_VERSION;
|
||||
use epiphany_core::EPIPHANY_CULTMESH_OPERATOR_RUN_RECEIPT_SCHEMA_VERSION;
|
||||
use epiphany_core::EpiphanyCultMeshOperatorRunIntentEntry;
|
||||
use epiphany_core::EpiphanyCultMeshOperatorRunReceiptEntry;
|
||||
use epiphany_core::load_latest_epiphany_cultmesh_operator_run_intent;
|
||||
use epiphany_core::load_latest_epiphany_cultmesh_operator_run_receipt;
|
||||
use epiphany_core::write_epiphany_cultmesh_operator_run_intent;
|
||||
use epiphany_core::write_epiphany_cultmesh_operator_run_receipt;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
match args.command.as_str() {
|
||||
"intent" => {
|
||||
let intent = EpiphanyCultMeshOperatorRunIntentEntry {
|
||||
schema_version: EPIPHANY_CULTMESH_OPERATOR_RUN_INTENT_SCHEMA_VERSION.to_string(),
|
||||
runtime_id: args.runtime_id.clone(),
|
||||
verse_id: EPIPHANY_CULTMESH_INTERNAL_VERSE_ID.to_string(),
|
||||
run_id: args.run_id.clone(),
|
||||
requested_at_utc: Utc::now().to_rfc3339(),
|
||||
mode: args.mode.clone(),
|
||||
root: args.root.clone(),
|
||||
workspace: args.workspace.clone(),
|
||||
thread_id: args.thread_id.clone(),
|
||||
codex_home: args.codex_home.clone(),
|
||||
target_dir: args.target_dir.clone(),
|
||||
max_steps: args.max_steps,
|
||||
timeout_seconds: args.timeout_seconds,
|
||||
auto_review: args.auto_review,
|
||||
no_ephemeral: args.no_ephemeral,
|
||||
artifact_root: args.artifact_root.clone(),
|
||||
dogfood_root: args.dogfood_root.clone(),
|
||||
};
|
||||
let written = write_epiphany_cultmesh_operator_run_intent(&args.store, intent)?;
|
||||
print_json(json!({"status": "written", "store": args.store, "intent": written}))?;
|
||||
}
|
||||
"receipt" => {
|
||||
let mut artifact_refs = Vec::new();
|
||||
push_non_empty(&mut artifact_refs, &args.result_path);
|
||||
push_non_empty(&mut artifact_refs, &args.artifact_root);
|
||||
push_non_empty(&mut artifact_refs, &args.dogfood_root);
|
||||
let receipt = EpiphanyCultMeshOperatorRunReceiptEntry {
|
||||
schema_version: EPIPHANY_CULTMESH_OPERATOR_RUN_RECEIPT_SCHEMA_VERSION.to_string(),
|
||||
runtime_id: args.runtime_id.clone(),
|
||||
verse_id: EPIPHANY_CULTMESH_INTERNAL_VERSE_ID.to_string(),
|
||||
run_id: args.run_id.clone(),
|
||||
completed_at_utc: Utc::now().to_rfc3339(),
|
||||
mode: args.mode.clone(),
|
||||
status: args.status.clone(),
|
||||
result_path: args.result_path.clone(),
|
||||
artifact_root: args.artifact_root.clone(),
|
||||
dogfood_root: args.dogfood_root.clone(),
|
||||
operator_snapshot_store: args.operator_snapshot_store.clone(),
|
||||
operator_snapshot_id: args.operator_snapshot_id.clone(),
|
||||
artifact_refs,
|
||||
notes: vec![
|
||||
"Receipt records local operator-run completion; referenced artifacts remain evidence, not owner state.".to_string(),
|
||||
"Codex app-server output is compatibility projection until the run path is native end to end.".to_string(),
|
||||
],
|
||||
};
|
||||
let written = write_epiphany_cultmesh_operator_run_receipt(&args.store, receipt)?;
|
||||
print_json(json!({"status": "written", "store": args.store, "receipt": written}))?;
|
||||
}
|
||||
"latest" => {
|
||||
let intent =
|
||||
load_latest_epiphany_cultmesh_operator_run_intent(&args.store, &args.runtime_id)?;
|
||||
let receipt =
|
||||
load_latest_epiphany_cultmesh_operator_run_receipt(&args.store, &args.runtime_id)?;
|
||||
print_json(json!({
|
||||
"status": if intent.is_some() || receipt.is_some() { "ready" } else { "missing" },
|
||||
"store": args.store,
|
||||
"intent": intent,
|
||||
"receipt": receipt,
|
||||
}))?;
|
||||
}
|
||||
"smoke" => {
|
||||
let intent = EpiphanyCultMeshOperatorRunIntentEntry {
|
||||
schema_version: EPIPHANY_CULTMESH_OPERATOR_RUN_INTENT_SCHEMA_VERSION.to_string(),
|
||||
runtime_id: args.runtime_id.clone(),
|
||||
verse_id: EPIPHANY_CULTMESH_INTERNAL_VERSE_ID.to_string(),
|
||||
run_id: args.run_id.clone(),
|
||||
requested_at_utc: "2026-05-27T00:00:00Z".to_string(),
|
||||
mode: "status".to_string(),
|
||||
root: "E:\\Projects\\EpiphanyAgent".to_string(),
|
||||
workspace: "E:\\Projects\\EpiphanyAgent".to_string(),
|
||||
thread_id: String::new(),
|
||||
codex_home: "C:\\Users\\Meta\\.codex".to_string(),
|
||||
target_dir: "C:\\Users\\Meta\\.cargo-target-codex".to_string(),
|
||||
max_steps: 4,
|
||||
timeout_seconds: 240,
|
||||
auto_review: false,
|
||||
no_ephemeral: false,
|
||||
artifact_root: ".epiphany-run/local-smoke".to_string(),
|
||||
dogfood_root: ".epiphany-dogfood/local-smoke".to_string(),
|
||||
};
|
||||
let receipt = EpiphanyCultMeshOperatorRunReceiptEntry {
|
||||
schema_version: EPIPHANY_CULTMESH_OPERATOR_RUN_RECEIPT_SCHEMA_VERSION.to_string(),
|
||||
runtime_id: args.runtime_id.clone(),
|
||||
verse_id: EPIPHANY_CULTMESH_INTERNAL_VERSE_ID.to_string(),
|
||||
run_id: args.run_id.clone(),
|
||||
completed_at_utc: "2026-05-27T00:00:01Z".to_string(),
|
||||
mode: "status".to_string(),
|
||||
status: "completed".to_string(),
|
||||
result_path: ".epiphany-run/local-smoke/status.json".to_string(),
|
||||
artifact_root: ".epiphany-run/local-smoke".to_string(),
|
||||
dogfood_root: ".epiphany-dogfood/local-smoke".to_string(),
|
||||
operator_snapshot_store: ".epiphany-run/cultmesh/operator-snapshots.cc".to_string(),
|
||||
operator_snapshot_id: "local-smoke-status".to_string(),
|
||||
artifact_refs: vec![".epiphany-run/local-smoke/status.json".to_string()],
|
||||
notes: vec!["smoke receipt".to_string()],
|
||||
};
|
||||
write_epiphany_cultmesh_operator_run_intent(&args.store, intent.clone())?;
|
||||
write_epiphany_cultmesh_operator_run_receipt(&args.store, receipt.clone())?;
|
||||
let latest_intent =
|
||||
load_latest_epiphany_cultmesh_operator_run_intent(&args.store, &args.runtime_id)?;
|
||||
let latest_receipt =
|
||||
load_latest_epiphany_cultmesh_operator_run_receipt(&args.store, &args.runtime_id)?;
|
||||
if latest_intent != Some(intent) || latest_receipt != Some(receipt) {
|
||||
anyhow::bail!("operator run intent/receipt did not round-trip through CultMesh");
|
||||
}
|
||||
print_json(json!({
|
||||
"status": "ok",
|
||||
"store": args.store,
|
||||
"runtimeId": args.runtime_id,
|
||||
"runId": args.run_id,
|
||||
}))?;
|
||||
}
|
||||
other => anyhow::bail!("unknown command {other:?}; use intent, receipt, latest, or smoke"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Args {
|
||||
command: String,
|
||||
store: PathBuf,
|
||||
runtime_id: String,
|
||||
run_id: String,
|
||||
mode: String,
|
||||
root: String,
|
||||
workspace: String,
|
||||
thread_id: String,
|
||||
codex_home: String,
|
||||
target_dir: String,
|
||||
max_steps: u32,
|
||||
timeout_seconds: u32,
|
||||
auto_review: bool,
|
||||
no_ephemeral: bool,
|
||||
artifact_root: String,
|
||||
dogfood_root: String,
|
||||
result_path: String,
|
||||
status: String,
|
||||
operator_snapshot_store: String,
|
||||
operator_snapshot_id: String,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let mut values = env::args().skip(1);
|
||||
let command = values.next().unwrap_or_else(|| "latest".to_string());
|
||||
let mut args = Args {
|
||||
command,
|
||||
store: PathBuf::from(".epiphany-run/cultmesh/operator-runs.cc"),
|
||||
runtime_id: "epiphany-local".to_string(),
|
||||
run_id: format!("run-{}", Utc::now().timestamp()),
|
||||
mode: "status".to_string(),
|
||||
root: String::new(),
|
||||
workspace: String::new(),
|
||||
thread_id: String::new(),
|
||||
codex_home: String::new(),
|
||||
target_dir: String::new(),
|
||||
max_steps: 4,
|
||||
timeout_seconds: 240,
|
||||
auto_review: false,
|
||||
no_ephemeral: false,
|
||||
artifact_root: String::new(),
|
||||
dogfood_root: String::new(),
|
||||
result_path: String::new(),
|
||||
status: "completed".to_string(),
|
||||
operator_snapshot_store: String::new(),
|
||||
operator_snapshot_id: String::new(),
|
||||
};
|
||||
|
||||
while let Some(arg) = values.next() {
|
||||
match arg.as_str() {
|
||||
"--store" => args.store = PathBuf::from(next(&mut values, "--store")?),
|
||||
"--runtime-id" => args.runtime_id = next(&mut values, "--runtime-id")?,
|
||||
"--run-id" => args.run_id = next(&mut values, "--run-id")?,
|
||||
"--mode" => args.mode = next(&mut values, "--mode")?,
|
||||
"--root" => args.root = next(&mut values, "--root")?,
|
||||
"--workspace" => args.workspace = next(&mut values, "--workspace")?,
|
||||
"--thread-id" => args.thread_id = next(&mut values, "--thread-id")?,
|
||||
"--codex-home" => args.codex_home = next(&mut values, "--codex-home")?,
|
||||
"--target-dir" => args.target_dir = next(&mut values, "--target-dir")?,
|
||||
"--max-steps" => args.max_steps = parse_u32(&mut values, "--max-steps")?,
|
||||
"--timeout-seconds" => {
|
||||
args.timeout_seconds = parse_u32(&mut values, "--timeout-seconds")?
|
||||
}
|
||||
"--auto-review" => args.auto_review = parse_bool(&mut values, "--auto-review")?,
|
||||
"--no-ephemeral" => args.no_ephemeral = parse_bool(&mut values, "--no-ephemeral")?,
|
||||
"--artifact-root" => args.artifact_root = next(&mut values, "--artifact-root")?,
|
||||
"--dogfood-root" => args.dogfood_root = next(&mut values, "--dogfood-root")?,
|
||||
"--result-path" => args.result_path = next(&mut values, "--result-path")?,
|
||||
"--status" => args.status = next(&mut values, "--status")?,
|
||||
"--operator-snapshot-store" => {
|
||||
args.operator_snapshot_store = next(&mut values, "--operator-snapshot-store")?
|
||||
}
|
||||
"--operator-snapshot-id" => {
|
||||
args.operator_snapshot_id = next(&mut values, "--operator-snapshot-id")?
|
||||
}
|
||||
_ => anyhow::bail!("unknown argument {arg:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent) = args.store.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
}
|
||||
|
||||
fn next(values: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
values
|
||||
.next()
|
||||
.with_context(|| format!("missing {name} value"))
|
||||
}
|
||||
|
||||
fn parse_u32(values: &mut impl Iterator<Item = String>, name: &str) -> Result<u32> {
|
||||
next(values, name)?
|
||||
.parse::<u32>()
|
||||
.with_context(|| format!("{name} must be a non-negative integer"))
|
||||
}
|
||||
|
||||
fn parse_bool(values: &mut impl Iterator<Item = String>, name: &str) -> Result<bool> {
|
||||
match next(values, name)?.as_str() {
|
||||
"true" | "1" | "yes" => Ok(true),
|
||||
"false" | "0" | "no" => Ok(false),
|
||||
_ => anyhow::bail!("{name} must be true/false"),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_non_empty(items: &mut Vec<String>, value: &str) {
|
||||
if !value.trim().is_empty() {
|
||||
items.push(value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn print_json(value: Value) -> Result<()> {
|
||||
println!("{}", serde_json::to_string_pretty(&value)?);
|
||||
Ok(())
|
||||
}
|
||||
164
epiphany-core/src/bin/epiphany-operator-snapshot.rs
Normal file
164
epiphany-core/src/bin/epiphany-operator-snapshot.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use epiphany_core::epiphany_cultmesh_operator_snapshot_from_status_json;
|
||||
use epiphany_core::load_latest_epiphany_cultmesh_operator_snapshot;
|
||||
use epiphany_core::write_epiphany_cultmesh_operator_snapshot;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
match args.command.as_str() {
|
||||
"from-status" => {
|
||||
let input = args.input.context("from-status requires --input")?;
|
||||
let source = fs::read_to_string(&input)
|
||||
.with_context(|| format!("failed to read {}", input.display()))?;
|
||||
let status_json: Value = serde_json::from_str(&source)
|
||||
.with_context(|| format!("failed to parse {}", input.display()))?;
|
||||
let snapshot = epiphany_cultmesh_operator_snapshot_from_status_json(
|
||||
args.runtime_id.clone(),
|
||||
args.snapshot_id.clone(),
|
||||
Utc::now().to_rfc3339(),
|
||||
args.source_mode.clone(),
|
||||
input.to_string_lossy(),
|
||||
&status_json,
|
||||
)?;
|
||||
let written = write_epiphany_cultmesh_operator_snapshot(&args.store, snapshot)?;
|
||||
print_json(json!({
|
||||
"status": "written",
|
||||
"store": args.store,
|
||||
"snapshot": written,
|
||||
}))?;
|
||||
}
|
||||
"latest" => {
|
||||
let latest =
|
||||
load_latest_epiphany_cultmesh_operator_snapshot(&args.store, &args.runtime_id)?;
|
||||
print_json(json!({
|
||||
"status": if latest.is_some() { "ready" } else { "missing" },
|
||||
"store": args.store,
|
||||
"snapshot": latest,
|
||||
}))?;
|
||||
}
|
||||
"smoke" => {
|
||||
let status_json = json!({
|
||||
"threadId": "thread-smoke",
|
||||
"scene": {
|
||||
"scene": {
|
||||
"stateStatus": "missing",
|
||||
"availableActions": ["crrc", "roles"]
|
||||
}
|
||||
},
|
||||
"pressure": {
|
||||
"pressure": {
|
||||
"level": "low"
|
||||
}
|
||||
},
|
||||
"reorient": {
|
||||
"decision": {
|
||||
"action": "regather",
|
||||
"nextAction": "Regather source context."
|
||||
}
|
||||
},
|
||||
"crrc": {
|
||||
"recommendation": {
|
||||
"action": "regatherManually"
|
||||
}
|
||||
},
|
||||
"coordinator": {
|
||||
"action": "wait"
|
||||
},
|
||||
"rawResult": {
|
||||
"sealed": true
|
||||
}
|
||||
});
|
||||
let snapshot = epiphany_cultmesh_operator_snapshot_from_status_json(
|
||||
args.runtime_id.clone(),
|
||||
args.snapshot_id.clone(),
|
||||
"2026-05-27T00:00:00Z",
|
||||
"status",
|
||||
".epiphany-smoke/operator-status.json",
|
||||
&status_json,
|
||||
)?;
|
||||
write_epiphany_cultmesh_operator_snapshot(&args.store, snapshot.clone())?;
|
||||
let latest =
|
||||
load_latest_epiphany_cultmesh_operator_snapshot(&args.store, &args.runtime_id)?;
|
||||
if latest != Some(snapshot) {
|
||||
anyhow::bail!("operator snapshot did not round-trip through CultMesh");
|
||||
}
|
||||
print_json(json!({
|
||||
"status": "ok",
|
||||
"store": args.store,
|
||||
"runtimeId": args.runtime_id,
|
||||
"snapshotId": args.snapshot_id,
|
||||
}))?;
|
||||
}
|
||||
other => anyhow::bail!("unknown command {other:?}; use from-status, latest, or smoke"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Args {
|
||||
command: String,
|
||||
store: PathBuf,
|
||||
runtime_id: String,
|
||||
snapshot_id: String,
|
||||
source_mode: String,
|
||||
input: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let mut values = env::args().skip(1);
|
||||
let command = values.next().unwrap_or_else(|| "latest".to_string());
|
||||
let mut store = PathBuf::from(".epiphany-run/cultmesh/operator-snapshots.cc");
|
||||
let mut runtime_id = "epiphany-local".to_string();
|
||||
let mut snapshot_id = format!("snapshot-{}", Utc::now().timestamp());
|
||||
let mut source_mode = "status".to_string();
|
||||
let mut input = None;
|
||||
|
||||
while let Some(arg) = values.next() {
|
||||
match arg.as_str() {
|
||||
"--store" => {
|
||||
store = PathBuf::from(values.next().context("missing --store value")?);
|
||||
}
|
||||
"--runtime-id" => {
|
||||
runtime_id = values.next().context("missing --runtime-id value")?;
|
||||
}
|
||||
"--snapshot-id" => {
|
||||
snapshot_id = values.next().context("missing --snapshot-id value")?;
|
||||
}
|
||||
"--source-mode" => {
|
||||
source_mode = values.next().context("missing --source-mode value")?;
|
||||
}
|
||||
"--input" => {
|
||||
input = Some(PathBuf::from(
|
||||
values.next().context("missing --input value")?,
|
||||
));
|
||||
}
|
||||
_ => anyhow::bail!("unknown argument {arg:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent) = store.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
command,
|
||||
store,
|
||||
runtime_id,
|
||||
snapshot_id,
|
||||
source_mode,
|
||||
input,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn print_json(value: Value) -> Result<()> {
|
||||
println!("{}", serde_json::to_string_pretty(&value)?);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
use std::{env, net::UdpSocket, path::PathBuf};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use epiphany_core::{
|
||||
EpiphanyPermitIdentity, PersonaPermitIssuerConfig, serve_persona_discord_permit_rudp,
|
||||
};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut values = std::collections::BTreeMap::new();
|
||||
let mut args = env::args().skip(1);
|
||||
while let Some(key) = args.next() {
|
||||
values.insert(
|
||||
key.clone(),
|
||||
args.next()
|
||||
.ok_or_else(|| anyhow!("{key} requires a value"))?,
|
||||
);
|
||||
}
|
||||
let value = |key: &str| {
|
||||
values
|
||||
.get(key)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("{key} is required"))
|
||||
};
|
||||
let runtime_id = value("--runtime-id")?;
|
||||
let identity = PathBuf::from(value("--permit-identity-store")?);
|
||||
let anchor: cultnet_rs::GameCultServiceTrustAnchorRecord =
|
||||
rmp_serde::from_slice(&std::fs::read(value("--permit-request-anchor")?)?)?;
|
||||
let provider_anchor: cultnet_rs::GameCultServiceTrustAnchorRecord =
|
||||
rmp_serde::from_slice(&std::fs::read(value("--permit-anchor")?)?)?;
|
||||
let signer = cultnet_rs::open_service_identity_at::<EpiphanyPermitIdentity>(&identity)?;
|
||||
let config = PersonaPermitIssuerConfig {
|
||||
runtime_id,
|
||||
cultmesh_store: PathBuf::from(value("--cultmesh-store")?),
|
||||
replay_store: PathBuf::from(value("--permit-replay-store")?),
|
||||
requester_anchor: anchor,
|
||||
provider_anchor,
|
||||
};
|
||||
let socket = UdpSocket::bind(value("--bind")?)?;
|
||||
serve_persona_discord_permit_rudp(socket, &config, &signer)
|
||||
}
|
||||
821
epiphany-core/src/bin/epiphany-persona-discord.rs
Normal file
821
epiphany-core/src/bin/epiphany-persona-discord.rs
Normal file
|
|
@ -0,0 +1,821 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use chrono::SecondsFormat;
|
||||
use reqwest::blocking::Client;
|
||||
use serde_json::Value;
|
||||
use std::cmp::Reverse;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
const CHAT_SCHEMA_VERSION: &str = "epiphany.persona_chat.v0";
|
||||
const BUBBLE_SCHEMA_VERSION: &str = "epiphany.persona_bubble.v0";
|
||||
const DISCORD_API: &str = "https://discord.com/api/v10";
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct PersonaConfig {
|
||||
allowed_channel_name: String,
|
||||
allowed_channel_id: Option<String>,
|
||||
allowed_channel_id_env: Option<String>,
|
||||
bot_token_env: Option<String>,
|
||||
persona_name: Option<String>,
|
||||
persona_avatar_url: Option<String>,
|
||||
webhook_cache_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = env::args().skip(1);
|
||||
let Some(command) = args.next() else {
|
||||
return usage();
|
||||
};
|
||||
let mut config_path = PathBuf::from("state/persona-discord.toml");
|
||||
let mut artifact_dir = PathBuf::from(".epiphany-persona");
|
||||
let mut content: Option<String> = None;
|
||||
let mut channel_id: Option<String> = None;
|
||||
let mut source = "epiphany/persona".to_string();
|
||||
let mut status = "ready".to_string();
|
||||
let mut mood = "attentive".to_string();
|
||||
let mut limit = 8_usize;
|
||||
let mut persona_name: Option<String> = None;
|
||||
let mut persona_avatar_url: Option<String> = None;
|
||||
let mut reply_to_message_id: Option<String> = None;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--config" => config_path = next_path(&mut args, "--config")?,
|
||||
"--artifact-dir" => artifact_dir = next_path(&mut args, "--artifact-dir")?,
|
||||
"--content" => content = Some(next_value(&mut args, "--content")?),
|
||||
"--channel-id" => channel_id = Some(next_value(&mut args, "--channel-id")?),
|
||||
"--source" => source = next_value(&mut args, "--source")?,
|
||||
"--status" => status = next_value(&mut args, "--status")?,
|
||||
"--mood" => mood = next_value(&mut args, "--mood")?,
|
||||
"--limit" => limit = next_value(&mut args, "--limit")?.parse()?,
|
||||
"--persona-name" => persona_name = Some(next_value(&mut args, "--persona-name")?),
|
||||
"--persona-avatar-url" => {
|
||||
persona_avatar_url = Some(next_value(&mut args, "--persona-avatar-url")?)
|
||||
}
|
||||
"--reply-to-message-id" => {
|
||||
reply_to_message_id = Some(next_value(&mut args, "--reply-to-message-id")?)
|
||||
}
|
||||
_ => return Err(anyhow!("unknown argument {arg:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
let result = match command.as_str() {
|
||||
"draft" => {
|
||||
let config = load_config(&config_path)?;
|
||||
let content = read_content(content)?;
|
||||
run_draft(
|
||||
&content,
|
||||
&config,
|
||||
&artifact_dir,
|
||||
"draft",
|
||||
"drafted without posting",
|
||||
)?
|
||||
}
|
||||
"bubble" => {
|
||||
let content = read_content(content)?;
|
||||
run_bubble(&content, &artifact_dir, &source, &status, &mood)?
|
||||
}
|
||||
"post" => {
|
||||
let config = load_config(&config_path)?;
|
||||
let content = read_content(content)?;
|
||||
run_post(
|
||||
&content,
|
||||
&config,
|
||||
&artifact_dir,
|
||||
channel_id,
|
||||
persona_name,
|
||||
persona_avatar_url,
|
||||
reply_to_message_id,
|
||||
)?
|
||||
}
|
||||
"latest" => serde_json::json!({
|
||||
"ok": true,
|
||||
"artifactDir": artifact_dir,
|
||||
"latestArtifacts": latest_persona_artifacts(&artifact_dir, limit),
|
||||
}),
|
||||
"smoke" => run_smoke()?,
|
||||
_ => return usage(),
|
||||
};
|
||||
let ok = result["ok"].as_bool().unwrap_or(false);
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
if !ok {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_draft(
|
||||
content: &str,
|
||||
config: &PersonaConfig,
|
||||
artifact_dir: &Path,
|
||||
status: &str,
|
||||
reason: &str,
|
||||
) -> Result<Value> {
|
||||
ensure_content(content, "Persona chat")?;
|
||||
let path = write_draft(content, config, artifact_dir, status, reason)?;
|
||||
Ok(serde_json::json!({
|
||||
"ok": status != "blocked",
|
||||
"posted": false,
|
||||
"draftPath": path,
|
||||
}))
|
||||
}
|
||||
|
||||
fn run_bubble(
|
||||
content: &str,
|
||||
artifact_dir: &Path,
|
||||
source: &str,
|
||||
status: &str,
|
||||
mood: &str,
|
||||
) -> Result<Value> {
|
||||
ensure_content(content, "Persona bubble")?;
|
||||
let payload = bubble_payload(content, source, status, mood);
|
||||
let path = artifact_dir.join(format!(
|
||||
"persona-bubble-{}-{}.json",
|
||||
now_stamp(),
|
||||
short_id()
|
||||
));
|
||||
write_json(&path, &payload)?;
|
||||
Ok(serde_json::json!({
|
||||
"ok": true,
|
||||
"posted": false,
|
||||
"bubblePath": path,
|
||||
"bubble": payload,
|
||||
}))
|
||||
}
|
||||
|
||||
fn run_post(
|
||||
content: &str,
|
||||
config: &PersonaConfig,
|
||||
artifact_dir: &Path,
|
||||
channel_id: Option<String>,
|
||||
persona_name: Option<String>,
|
||||
persona_avatar_url: Option<String>,
|
||||
reply_to_message_id: Option<String>,
|
||||
) -> Result<Value> {
|
||||
ensure_content(content, "Persona chat")?;
|
||||
let configured_channel_id = allowed_channel_id(config);
|
||||
let requested_channel_id = channel_id.or_else(|| configured_channel_id.clone());
|
||||
let Some(configured_channel_id) = configured_channel_id else {
|
||||
let path = write_draft(
|
||||
content,
|
||||
config,
|
||||
artifact_dir,
|
||||
"blocked",
|
||||
"missing #aquarium channel id",
|
||||
)?;
|
||||
return Ok(serde_json::json!({
|
||||
"ok": false,
|
||||
"posted": false,
|
||||
"blocked": "missing-channel-id",
|
||||
"draftPath": path,
|
||||
}));
|
||||
};
|
||||
if requested_channel_id.as_deref() != Some(configured_channel_id.as_str()) {
|
||||
let path = write_draft(
|
||||
content,
|
||||
config,
|
||||
artifact_dir,
|
||||
"blocked",
|
||||
"requested channel does not match configured #aquarium channel id",
|
||||
)?;
|
||||
return Ok(serde_json::json!({
|
||||
"ok": false,
|
||||
"posted": false,
|
||||
"blocked": "wrong-channel",
|
||||
"draftPath": path,
|
||||
}));
|
||||
}
|
||||
let Some(token) = bot_token(config) else {
|
||||
let path = write_draft(
|
||||
content,
|
||||
config,
|
||||
artifact_dir,
|
||||
"blocked",
|
||||
"missing Discord bot token",
|
||||
)?;
|
||||
return Ok(serde_json::json!({
|
||||
"ok": false,
|
||||
"posted": false,
|
||||
"blocked": "missing-token",
|
||||
"draftPath": path,
|
||||
}));
|
||||
};
|
||||
let persona = resolve_persona(config, persona_name, persona_avatar_url)?;
|
||||
let posted = post_discord_message(
|
||||
&token,
|
||||
&configured_channel_id,
|
||||
content.trim(),
|
||||
reply_to_message_id.as_deref(),
|
||||
persona.as_ref(),
|
||||
config,
|
||||
artifact_dir,
|
||||
)?;
|
||||
let path = write_draft(
|
||||
content,
|
||||
config,
|
||||
artifact_dir,
|
||||
"posted",
|
||||
&format!(
|
||||
"posted message {} via {}",
|
||||
posted.message_id, posted.transport
|
||||
),
|
||||
)?;
|
||||
Ok(serde_json::json!({
|
||||
"ok": true,
|
||||
"posted": true,
|
||||
"messageId": posted.message_id,
|
||||
"transport": posted.transport,
|
||||
"persona": persona.map(|persona| serde_json::json!({
|
||||
"name": persona.name,
|
||||
"avatarUrl": persona.avatar_url,
|
||||
})),
|
||||
"draftPath": path,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct DiscordPersona {
|
||||
name: String,
|
||||
avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PostedDiscordMessage {
|
||||
message_id: String,
|
||||
transport: &'static str,
|
||||
}
|
||||
|
||||
fn resolve_persona(
|
||||
config: &PersonaConfig,
|
||||
persona_name: Option<String>,
|
||||
persona_avatar_url: Option<String>,
|
||||
) -> Result<Option<DiscordPersona>> {
|
||||
let name = trim_optional(persona_name).or_else(|| trim_optional(config.persona_name.clone()));
|
||||
let avatar_url = trim_optional(persona_avatar_url)
|
||||
.or_else(|| trim_optional(config.persona_avatar_url.clone()));
|
||||
if name.is_none() && avatar_url.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(name) = name else {
|
||||
return Err(anyhow!(
|
||||
"persona-name is required when posting with a persona avatar"
|
||||
));
|
||||
};
|
||||
Ok(Some(DiscordPersona {
|
||||
name: name.chars().take(80).collect(),
|
||||
avatar_url,
|
||||
}))
|
||||
}
|
||||
|
||||
fn post_discord_message(
|
||||
token: &str,
|
||||
channel_id: &str,
|
||||
content: &str,
|
||||
reply_to_message_id: Option<&str>,
|
||||
persona: Option<&DiscordPersona>,
|
||||
config: &PersonaConfig,
|
||||
artifact_dir: &Path,
|
||||
) -> Result<PostedDiscordMessage> {
|
||||
if let Some(persona) = persona {
|
||||
return post_discord_persona_message(
|
||||
token,
|
||||
channel_id,
|
||||
content,
|
||||
reply_to_message_id,
|
||||
persona,
|
||||
config,
|
||||
artifact_dir,
|
||||
);
|
||||
}
|
||||
|
||||
let response = Client::new()
|
||||
.post(format!("{DISCORD_API}/channels/{channel_id}/messages"))
|
||||
.header("Authorization", format!("Bot {token}"))
|
||||
.header("User-Agent", "EpiphanyPersona/0.1")
|
||||
.json(&serde_json::json!({
|
||||
"content": content,
|
||||
"message_reference": reply_to_message_id.map(|message_id| serde_json::json!({
|
||||
"message_id": message_id,
|
||||
"fail_if_not_exists": false,
|
||||
})),
|
||||
"allowed_mentions": {"parse": []},
|
||||
}))
|
||||
.send()
|
||||
.context("Discord post failed")?;
|
||||
let payload = decode_discord_json(response, "Discord message post")?;
|
||||
Ok(PostedDiscordMessage {
|
||||
message_id: required_discord_id(&payload, "Discord message post")?,
|
||||
transport: "bot",
|
||||
})
|
||||
}
|
||||
|
||||
fn post_discord_persona_message(
|
||||
token: &str,
|
||||
channel_id: &str,
|
||||
content: &str,
|
||||
reply_to_message_id: Option<&str>,
|
||||
persona: &DiscordPersona,
|
||||
config: &PersonaConfig,
|
||||
artifact_dir: &Path,
|
||||
) -> Result<PostedDiscordMessage> {
|
||||
let target = resolve_webhook_target(token, channel_id)?;
|
||||
let cache_path = persona_webhook_cache_path(config, artifact_dir);
|
||||
let mut webhook = cached_persona_webhook(&cache_path, &target.webhook_channel_id)?;
|
||||
if webhook.is_none() {
|
||||
let created = create_persona_webhook(token, &target.webhook_channel_id)?;
|
||||
write_cached_persona_webhook(&cache_path, &target.webhook_channel_id, &created)?;
|
||||
webhook = Some(created);
|
||||
}
|
||||
let webhook = webhook.context("persona webhook should exist")?;
|
||||
match execute_persona_webhook(&webhook, &target, content, reply_to_message_id, persona) {
|
||||
Ok(posted) => Ok(posted),
|
||||
Err(error) if is_stale_webhook_error(&error) => {
|
||||
clear_cached_persona_webhook(&cache_path, &target.webhook_channel_id)?;
|
||||
let refreshed = create_persona_webhook(token, &target.webhook_channel_id)?;
|
||||
write_cached_persona_webhook(&cache_path, &target.webhook_channel_id, &refreshed)?;
|
||||
execute_persona_webhook(&refreshed, &target, content, reply_to_message_id, persona)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct WebhookTarget {
|
||||
webhook_channel_id: String,
|
||||
thread_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CachedWebhookRecord {
|
||||
id: String,
|
||||
token: String,
|
||||
channel_id: String,
|
||||
name: String,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
fn resolve_webhook_target(token: &str, channel_id: &str) -> Result<WebhookTarget> {
|
||||
let response = Client::new()
|
||||
.get(format!("{DISCORD_API}/channels/{channel_id}"))
|
||||
.header("Authorization", format!("Bot {token}"))
|
||||
.header("User-Agent", "EpiphanyPersona/0.1")
|
||||
.send()
|
||||
.context("Discord channel lookup failed")?;
|
||||
let payload = decode_discord_json(response, "Discord channel lookup")?;
|
||||
let channel_type = payload["type"]
|
||||
.as_i64()
|
||||
.ok_or_else(|| anyhow!("Discord channel lookup returned no numeric type"))?;
|
||||
let id = required_discord_id(&payload, "Discord channel lookup")?;
|
||||
if matches!(channel_type, 10 | 11 | 12) {
|
||||
let parent_id = payload["parent_id"].as_str().ok_or_else(|| {
|
||||
anyhow!("Discord thread {id} has no parent channel for webhook routing")
|
||||
})?;
|
||||
return Ok(WebhookTarget {
|
||||
webhook_channel_id: parent_id.to_string(),
|
||||
thread_id: Some(id),
|
||||
});
|
||||
}
|
||||
Ok(WebhookTarget {
|
||||
webhook_channel_id: id,
|
||||
thread_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_persona_webhook(token: &str, channel_id: &str) -> Result<CachedWebhookRecord> {
|
||||
let response = Client::new()
|
||||
.post(format!("{DISCORD_API}/channels/{channel_id}/webhooks"))
|
||||
.header("Authorization", format!("Bot {token}"))
|
||||
.header("User-Agent", "EpiphanyPersona/0.1")
|
||||
.json(&serde_json::json!({"name": "Epiphany Persona Pipe"}))
|
||||
.send()
|
||||
.context("Discord webhook creation failed")?;
|
||||
let payload = decode_discord_json(response, "Discord webhook creation")?;
|
||||
let id = required_discord_id(&payload, "Discord webhook creation")?;
|
||||
let token = payload["token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("Discord webhook creation returned no executable token"))?;
|
||||
Ok(CachedWebhookRecord {
|
||||
id,
|
||||
token: token.to_string(),
|
||||
channel_id: channel_id.to_string(),
|
||||
name: "Epiphany Persona Pipe".to_string(),
|
||||
created_at: now_iso(),
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_persona_webhook(
|
||||
webhook: &CachedWebhookRecord,
|
||||
target: &WebhookTarget,
|
||||
content: &str,
|
||||
reply_to_message_id: Option<&str>,
|
||||
persona: &DiscordPersona,
|
||||
) -> Result<PostedDiscordMessage> {
|
||||
let mut url = format!(
|
||||
"{DISCORD_API}/webhooks/{}/{}?wait=true",
|
||||
webhook.id, webhook.token
|
||||
);
|
||||
if let Some(thread_id) = &target.thread_id {
|
||||
url.push_str("&thread_id=");
|
||||
url.push_str(thread_id);
|
||||
}
|
||||
let response = Client::new()
|
||||
.post(url)
|
||||
.header("User-Agent", "EpiphanyPersona/0.1")
|
||||
.json(&serde_json::json!({
|
||||
"content": content,
|
||||
"username": persona.name,
|
||||
"avatar_url": persona.avatar_url,
|
||||
"message_reference": reply_to_message_id.map(|message_id| serde_json::json!({
|
||||
"message_id": message_id,
|
||||
"fail_if_not_exists": false,
|
||||
})),
|
||||
"allowed_mentions": {"parse": []},
|
||||
}))
|
||||
.send()
|
||||
.context("Discord webhook execution failed")?;
|
||||
let payload = decode_discord_json(response, "Discord webhook execution")?;
|
||||
Ok(PostedDiscordMessage {
|
||||
message_id: required_discord_id(&payload, "Discord webhook execution")?,
|
||||
transport: "webhook",
|
||||
})
|
||||
}
|
||||
|
||||
fn run_smoke() -> Result<Value> {
|
||||
let temp_dir = scoped_temp_dir("epiphany-persona-discord-smoke")?;
|
||||
let config = PersonaConfig {
|
||||
allowed_channel_name: "#aquarium".to_string(),
|
||||
allowed_channel_id: None,
|
||||
allowed_channel_id_env: Some("EPIPHANY_PERSONA_AQUARIUM_CHANNEL_ID_TEST".to_string()),
|
||||
bot_token_env: Some("DISCORD_BOT_TOKEN_TEST".to_string()),
|
||||
persona_name: Some("Smoke Persona".to_string()),
|
||||
persona_avatar_url: Some("https://example.invalid/persona.png".to_string()),
|
||||
webhook_cache_path: Some(temp_dir.join("webhook-cache.json")),
|
||||
};
|
||||
unsafe {
|
||||
env::remove_var("EPIPHANY_PERSONA_AQUARIUM_CHANNEL_ID_TEST");
|
||||
}
|
||||
let draft = run_draft(
|
||||
"Persona notices Proprioception and Soul disagree about evidence shape.",
|
||||
&config,
|
||||
&temp_dir,
|
||||
"draft",
|
||||
"drafted without posting",
|
||||
)?;
|
||||
let bubble = run_bubble(
|
||||
"Persona opens an Aquarium bubble even while Discord is unavailable.",
|
||||
&temp_dir,
|
||||
"smoke/persona",
|
||||
"ready",
|
||||
"attentive",
|
||||
)?;
|
||||
let blocked = run_post(
|
||||
"Persona should not post without a configured #aquarium channel id.",
|
||||
&config,
|
||||
&temp_dir,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
unsafe {
|
||||
env::set_var("EPIPHANY_PERSONA_AQUARIUM_CHANNEL_ID_TEST", "123");
|
||||
}
|
||||
let wrong = run_post(
|
||||
"Persona should not post outside #aquarium.",
|
||||
&config,
|
||||
&temp_dir,
|
||||
Some("456".to_string()),
|
||||
Some("Wrong Persona".to_string()),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
let ok = draft["ok"] == true
|
||||
&& bubble["ok"] == true
|
||||
&& bubble["bubble"]["schema_version"] == BUBBLE_SCHEMA_VERSION
|
||||
&& bubble["bubble"]["bubble"]["requiresDiscord"] == false
|
||||
&& blocked["ok"] == false
|
||||
&& blocked["blocked"] == "missing-channel-id"
|
||||
&& wrong["ok"] == false
|
||||
&& wrong["blocked"] == "wrong-channel";
|
||||
let result = serde_json::json!({
|
||||
"ok": ok,
|
||||
"draft": draft,
|
||||
"bubble": bubble,
|
||||
"blocked": blocked,
|
||||
"wrongChannel": wrong,
|
||||
});
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn write_draft(
|
||||
content: &str,
|
||||
config: &PersonaConfig,
|
||||
artifact_dir: &Path,
|
||||
status: &str,
|
||||
reason: &str,
|
||||
) -> Result<PathBuf> {
|
||||
let payload = serde_json::json!({
|
||||
"schema_version": CHAT_SCHEMA_VERSION,
|
||||
"created_at": now_iso(),
|
||||
"status": status,
|
||||
"reason": reason,
|
||||
"allowed_channel_name": if config.allowed_channel_name.is_empty() { "#aquarium" } else { &config.allowed_channel_name },
|
||||
"allowed_channel_id": allowed_channel_id(config),
|
||||
"persona_name": config.persona_name,
|
||||
"persona_avatar_url": config.persona_avatar_url,
|
||||
"content": content.trim(),
|
||||
});
|
||||
let path = artifact_dir.join(format!("persona-chat-{}-{}.json", now_stamp(), short_id()));
|
||||
write_json(&path, &payload)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn bubble_payload(content: &str, source: &str, status: &str, mood: &str) -> Value {
|
||||
serde_json::json!({
|
||||
"schema_version": BUBBLE_SCHEMA_VERSION,
|
||||
"created_at": now_iso(),
|
||||
"status": status,
|
||||
"source": source,
|
||||
"target": "aquarium",
|
||||
"role_id": "persona",
|
||||
"agent_id": "persona",
|
||||
"display_name": "Persona",
|
||||
"mood": mood,
|
||||
"content": content.trim(),
|
||||
"bubble": {
|
||||
"kind": "agent-chat",
|
||||
"anchorRoleId": "persona",
|
||||
"opensIn": "aquarium",
|
||||
"requiresDiscord": false,
|
||||
"ttlSeconds": 90,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn latest_persona_artifacts(artifact_dir: &Path, limit: usize) -> Vec<Value> {
|
||||
let Ok(read_dir) = fs::read_dir(artifact_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut paths = read_dir
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with("persona-") && name.ends_with(".json"))
|
||||
})
|
||||
.filter_map(|path| Some((path.metadata().ok()?.modified().ok()?, path)))
|
||||
.collect::<Vec<_>>();
|
||||
paths.sort_by_key(|item| Reverse(item.0));
|
||||
paths
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.filter_map(|(modified, path)| {
|
||||
let payload: Value = serde_json::from_str(&fs::read_to_string(&path).ok()?).ok()?;
|
||||
let modified_at: chrono::DateTime<chrono::Utc> = modified.into();
|
||||
Some(serde_json::json!({
|
||||
"path": path,
|
||||
"name": path.file_name().and_then(|name| name.to_str()),
|
||||
"modifiedAt": modified_at.to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
"schemaVersion": payload.get("schema_version"),
|
||||
"status": payload.get("status"),
|
||||
"reason": payload.get("reason"),
|
||||
"content": payload.get("content"),
|
||||
"bubble": payload.get("bubble"),
|
||||
"source": payload.get("source"),
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn persona_webhook_cache_path(config: &PersonaConfig, artifact_dir: &Path) -> PathBuf {
|
||||
config
|
||||
.webhook_cache_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| artifact_dir.join("discord-webhook-cache.json"))
|
||||
}
|
||||
|
||||
fn cached_persona_webhook(path: &Path, channel_id: &str) -> Result<Option<CachedWebhookRecord>> {
|
||||
let cache = read_persona_webhook_cache(path)?;
|
||||
let Some(value) = cache.get(channel_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let record: CachedWebhookRecord = serde_json::from_value(value.clone())
|
||||
.with_context(|| format!("cached persona webhook for channel {channel_id} is malformed"))?;
|
||||
Ok((record.channel_id == channel_id).then_some(record))
|
||||
}
|
||||
|
||||
fn read_persona_webhook_cache(path: &Path) -> Result<serde_json::Map<String, Value>> {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(raw) => {
|
||||
let value: Value = serde_json::from_str(strip_bom(&raw))
|
||||
.with_context(|| format!("failed to decode {}", path.display()))?;
|
||||
Ok(value.as_object().cloned().unwrap_or_default())
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::Map::new()),
|
||||
Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_cached_persona_webhook(
|
||||
path: &Path,
|
||||
channel_id: &str,
|
||||
webhook: &CachedWebhookRecord,
|
||||
) -> Result<()> {
|
||||
let mut cache = read_persona_webhook_cache(path)?;
|
||||
cache.insert(channel_id.to_string(), serde_json::to_value(webhook)?);
|
||||
write_json(path, &Value::Object(cache))
|
||||
}
|
||||
|
||||
fn clear_cached_persona_webhook(path: &Path, channel_id: &str) -> Result<()> {
|
||||
let mut cache = read_persona_webhook_cache(path)?;
|
||||
if cache.remove(channel_id).is_some() {
|
||||
write_json(path, &Value::Object(cache))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_discord_json(response: reqwest::blocking::Response, context: &str) -> Result<Value> {
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.with_context(|| format!("{context} response body read failed"))?;
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("{context} failed with HTTP {status}: {body}"));
|
||||
}
|
||||
serde_json::from_str(&body).with_context(|| format!("{context} response was not JSON"))
|
||||
}
|
||||
|
||||
fn required_discord_id(payload: &Value, context: &str) -> Result<String> {
|
||||
payload["id"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| anyhow!("{context} returned no id"))
|
||||
}
|
||||
|
||||
fn is_stale_webhook_error(error: &anyhow::Error) -> bool {
|
||||
let message = error.to_string();
|
||||
message.contains("Discord webhook execution failed with HTTP 401")
|
||||
|| message.contains("Discord webhook execution failed with HTTP 404")
|
||||
}
|
||||
|
||||
fn trim_optional(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn strip_bom(value: &str) -> &str {
|
||||
value.strip_prefix('\u{feff}').unwrap_or(value)
|
||||
}
|
||||
|
||||
fn load_config(path: &Path) -> Result<PersonaConfig> {
|
||||
let raw =
|
||||
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "json")
|
||||
{
|
||||
let payload: Value = serde_json::from_str(&raw)
|
||||
.with_context(|| format!("failed to decode {}", path.display()))?;
|
||||
return Ok(PersonaConfig {
|
||||
allowed_channel_name: payload["allowed_channel_name"]
|
||||
.as_str()
|
||||
.unwrap_or("#aquarium")
|
||||
.to_string(),
|
||||
allowed_channel_id: payload["allowed_channel_id"]
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
allowed_channel_id_env: payload["allowed_channel_id_env"]
|
||||
.as_str()
|
||||
.map(str::to_string),
|
||||
bot_token_env: payload["bot_token_env"].as_str().map(str::to_string),
|
||||
persona_name: payload["persona_name"].as_str().map(str::to_string),
|
||||
persona_avatar_url: payload["persona_avatar_url"].as_str().map(str::to_string),
|
||||
webhook_cache_path: payload["webhook_cache_path"].as_str().map(PathBuf::from),
|
||||
});
|
||||
}
|
||||
Ok(PersonaConfig {
|
||||
allowed_channel_name: toml_string(&raw, "allowed_channel_name")
|
||||
.unwrap_or_else(|| "#aquarium".to_string()),
|
||||
allowed_channel_id: toml_string(&raw, "allowed_channel_id"),
|
||||
allowed_channel_id_env: toml_string(&raw, "allowed_channel_id_env"),
|
||||
bot_token_env: toml_string(&raw, "bot_token_env"),
|
||||
persona_name: toml_string(&raw, "persona_name"),
|
||||
persona_avatar_url: toml_string(&raw, "persona_avatar_url"),
|
||||
webhook_cache_path: toml_string(&raw, "webhook_cache_path").map(PathBuf::from),
|
||||
})
|
||||
}
|
||||
|
||||
fn toml_string(raw: &str, key: &str) -> Option<String> {
|
||||
raw.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.starts_with('#'))
|
||||
.find_map(|line| {
|
||||
let (left, right) = line.split_once('=')?;
|
||||
(left.trim() == key).then(|| parse_quoted(right.trim()))?
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_quoted(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
value
|
||||
.strip_prefix('"')
|
||||
.and_then(|value| value.strip_suffix('"'))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn allowed_channel_id(config: &PersonaConfig) -> Option<String> {
|
||||
config
|
||||
.allowed_channel_id
|
||||
.clone()
|
||||
.or_else(|| config.allowed_channel_id_env.as_ref().and_then(env_value))
|
||||
}
|
||||
|
||||
fn bot_token(config: &PersonaConfig) -> Option<String> {
|
||||
config.bot_token_env.as_ref().and_then(env_value)
|
||||
}
|
||||
|
||||
fn env_value(name: &String) -> Option<String> {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn read_content(content: Option<String>) -> Result<String> {
|
||||
let Some(content) = content else {
|
||||
let mut value = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut value)
|
||||
.context("failed to read stdin")?;
|
||||
return Ok(value);
|
||||
};
|
||||
let path = PathBuf::from(&content);
|
||||
if path.exists() {
|
||||
return fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read {}", path.display()));
|
||||
}
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn ensure_content(content: &str, label: &str) -> Result<()> {
|
||||
if content.trim().is_empty() {
|
||||
return Err(anyhow!("{label} content is empty"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))
|
||||
.with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn now_iso() -> String {
|
||||
chrono::Utc::now()
|
||||
.to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||
.replace('Z', "+00:00")
|
||||
}
|
||||
|
||||
fn now_stamp() -> String {
|
||||
chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string()
|
||||
}
|
||||
|
||||
fn short_id() -> String {
|
||||
Uuid::new_v4().simple().to_string()[..8].to_string()
|
||||
}
|
||||
|
||||
fn scoped_temp_dir(prefix: &str) -> Result<PathBuf> {
|
||||
let path = env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&path).with_context(|| format!("failed to create {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn next_path(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(next_value(args, name)?))
|
||||
}
|
||||
|
||||
fn next_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
args.next()
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
|
||||
fn usage() -> Result<()> {
|
||||
Err(anyhow!(
|
||||
"usage: epiphany-persona-discord <draft|bubble|post|latest|smoke> [--config <path>] [--artifact-dir <path>] [--content <text-or-path>]"
|
||||
))
|
||||
}
|
||||
529
epiphany-core/src/bin/epiphany-phase6-context-smoke.rs
Normal file
529
epiphany-core/src/bin/epiphany-phase6-context-smoke.rs
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[path = "epiphany-mvp-status.rs"]
|
||||
mod status_cli;
|
||||
|
||||
const DEFAULT_APP_SERVER: &str = r"C:\Users\Meta\.cargo-target-codex\debug\codex-app-server.exe";
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
let result = run_smoke(&args)?;
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
app_server: PathBuf,
|
||||
codex_home: PathBuf,
|
||||
result: PathBuf,
|
||||
transcript: PathBuf,
|
||||
stderr: PathBuf,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let mut parsed = Self {
|
||||
app_server: PathBuf::from(DEFAULT_APP_SERVER),
|
||||
codex_home: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-context-codex-home"),
|
||||
result: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-context-smoke-result.json"),
|
||||
transcript: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-context-smoke-transcript.jsonl"),
|
||||
stderr: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-context-smoke-server.stderr.log"),
|
||||
};
|
||||
let mut args = env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--app-server" => parsed.app_server = take_path(&mut args, "--app-server")?,
|
||||
"--codex-home" => parsed.codex_home = take_path(&mut args, "--codex-home")?,
|
||||
"--result" => parsed.result = take_path(&mut args, "--result")?,
|
||||
"--transcript" => parsed.transcript = take_path(&mut args, "--transcript")?,
|
||||
"--stderr" => parsed.stderr = take_path(&mut args, "--stderr")?,
|
||||
other => return Err(anyhow!("unknown argument: {other}")),
|
||||
}
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_smoke(args: &Args) -> Result<Value> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let app_server = status_cli::absolute_path(&args.app_server)?;
|
||||
if !app_server.exists() {
|
||||
return Err(anyhow!(
|
||||
"codex app-server binary not found: {}",
|
||||
app_server.display()
|
||||
));
|
||||
}
|
||||
let codex_home = status_cli::absolute_path(&args.codex_home)?;
|
||||
let result_path = status_cli::absolute_path(&args.result)?;
|
||||
let transcript_path = status_cli::absolute_path(&args.transcript)?;
|
||||
let stderr_path = status_cli::absolute_path(&args.stderr)?;
|
||||
reset_smoke_paths(
|
||||
&root,
|
||||
&[
|
||||
codex_home.clone(),
|
||||
result_path.clone(),
|
||||
transcript_path.clone(),
|
||||
stderr_path.clone(),
|
||||
],
|
||||
)?;
|
||||
fs::create_dir_all(&codex_home)
|
||||
.with_context(|| format!("failed to create {}", codex_home.display()))?;
|
||||
|
||||
let mut client = status_cli::AppServerClient::start(
|
||||
&app_server,
|
||||
&codex_home,
|
||||
&transcript_path,
|
||||
&stderr_path,
|
||||
)?;
|
||||
client.send(
|
||||
"initialize",
|
||||
Some(json!({
|
||||
"clientInfo": {
|
||||
"name": "epiphany-phase6-context-smoke",
|
||||
"title": "Epiphany Phase 6 Context Smoke",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
"capabilities": {"experimentalApi": true},
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
client.send("initialized", None, false)?;
|
||||
let started = client.send(
|
||||
"thread/start",
|
||||
Some(json!({"cwd": root.join("epiphany-core"), "ephemeral": true})),
|
||||
true,
|
||||
)?;
|
||||
let thread_id = started
|
||||
.pointer("/thread/id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("thread/start response missing thread.id"))?
|
||||
.to_string();
|
||||
|
||||
let missing_notification_start = client.notification_len();
|
||||
let missing_response = client.send(
|
||||
"thread/epiphany/context",
|
||||
Some(json!({"threadId": thread_id, "graphNodeIds": ["context-surface"]})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
missing_response.get("threadId").and_then(Value::as_str) == Some(&thread_id),
|
||||
"context response should echo thread id",
|
||||
)?;
|
||||
assert_missing_context(&missing_response)?;
|
||||
client.require_no_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
missing_notification_start,
|
||||
Duration::from_secs(1),
|
||||
)?;
|
||||
|
||||
let update_notification_start = client.notification_len();
|
||||
let update = client.send(
|
||||
"thread/epiphany/update",
|
||||
Some(json!({
|
||||
"threadId": thread_id,
|
||||
"expectedRevision": 0,
|
||||
"patch": context_patch(),
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
update.get("revision").and_then(Value::as_u64) == Some(1),
|
||||
"context smoke patch should advance revision to 1",
|
||||
)?;
|
||||
client.wait_for_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
update_notification_start,
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
|
||||
let context_notification_start = client.notification_len();
|
||||
let ready_response = client.send(
|
||||
"thread/epiphany/context",
|
||||
Some(json!({
|
||||
"threadId": thread_id,
|
||||
"graphNodeIds": ["missing-node"],
|
||||
"graphEdgeIds": ["missing-edge"],
|
||||
"observationIds": ["obs-context", "missing-observation"],
|
||||
"evidenceIds": ["ev-context-extra", "missing-evidence"],
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
assert_ready_context(&ready_response)?;
|
||||
client.require_no_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
context_notification_start,
|
||||
Duration::from_secs(1),
|
||||
)?;
|
||||
|
||||
let final_read = client.send(
|
||||
"thread/read",
|
||||
Some(json!({"threadId": thread_id, "includeTurns": false})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
final_read
|
||||
.pointer("/thread/epiphanyState/revision")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(1),
|
||||
"context reflection should not mutate state revision",
|
||||
)?;
|
||||
|
||||
let result = json!({
|
||||
"threadId": thread_id,
|
||||
"codexHome": codex_home,
|
||||
"missingStateStatus": missing_response["stateStatus"],
|
||||
"readyStateStatus": ready_response["stateStatus"],
|
||||
"readyRevision": ready_response["stateRevision"],
|
||||
"architectureNodeIds": ids(&ready_response["context"]["graph"]["architectureNodes"]),
|
||||
"investigationCheckpointId": ready_response["context"]["investigationCheckpoint"]["checkpoint_id"],
|
||||
"investigationDisposition": ready_response["context"]["investigationCheckpoint"]["disposition"],
|
||||
"evidenceIds": ids(&ready_response["context"]["evidence"]),
|
||||
"contextNotificationCount": client.notification_count(
|
||||
"thread/epiphany/stateUpdated",
|
||||
context_notification_start,
|
||||
),
|
||||
"finalReadRevision": final_read["thread"]["epiphanyState"]["revision"],
|
||||
});
|
||||
write_json(&result_path, &result)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn context_patch() -> Value {
|
||||
let code_ref = json!({
|
||||
"path": "app-server/src/codex_message_processor.rs",
|
||||
"start_line": 10791,
|
||||
"end_line": 10989,
|
||||
"symbol": "map_epiphany_context"
|
||||
});
|
||||
json!({
|
||||
"objective": "Expose a read-only Epiphany context shard without returning the full state.",
|
||||
"activeSubgoalId": "phase6-context-smoke",
|
||||
"investigationCheckpoint": {
|
||||
"checkpoint_id": "phase6-context-investigation",
|
||||
"kind": "source_gathering",
|
||||
"disposition": "regather_required",
|
||||
"focus": "Make stale planning obvious when context is re-read after compaction.",
|
||||
"summary": "Context reflection should expose the full durable checkpoint packet.",
|
||||
"next_action": "Re-gather source before editing if the packet no longer matches reality.",
|
||||
"captured_at_turn_id": "turn-phase6-context",
|
||||
"open_questions": ["How much checkpoint detail belongs in scene versus context?"],
|
||||
"code_refs": [code_ref.clone()],
|
||||
"evidence_ids": ["ev-context-linked"]
|
||||
},
|
||||
"subgoals": [{
|
||||
"id": "phase6-context-smoke",
|
||||
"title": "Live-smoke context shard reflection",
|
||||
"status": "active",
|
||||
"summary": "The app-server context surface should return targeted graph/evidence state."
|
||||
}],
|
||||
"graphs": {
|
||||
"architecture": {
|
||||
"nodes": [{
|
||||
"id": "context-surface",
|
||||
"title": "Context shard surface",
|
||||
"purpose": "Return bounded state context for clients without becoming a writer.",
|
||||
"code_refs": [code_ref.clone()]
|
||||
}],
|
||||
"edges": [{
|
||||
"id": "context-edge",
|
||||
"source_id": "context-surface",
|
||||
"target_id": "context-surface",
|
||||
"kind": "reflects",
|
||||
"code_refs": [code_ref.clone()]
|
||||
}]
|
||||
},
|
||||
"dataflow": {
|
||||
"nodes": [{
|
||||
"id": "typed-state",
|
||||
"title": "Typed Epiphany state",
|
||||
"purpose": "Remain authoritative while context shards reflect a slice."
|
||||
}]
|
||||
},
|
||||
"links": [{
|
||||
"dataflow_node_id": "typed-state",
|
||||
"architecture_node_id": "context-surface",
|
||||
"relationship": "bounded-reflection"
|
||||
}]
|
||||
},
|
||||
"graphFrontier": {
|
||||
"active_node_ids": ["context-surface"],
|
||||
"active_edge_ids": ["context-edge"]
|
||||
},
|
||||
"graphCheckpoint": {
|
||||
"checkpoint_id": "phase6-context-smoke",
|
||||
"graph_revision": 1,
|
||||
"summary": "Context shard reflection is the active Phase 6 smoke target.",
|
||||
"frontier_node_ids": ["context-surface"]
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"id": "ev-context-linked",
|
||||
"kind": "smoke-test",
|
||||
"status": "ok",
|
||||
"summary": "Context smoke linked evidence should come along with the observation.",
|
||||
"code_refs": [code_ref.clone()]
|
||||
},
|
||||
{
|
||||
"id": "ev-context-extra",
|
||||
"kind": "review",
|
||||
"status": "ok",
|
||||
"summary": "Context smoke direct evidence should be returned when requested.",
|
||||
"code_refs": [code_ref]
|
||||
}
|
||||
],
|
||||
"observations": [{
|
||||
"id": "obs-context",
|
||||
"summary": "Context smoke observation should be selected by id.",
|
||||
"source_kind": "smoke",
|
||||
"status": "ok",
|
||||
"code_refs": [{
|
||||
"path": "app-server/src/codex_message_processor.rs",
|
||||
"start_line": 10791,
|
||||
"end_line": 10989,
|
||||
"symbol": "map_epiphany_context"
|
||||
}],
|
||||
"evidence_ids": ["ev-context-linked"]
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_missing_context(response: &Value) -> Result<()> {
|
||||
require(
|
||||
response.get("source").and_then(Value::as_str) == Some("live"),
|
||||
"missing context should report live source",
|
||||
)?;
|
||||
require(
|
||||
response.get("stateStatus").and_then(Value::as_str) == Some("missing"),
|
||||
"missing context should report missing state",
|
||||
)?;
|
||||
require(
|
||||
response.get("stateRevision").is_none(),
|
||||
"missing context should not invent a revision",
|
||||
)?;
|
||||
require(
|
||||
response.pointer("/context/graph") == Some(&json!({})),
|
||||
"missing context should not invent graph records",
|
||||
)?;
|
||||
require(
|
||||
string_array_eq(
|
||||
response
|
||||
.pointer("/missing/graphNodeIds")
|
||||
.and_then(Value::as_array),
|
||||
&["context-surface"],
|
||||
),
|
||||
"missing context should echo requested missing node ids",
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_ready_context(response: &Value) -> Result<()> {
|
||||
require(
|
||||
response.get("source").and_then(Value::as_str) == Some("live"),
|
||||
"ready context should report live source",
|
||||
)?;
|
||||
require(
|
||||
response.get("stateStatus").and_then(Value::as_str) == Some("ready"),
|
||||
"ready context should report ready state",
|
||||
)?;
|
||||
require(
|
||||
response.get("stateRevision").and_then(Value::as_u64) == Some(1),
|
||||
"ready context should preserve state revision identity",
|
||||
)?;
|
||||
require(
|
||||
ids_eq(
|
||||
response
|
||||
.pointer("/context/graph/architectureNodes")
|
||||
.and_then(Value::as_array),
|
||||
&["context-surface"],
|
||||
),
|
||||
"context should include active frontier architecture node",
|
||||
)?;
|
||||
require(
|
||||
ids_eq(
|
||||
response
|
||||
.pointer("/context/graph/architectureEdges")
|
||||
.and_then(Value::as_array),
|
||||
&["context-edge"],
|
||||
),
|
||||
"context should include active frontier architecture edge",
|
||||
)?;
|
||||
require(
|
||||
response
|
||||
.pointer("/context/graph/links/0/architecture_node_id")
|
||||
.and_then(Value::as_str)
|
||||
== Some("context-surface"),
|
||||
"context should include links touching selected graph nodes",
|
||||
)?;
|
||||
require(
|
||||
string_array_eq(
|
||||
response
|
||||
.pointer("/context/frontier/active_node_ids")
|
||||
.and_then(Value::as_array),
|
||||
&["context-surface"],
|
||||
),
|
||||
"context should include frontier when active frontier is requested by default",
|
||||
)?;
|
||||
require(
|
||||
response
|
||||
.pointer("/context/checkpoint/checkpoint_id")
|
||||
.and_then(Value::as_str)
|
||||
== Some("phase6-context-smoke"),
|
||||
"context should expose the current graph checkpoint",
|
||||
)?;
|
||||
require(
|
||||
response
|
||||
.pointer("/context/investigationCheckpoint/checkpoint_id")
|
||||
.and_then(Value::as_str)
|
||||
== Some("phase6-context-investigation"),
|
||||
"context should expose the investigation checkpoint id",
|
||||
)?;
|
||||
require(
|
||||
response
|
||||
.pointer("/context/investigationCheckpoint/disposition")
|
||||
.and_then(Value::as_str)
|
||||
== Some("regather_required"),
|
||||
"context should expose the checkpoint disposition",
|
||||
)?;
|
||||
require(
|
||||
ids_eq(
|
||||
response
|
||||
.pointer("/context/observations")
|
||||
.and_then(Value::as_array),
|
||||
&["obs-context"],
|
||||
),
|
||||
"context should include requested observation",
|
||||
)?;
|
||||
require(
|
||||
ids_eq(
|
||||
response
|
||||
.pointer("/context/evidence")
|
||||
.and_then(Value::as_array),
|
||||
&["ev-context-linked", "ev-context-extra"],
|
||||
),
|
||||
"context should include linked and directly requested evidence",
|
||||
)?;
|
||||
require(
|
||||
string_array_eq(
|
||||
response
|
||||
.pointer("/missing/graphNodeIds")
|
||||
.and_then(Value::as_array),
|
||||
&["missing-node"],
|
||||
) && string_array_eq(
|
||||
response
|
||||
.pointer("/missing/graphEdgeIds")
|
||||
.and_then(Value::as_array),
|
||||
&["missing-edge"],
|
||||
) && string_array_eq(
|
||||
response
|
||||
.pointer("/missing/observationIds")
|
||||
.and_then(Value::as_array),
|
||||
&["missing-observation"],
|
||||
) && string_array_eq(
|
||||
response
|
||||
.pointer("/missing/evidenceIds")
|
||||
.and_then(Value::as_array),
|
||||
&["missing-evidence"],
|
||||
),
|
||||
"context should report unresolved requested ids",
|
||||
)
|
||||
}
|
||||
|
||||
fn ids(value: &Value) -> Value {
|
||||
json!(
|
||||
value
|
||||
.as_array()
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.get("id").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
fn ids_eq(values: Option<&Vec<Value>>, expected: &[&str]) -> bool {
|
||||
values.is_some_and(|values| {
|
||||
values.len() == expected.len()
|
||||
&& values
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(value, expected)| value.get("id").and_then(Value::as_str) == Some(*expected))
|
||||
})
|
||||
}
|
||||
|
||||
fn string_array_eq(values: Option<&Vec<Value>>, expected: &[&str]) -> bool {
|
||||
values.is_some_and(|values| {
|
||||
values.len() == expected.len()
|
||||
&& values
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(value, expected)| value.as_str() == Some(*expected))
|
||||
})
|
||||
}
|
||||
|
||||
fn reset_smoke_paths(root: &Path, paths: &[PathBuf]) -> Result<()> {
|
||||
let smoke_root = root.join(".epiphany-smoke").canonicalize().or_else(|_| {
|
||||
let smoke_root = root.join(".epiphany-smoke");
|
||||
fs::create_dir_all(&smoke_root)?;
|
||||
smoke_root.canonicalize()
|
||||
})?;
|
||||
for path in paths {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let resolved = path.canonicalize()?;
|
||||
if resolved == smoke_root || !resolved.starts_with(&smoke_root) {
|
||||
return Err(anyhow!(
|
||||
"refusing to delete non-smoke path: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(path)?;
|
||||
} else {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require(condition: bool, message: &str) -> Result<()> {
|
||||
if condition {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("{message}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn take_path(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
args.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
389
epiphany-core/src/bin/epiphany-phase6-freshness-smoke.rs
Normal file
389
epiphany-core/src/bin/epiphany-phase6-freshness-smoke.rs
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[path = "epiphany-mvp-status.rs"]
|
||||
mod status_cli;
|
||||
|
||||
const DEFAULT_APP_SERVER: &str = r"C:\Users\Meta\.cargo-target-codex\debug\codex-app-server.exe";
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
let result = run_smoke(&args)?;
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
app_server: PathBuf,
|
||||
codex_home: PathBuf,
|
||||
result: PathBuf,
|
||||
transcript: PathBuf,
|
||||
stderr: PathBuf,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let mut parsed = Self {
|
||||
app_server: PathBuf::from(DEFAULT_APP_SERVER),
|
||||
codex_home: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-freshness-codex-home"),
|
||||
result: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-freshness-smoke-result.json"),
|
||||
transcript: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-freshness-smoke-transcript.jsonl"),
|
||||
stderr: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-freshness-smoke-server.stderr.log"),
|
||||
};
|
||||
let mut args = env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--app-server" => parsed.app_server = take_path(&mut args, "--app-server")?,
|
||||
"--codex-home" => parsed.codex_home = take_path(&mut args, "--codex-home")?,
|
||||
"--result" => parsed.result = take_path(&mut args, "--result")?,
|
||||
"--transcript" => parsed.transcript = take_path(&mut args, "--transcript")?,
|
||||
"--stderr" => parsed.stderr = take_path(&mut args, "--stderr")?,
|
||||
other => return Err(anyhow!("unknown argument: {other}")),
|
||||
}
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_smoke(args: &Args) -> Result<Value> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let app_server = status_cli::absolute_path(&args.app_server)?;
|
||||
if !app_server.exists() {
|
||||
return Err(anyhow!(
|
||||
"codex app-server binary not found: {}",
|
||||
app_server.display()
|
||||
));
|
||||
}
|
||||
let codex_home = status_cli::absolute_path(&args.codex_home)?;
|
||||
let result_path = status_cli::absolute_path(&args.result)?;
|
||||
let transcript_path = status_cli::absolute_path(&args.transcript)?;
|
||||
let stderr_path = status_cli::absolute_path(&args.stderr)?;
|
||||
reset_smoke_paths(
|
||||
&root,
|
||||
&[
|
||||
codex_home.clone(),
|
||||
result_path.clone(),
|
||||
transcript_path.clone(),
|
||||
stderr_path.clone(),
|
||||
],
|
||||
)?;
|
||||
fs::create_dir_all(&codex_home)
|
||||
.with_context(|| format!("failed to create {}", codex_home.display()))?;
|
||||
|
||||
let mut client = status_cli::AppServerClient::start(
|
||||
&app_server,
|
||||
&codex_home,
|
||||
&transcript_path,
|
||||
&stderr_path,
|
||||
)?;
|
||||
client.send(
|
||||
"initialize",
|
||||
Some(json!({
|
||||
"clientInfo": {
|
||||
"name": "epiphany-phase6-freshness-smoke",
|
||||
"title": "Epiphany Phase 6 Freshness Smoke",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
"capabilities": {"experimentalApi": true},
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
client.send("initialized", None, false)?;
|
||||
let started = client.send(
|
||||
"thread/start",
|
||||
Some(json!({"cwd": root.join("epiphany-core"), "ephemeral": true})),
|
||||
true,
|
||||
)?;
|
||||
let thread_id = started
|
||||
.pointer("/thread/id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("thread/start response missing thread.id"))?
|
||||
.to_string();
|
||||
|
||||
let missing_notification_start = client.notification_len();
|
||||
let missing_response = client.send(
|
||||
"thread/epiphany/freshness",
|
||||
Some(json!({"threadId": thread_id})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
missing_response.get("threadId").and_then(Value::as_str) == Some(&thread_id),
|
||||
"freshness response should echo thread id",
|
||||
)?;
|
||||
assert_missing_state_freshness(&missing_response)?;
|
||||
client.require_no_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
missing_notification_start,
|
||||
Duration::from_secs(1),
|
||||
)?;
|
||||
|
||||
let update_notification_start = client.notification_len();
|
||||
let update = client.send(
|
||||
"thread/epiphany/update",
|
||||
Some(json!({
|
||||
"threadId": thread_id,
|
||||
"expectedRevision": 0,
|
||||
"patch": freshness_patch(),
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
update.get("revision").and_then(Value::as_u64) == Some(1),
|
||||
"freshness smoke patch should advance revision to 1",
|
||||
)?;
|
||||
client.wait_for_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
update_notification_start,
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
|
||||
let ready_notification_start = client.notification_len();
|
||||
let ready_response = client.send(
|
||||
"thread/epiphany/freshness",
|
||||
Some(json!({"threadId": thread_id})),
|
||||
true,
|
||||
)?;
|
||||
assert_ready_freshness(&ready_response)?;
|
||||
client.require_no_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
ready_notification_start,
|
||||
Duration::from_secs(1),
|
||||
)?;
|
||||
|
||||
let final_read = client.send(
|
||||
"thread/read",
|
||||
Some(json!({"threadId": thread_id, "includeTurns": false})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
final_read
|
||||
.pointer("/thread/epiphanyState/revision")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(1),
|
||||
"freshness reflection should not mutate state revision",
|
||||
)?;
|
||||
|
||||
let result = json!({
|
||||
"threadId": thread_id,
|
||||
"codexHome": codex_home,
|
||||
"missingRetrievalStatus": missing_response["retrieval"]["status"],
|
||||
"missingGraphStatus": missing_response["graph"]["status"],
|
||||
"readyRevision": ready_response["stateRevision"],
|
||||
"readyRetrievalStatus": ready_response["retrieval"]["status"],
|
||||
"readyGraphStatus": ready_response["graph"]["status"],
|
||||
"readyDirtyPathCount": ready_response["graph"]["dirtyPathCount"],
|
||||
"freshnessNotificationCount": client.notification_count(
|
||||
"thread/epiphany/stateUpdated",
|
||||
ready_notification_start,
|
||||
),
|
||||
"finalReadRevision": final_read["thread"]["epiphanyState"]["revision"],
|
||||
});
|
||||
write_json(&result_path, &result)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn freshness_patch() -> Value {
|
||||
json!({
|
||||
"objective": "Expose read-only Epiphany freshness reflection without inventing watcher-driven invalidation.",
|
||||
"activeSubgoalId": "phase6-freshness-smoke",
|
||||
"subgoals": [{
|
||||
"id": "phase6-freshness-smoke",
|
||||
"title": "Live-smoke freshness reflection",
|
||||
"status": "active",
|
||||
"summary": "The freshness surface should reflect graph staleness and live retrieval state without mutation."
|
||||
}],
|
||||
"graphs": {
|
||||
"architecture": {
|
||||
"nodes": [{
|
||||
"id": "freshness-surface",
|
||||
"title": "Freshness reflection surface",
|
||||
"purpose": "Expose exact retrieval and graph freshness pressure without becoming a scheduler.",
|
||||
"code_refs": [{
|
||||
"path": "app-server/src/codex_message_processor.rs",
|
||||
"start_line": 4200,
|
||||
"end_line": 4340,
|
||||
"symbol": "thread_epiphany_freshness"
|
||||
}]
|
||||
}]
|
||||
},
|
||||
"dataflow": {"nodes": []},
|
||||
"links": []
|
||||
},
|
||||
"graphFrontier": {
|
||||
"active_node_ids": ["freshness-surface"],
|
||||
"dirty_paths": ["app-server/src/codex_message_processor.rs"],
|
||||
"open_question_ids": ["q-freshness-gap"]
|
||||
},
|
||||
"graphCheckpoint": {
|
||||
"checkpoint_id": "ck-freshness-1",
|
||||
"graph_revision": 1,
|
||||
"summary": "Freshness smoke checkpoint",
|
||||
"frontier_node_ids": ["freshness-surface"],
|
||||
"open_question_ids": ["q-freshness-gap"]
|
||||
},
|
||||
"churn": {
|
||||
"understanding_status": "ready",
|
||||
"diff_pressure": "low",
|
||||
"graph_freshness": "stale",
|
||||
"unexplained_writes": 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_missing_state_freshness(response: &Value) -> Result<()> {
|
||||
require(
|
||||
response.get("source").and_then(Value::as_str) == Some("live"),
|
||||
"freshness should report live source for a loaded thread",
|
||||
)?;
|
||||
require(
|
||||
response.get("stateRevision").is_none(),
|
||||
"missing-state freshness should not invent a revision",
|
||||
)?;
|
||||
let retrieval = &response["retrieval"];
|
||||
require(
|
||||
retrieval.get("status").and_then(Value::as_str) == Some("ready"),
|
||||
"live retrieval freshness should be available",
|
||||
)?;
|
||||
require(
|
||||
retrieval.get("note").and_then(Value::as_str) == Some("Retrieval catalog is ready."),
|
||||
"fresh live retrieval should report ready note",
|
||||
)?;
|
||||
let graph = &response["graph"];
|
||||
require(
|
||||
graph.get("status").and_then(Value::as_str) == Some("missing"),
|
||||
"missing Epiphany state should block graph freshness",
|
||||
)?;
|
||||
require(
|
||||
graph.get("note").and_then(Value::as_str)
|
||||
== Some("Epiphany state is missing, so graph freshness cannot be assessed."),
|
||||
"missing graph freshness should explain itself",
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_ready_freshness(response: &Value) -> Result<()> {
|
||||
require(
|
||||
response.get("source").and_then(Value::as_str) == Some("live"),
|
||||
"ready freshness should report live source",
|
||||
)?;
|
||||
require(
|
||||
response.get("stateRevision").and_then(Value::as_u64) == Some(1),
|
||||
"freshness should preserve state revision identity",
|
||||
)?;
|
||||
let retrieval = &response["retrieval"];
|
||||
require(
|
||||
retrieval.get("status").and_then(Value::as_str) == Some("ready"),
|
||||
"live retrieval freshness should stay ready for the smoke workspace",
|
||||
)?;
|
||||
require(
|
||||
retrieval.get("semanticAvailable").and_then(Value::as_bool) == Some(true),
|
||||
"live retrieval freshness should report semantic availability",
|
||||
)?;
|
||||
let graph = &response["graph"];
|
||||
require(
|
||||
graph.get("status").and_then(Value::as_str) == Some("stale"),
|
||||
"dirty graph frontier should report stale freshness",
|
||||
)?;
|
||||
require(
|
||||
graph.get("graphFreshness").and_then(Value::as_str) == Some("stale"),
|
||||
"graph freshness should expose the churn hint",
|
||||
)?;
|
||||
require(
|
||||
graph.get("checkpointId").and_then(Value::as_str) == Some("ck-freshness-1"),
|
||||
"graph freshness should expose the checkpoint id",
|
||||
)?;
|
||||
require(
|
||||
graph.get("dirtyPathCount").and_then(Value::as_u64) == Some(1),
|
||||
"graph freshness should count dirty paths",
|
||||
)?;
|
||||
require(
|
||||
string_array_eq(
|
||||
graph.get("dirtyPaths").and_then(Value::as_array),
|
||||
&["app-server/src/codex_message_processor.rs"],
|
||||
),
|
||||
"graph freshness should expose dirty paths",
|
||||
)?;
|
||||
require(
|
||||
graph.get("openQuestionCount").and_then(Value::as_u64) == Some(1),
|
||||
"graph freshness should count open questions",
|
||||
)?;
|
||||
require(
|
||||
graph.get("openGapCount").and_then(Value::as_u64) == Some(0),
|
||||
"graph freshness should count open gaps",
|
||||
)
|
||||
}
|
||||
|
||||
fn string_array_eq(values: Option<&Vec<Value>>, expected: &[&str]) -> bool {
|
||||
values.is_some_and(|values| {
|
||||
values.len() == expected.len()
|
||||
&& values
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(value, expected)| value.as_str() == Some(*expected))
|
||||
})
|
||||
}
|
||||
|
||||
fn reset_smoke_paths(root: &Path, paths: &[PathBuf]) -> Result<()> {
|
||||
let smoke_root = root.join(".epiphany-smoke").canonicalize().or_else(|_| {
|
||||
let smoke_root = root.join(".epiphany-smoke");
|
||||
fs::create_dir_all(&smoke_root)?;
|
||||
smoke_root.canonicalize()
|
||||
})?;
|
||||
for path in paths {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let resolved = path.canonicalize()?;
|
||||
if resolved == smoke_root || !resolved.starts_with(&smoke_root) {
|
||||
return Err(anyhow!(
|
||||
"refusing to delete non-smoke path: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(path)?;
|
||||
} else {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require(condition: bool, message: &str) -> Result<()> {
|
||||
if condition {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("{message}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn take_path(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
args.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
540
epiphany-core/src/bin/epiphany-phase6-graph-query-smoke.rs
Normal file
540
epiphany-core/src/bin/epiphany-phase6-graph-query-smoke.rs
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[path = "epiphany-mvp-status.rs"]
|
||||
mod status_cli;
|
||||
|
||||
const DEFAULT_APP_SERVER: &str = r"C:\Users\Meta\.cargo-target-codex\debug\codex-app-server.exe";
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
let result = run_smoke(&args)?;
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
app_server: PathBuf,
|
||||
codex_home: PathBuf,
|
||||
result: PathBuf,
|
||||
transcript: PathBuf,
|
||||
stderr: PathBuf,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let mut parsed = Self {
|
||||
app_server: PathBuf::from(DEFAULT_APP_SERVER),
|
||||
codex_home: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-graph-query-codex-home"),
|
||||
result: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-graph-query-smoke-result.json"),
|
||||
transcript: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-graph-query-smoke-transcript.jsonl"),
|
||||
stderr: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-graph-query-smoke-server.stderr.log"),
|
||||
};
|
||||
let mut args = env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--app-server" => parsed.app_server = take_path(&mut args, "--app-server")?,
|
||||
"--codex-home" => parsed.codex_home = take_path(&mut args, "--codex-home")?,
|
||||
"--result" => parsed.result = take_path(&mut args, "--result")?,
|
||||
"--transcript" => parsed.transcript = take_path(&mut args, "--transcript")?,
|
||||
"--stderr" => parsed.stderr = take_path(&mut args, "--stderr")?,
|
||||
other => return Err(anyhow!("unknown argument: {other}")),
|
||||
}
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_smoke(args: &Args) -> Result<Value> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let app_server = status_cli::absolute_path(&args.app_server)?;
|
||||
if !app_server.exists() {
|
||||
return Err(anyhow!(
|
||||
"codex app-server binary not found: {}",
|
||||
app_server.display()
|
||||
));
|
||||
}
|
||||
let codex_home = status_cli::absolute_path(&args.codex_home)?;
|
||||
let result_path = status_cli::absolute_path(&args.result)?;
|
||||
let transcript_path = status_cli::absolute_path(&args.transcript)?;
|
||||
let stderr_path = status_cli::absolute_path(&args.stderr)?;
|
||||
reset_smoke_paths(
|
||||
&root,
|
||||
&[
|
||||
codex_home.clone(),
|
||||
result_path.clone(),
|
||||
transcript_path.clone(),
|
||||
stderr_path.clone(),
|
||||
],
|
||||
)?;
|
||||
fs::create_dir_all(&codex_home)
|
||||
.with_context(|| format!("failed to create {}", codex_home.display()))?;
|
||||
|
||||
let mut client = status_cli::AppServerClient::start(
|
||||
&app_server,
|
||||
&codex_home,
|
||||
&transcript_path,
|
||||
&stderr_path,
|
||||
)?;
|
||||
client.send(
|
||||
"initialize",
|
||||
Some(json!({
|
||||
"clientInfo": {
|
||||
"name": "epiphany-phase6-graph-query-smoke",
|
||||
"title": "Epiphany Phase 6 Graph Query Smoke",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
"capabilities": {"experimentalApi": true},
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
client.send("initialized", None, false)?;
|
||||
let started = client.send(
|
||||
"thread/start",
|
||||
Some(json!({"cwd": root.join("epiphany-core"), "ephemeral": true})),
|
||||
true,
|
||||
)?;
|
||||
let thread_id = started
|
||||
.pointer("/thread/id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("thread/start response missing thread.id"))?
|
||||
.to_string();
|
||||
|
||||
let missing_notification_start = client.notification_len();
|
||||
let missing_response = client.send(
|
||||
"thread/epiphany/graphQuery",
|
||||
Some(json!({
|
||||
"threadId": thread_id,
|
||||
"query": {"kind": "node", "nodeIds": ["graph-query-surface"]},
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
assert_missing_graph_query(&missing_response, &thread_id)?;
|
||||
client.require_no_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
missing_notification_start,
|
||||
Duration::from_secs(1),
|
||||
)?;
|
||||
|
||||
let update_notification_start = client.notification_len();
|
||||
let update = client.send(
|
||||
"thread/epiphany/update",
|
||||
Some(json!({
|
||||
"threadId": thread_id,
|
||||
"expectedRevision": 0,
|
||||
"patch": graph_query_patch(),
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
update.get("revision").and_then(Value::as_u64) == Some(1),
|
||||
"graph query smoke patch should advance revision to 1",
|
||||
)?;
|
||||
client.wait_for_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
update_notification_start,
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
|
||||
let frontier_notification_start = client.notification_len();
|
||||
let frontier_response = client.send(
|
||||
"thread/epiphany/graphQuery",
|
||||
Some(json!({
|
||||
"threadId": thread_id,
|
||||
"query": {
|
||||
"kind": "frontierNeighborhood",
|
||||
"direction": "outgoing",
|
||||
"depth": 1,
|
||||
},
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
assert_frontier_graph_query(&frontier_response)?;
|
||||
client.require_no_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
frontier_notification_start,
|
||||
Duration::from_secs(1),
|
||||
)?;
|
||||
|
||||
let path_notification_start = client.notification_len();
|
||||
let path_response = client.send(
|
||||
"thread/epiphany/graphQuery",
|
||||
Some(json!({
|
||||
"threadId": thread_id,
|
||||
"query": {
|
||||
"kind": "path",
|
||||
"paths": ["app-server/src/codex_message_processor.rs"],
|
||||
"symbols": ["map_epiphany_graph_query"],
|
||||
},
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
assert_path_graph_query(&path_response)?;
|
||||
client.require_no_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
path_notification_start,
|
||||
Duration::from_secs(1),
|
||||
)?;
|
||||
|
||||
let final_read = client.send(
|
||||
"thread/read",
|
||||
Some(json!({"threadId": thread_id, "includeTurns": false})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
final_read
|
||||
.pointer("/thread/epiphanyState/revision")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(1),
|
||||
"graph query reflection should not mutate state revision",
|
||||
)?;
|
||||
|
||||
let result = json!({
|
||||
"threadId": thread_id,
|
||||
"codexHome": codex_home,
|
||||
"missingStateStatus": missing_response["stateStatus"],
|
||||
"frontierStateStatus": frontier_response["stateStatus"],
|
||||
"pathStateStatus": path_response["stateStatus"],
|
||||
"readyRevision": frontier_response["stateRevision"],
|
||||
"frontierArchitectureNodeIds": ids(&frontier_response["graph"]["architectureNodes"]),
|
||||
"frontierDataflowNodeIds": ids(&frontier_response["graph"]["dataflowNodes"]),
|
||||
"pathMatchedSymbols": path_response["matched"]["symbols"],
|
||||
"frontierNotificationCount": client.notification_count(
|
||||
"thread/epiphany/stateUpdated",
|
||||
frontier_notification_start,
|
||||
),
|
||||
"pathNotificationCount": client.notification_count(
|
||||
"thread/epiphany/stateUpdated",
|
||||
path_notification_start,
|
||||
),
|
||||
"finalReadRevision": final_read["thread"]["epiphanyState"]["revision"],
|
||||
});
|
||||
write_json(&result_path, &result)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn graph_query_patch() -> Value {
|
||||
let code_ref = json!({
|
||||
"path": "app-server/src/codex_message_processor.rs",
|
||||
"start_line": 15201,
|
||||
"end_line": 15534,
|
||||
"symbol": "map_epiphany_graph_query"
|
||||
});
|
||||
json!({
|
||||
"objective": "Expose read-only graph traversal for implementation and verifier agents.",
|
||||
"activeSubgoalId": "phase6-graph-query-smoke",
|
||||
"subgoals": [{
|
||||
"id": "phase6-graph-query-smoke",
|
||||
"title": "Live-smoke graph query traversal",
|
||||
"status": "active",
|
||||
"summary": "The graph query surface should traverse typed graph state without mutating it."
|
||||
}],
|
||||
"graphs": {
|
||||
"architecture": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "graph-query-surface",
|
||||
"title": "Graph query surface",
|
||||
"purpose": "Return bounded graph neighborhoods and path matches for agents.",
|
||||
"code_refs": [code_ref.clone()]
|
||||
},
|
||||
{
|
||||
"id": "implementation-agent",
|
||||
"title": "Implementation agent",
|
||||
"purpose": "Uses graph neighborhoods to decide where to edit."
|
||||
},
|
||||
{
|
||||
"id": "verifier-agent",
|
||||
"title": "Verifier agent",
|
||||
"purpose": "Uses graph neighborhoods to inspect blast radius."
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "edge-query-implementation",
|
||||
"source_id": "graph-query-surface",
|
||||
"target_id": "implementation-agent",
|
||||
"kind": "guides",
|
||||
"code_refs": [code_ref.clone()]
|
||||
},
|
||||
{
|
||||
"id": "edge-query-verifier",
|
||||
"source_id": "graph-query-surface",
|
||||
"target_id": "verifier-agent",
|
||||
"kind": "guides"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dataflow": {
|
||||
"nodes": [{
|
||||
"id": "typed-graph-state",
|
||||
"title": "Typed graph state",
|
||||
"purpose": "Remain the authoritative model behind graph traversal.",
|
||||
"code_refs": [code_ref.clone()]
|
||||
}]
|
||||
},
|
||||
"links": [{
|
||||
"dataflow_node_id": "typed-graph-state",
|
||||
"architecture_node_id": "graph-query-surface",
|
||||
"relationship": "authoritative-query-source",
|
||||
"code_refs": [code_ref]
|
||||
}]
|
||||
},
|
||||
"graphFrontier": {
|
||||
"active_node_ids": ["graph-query-surface"],
|
||||
"active_edge_ids": ["edge-query-implementation"]
|
||||
},
|
||||
"graphCheckpoint": {
|
||||
"checkpoint_id": "phase6-graph-query-smoke",
|
||||
"graph_revision": 1,
|
||||
"summary": "Graph query traversal is the active Phase 6 smoke target.",
|
||||
"frontier_node_ids": ["graph-query-surface"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_missing_graph_query(response: &Value, thread_id: &str) -> Result<()> {
|
||||
require(
|
||||
response.get("threadId").and_then(Value::as_str) == Some(thread_id),
|
||||
"graph query should echo thread id",
|
||||
)?;
|
||||
require(
|
||||
response.get("source").and_then(Value::as_str) == Some("live"),
|
||||
"missing graph query should report live source",
|
||||
)?;
|
||||
require(
|
||||
response.get("stateStatus").and_then(Value::as_str) == Some("missing"),
|
||||
"missing graph query should report missing state",
|
||||
)?;
|
||||
require(
|
||||
response.get("stateRevision").is_none(),
|
||||
"missing graph query should not invent a revision",
|
||||
)?;
|
||||
require(
|
||||
response.get("graph") == Some(&json!({})),
|
||||
"missing graph query should not invent graph records",
|
||||
)?;
|
||||
require(
|
||||
string_array_eq(
|
||||
response
|
||||
.pointer("/missing/nodeIds")
|
||||
.and_then(Value::as_array),
|
||||
&["graph-query-surface"],
|
||||
),
|
||||
"missing graph query should echo unresolved explicit node ids",
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_frontier_graph_query(response: &Value) -> Result<()> {
|
||||
require(
|
||||
response.get("stateStatus").and_then(Value::as_str) == Some("ready"),
|
||||
"frontier graph query should report ready state",
|
||||
)?;
|
||||
require(
|
||||
response.get("stateRevision").and_then(Value::as_u64) == Some(1),
|
||||
"frontier graph query should preserve revision",
|
||||
)?;
|
||||
require(
|
||||
ids_eq(
|
||||
response
|
||||
.pointer("/graph/architectureNodes")
|
||||
.and_then(Value::as_array),
|
||||
&[
|
||||
"graph-query-surface",
|
||||
"implementation-agent",
|
||||
"verifier-agent",
|
||||
],
|
||||
),
|
||||
"frontier graph query should return one-hop architecture neighbors",
|
||||
)?;
|
||||
require(
|
||||
ids_eq(
|
||||
response
|
||||
.pointer("/graph/dataflowNodes")
|
||||
.and_then(Value::as_array),
|
||||
&["typed-graph-state"],
|
||||
),
|
||||
"frontier graph query should preserve linked dataflow node",
|
||||
)?;
|
||||
require(
|
||||
ids_eq(
|
||||
response
|
||||
.pointer("/graph/architectureEdges")
|
||||
.and_then(Value::as_array),
|
||||
&["edge-query-implementation", "edge-query-verifier"],
|
||||
),
|
||||
"frontier graph query should return active and incident architecture edges",
|
||||
)?;
|
||||
require(
|
||||
response
|
||||
.pointer("/graph/links/0/dataflow_node_id")
|
||||
.and_then(Value::as_str)
|
||||
== Some("typed-graph-state"),
|
||||
"frontier graph query should return architecture/dataflow link",
|
||||
)?;
|
||||
require(
|
||||
string_array_eq(
|
||||
response
|
||||
.pointer("/frontier/active_node_ids")
|
||||
.and_then(Value::as_array),
|
||||
&["graph-query-surface"],
|
||||
),
|
||||
"frontier graph query should include current frontier",
|
||||
)?;
|
||||
require(
|
||||
response
|
||||
.pointer("/checkpoint/checkpoint_id")
|
||||
.and_then(Value::as_str)
|
||||
== Some("phase6-graph-query-smoke"),
|
||||
"frontier graph query should include graph checkpoint",
|
||||
)?;
|
||||
require(
|
||||
response.get("missing") == Some(&json!({})),
|
||||
"frontier graph query should have no missing records",
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_path_graph_query(response: &Value) -> Result<()> {
|
||||
require(
|
||||
response.get("stateStatus").and_then(Value::as_str) == Some("ready"),
|
||||
"path graph query should report ready state",
|
||||
)?;
|
||||
require(
|
||||
string_array_eq(
|
||||
response.pointer("/matched/paths").and_then(Value::as_array),
|
||||
&["app-server/src/codex_message_processor.rs"],
|
||||
),
|
||||
"path graph query should report matched code ref path",
|
||||
)?;
|
||||
require(
|
||||
string_array_eq(
|
||||
response
|
||||
.pointer("/matched/symbols")
|
||||
.and_then(Value::as_array),
|
||||
&["map_epiphany_graph_query"],
|
||||
),
|
||||
"path graph query should report matched symbol",
|
||||
)?;
|
||||
require(
|
||||
string_array_contains(
|
||||
response
|
||||
.pointer("/matched/nodeIds")
|
||||
.and_then(Value::as_array),
|
||||
"graph-query-surface",
|
||||
),
|
||||
"path graph query should include graph query node in matches",
|
||||
)?;
|
||||
require(
|
||||
string_array_contains(
|
||||
response
|
||||
.pointer("/matched/nodeIds")
|
||||
.and_then(Value::as_array),
|
||||
"typed-graph-state",
|
||||
),
|
||||
"path graph query should include linked dataflow node in matches",
|
||||
)
|
||||
}
|
||||
|
||||
fn ids(value: &Value) -> Value {
|
||||
json!(
|
||||
value
|
||||
.as_array()
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.get("id").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
fn ids_eq(values: Option<&Vec<Value>>, expected: &[&str]) -> bool {
|
||||
values.is_some_and(|values| {
|
||||
values.len() == expected.len()
|
||||
&& values
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(value, expected)| value.get("id").and_then(Value::as_str) == Some(*expected))
|
||||
})
|
||||
}
|
||||
|
||||
fn string_array_eq(values: Option<&Vec<Value>>, expected: &[&str]) -> bool {
|
||||
values.is_some_and(|values| {
|
||||
values.len() == expected.len()
|
||||
&& values
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(value, expected)| value.as_str() == Some(*expected))
|
||||
})
|
||||
}
|
||||
|
||||
fn string_array_contains(values: Option<&Vec<Value>>, expected: &str) -> bool {
|
||||
values.is_some_and(|values| values.iter().any(|value| value.as_str() == Some(expected)))
|
||||
}
|
||||
|
||||
fn reset_smoke_paths(root: &Path, paths: &[PathBuf]) -> Result<()> {
|
||||
let smoke_root = root.join(".epiphany-smoke").canonicalize().or_else(|_| {
|
||||
let smoke_root = root.join(".epiphany-smoke");
|
||||
fs::create_dir_all(&smoke_root)?;
|
||||
smoke_root.canonicalize()
|
||||
})?;
|
||||
for path in paths {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let resolved = path.canonicalize()?;
|
||||
if resolved == smoke_root || !resolved.starts_with(&smoke_root) {
|
||||
return Err(anyhow!(
|
||||
"refusing to delete non-smoke path: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(path)?;
|
||||
} else {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require(condition: bool, message: &str) -> Result<()> {
|
||||
if condition {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("{message}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn take_path(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
args.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
529
epiphany-core/src/bin/epiphany-phase6-planning-smoke.rs
Normal file
529
epiphany-core/src/bin/epiphany-phase6-planning-smoke.rs
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[path = "epiphany-mvp-status.rs"]
|
||||
mod status_cli;
|
||||
|
||||
const DEFAULT_APP_SERVER: &str = r"C:\Users\Meta\.cargo-target-codex\debug\codex-app-server.exe";
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
let result = run_smoke(&args)?;
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
app_server: PathBuf,
|
||||
codex_home: PathBuf,
|
||||
result: PathBuf,
|
||||
transcript: PathBuf,
|
||||
stderr: PathBuf,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let mut parsed = Self {
|
||||
app_server: PathBuf::from(DEFAULT_APP_SERVER),
|
||||
codex_home: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-planning-codex-home"),
|
||||
result: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-planning-smoke-result.json"),
|
||||
transcript: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-planning-smoke-transcript.jsonl"),
|
||||
stderr: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-planning-smoke-server.stderr.log"),
|
||||
};
|
||||
let mut args = env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--app-server" => parsed.app_server = take_path(&mut args, "--app-server")?,
|
||||
"--codex-home" => parsed.codex_home = take_path(&mut args, "--codex-home")?,
|
||||
"--result" => parsed.result = take_path(&mut args, "--result")?,
|
||||
"--transcript" => parsed.transcript = take_path(&mut args, "--transcript")?,
|
||||
"--stderr" => parsed.stderr = take_path(&mut args, "--stderr")?,
|
||||
other => return Err(anyhow!("unknown argument: {other}")),
|
||||
}
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_smoke(args: &Args) -> Result<Value> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let app_server = status_cli::absolute_path(&args.app_server)?;
|
||||
if !app_server.exists() {
|
||||
return Err(anyhow!(
|
||||
"codex app-server binary not found: {}",
|
||||
app_server.display()
|
||||
));
|
||||
}
|
||||
let codex_home = status_cli::absolute_path(&args.codex_home)?;
|
||||
let result_path = status_cli::absolute_path(&args.result)?;
|
||||
let transcript_path = status_cli::absolute_path(&args.transcript)?;
|
||||
let stderr_path = status_cli::absolute_path(&args.stderr)?;
|
||||
reset_smoke_paths(
|
||||
&root,
|
||||
&[
|
||||
codex_home.clone(),
|
||||
result_path.clone(),
|
||||
transcript_path.clone(),
|
||||
stderr_path.clone(),
|
||||
],
|
||||
)?;
|
||||
fs::create_dir_all(&codex_home)
|
||||
.with_context(|| format!("failed to create {}", codex_home.display()))?;
|
||||
|
||||
let mut client = status_cli::AppServerClient::start(
|
||||
&app_server,
|
||||
&codex_home,
|
||||
&transcript_path,
|
||||
&stderr_path,
|
||||
)?;
|
||||
client.send(
|
||||
"initialize",
|
||||
Some(json!({
|
||||
"clientInfo": {
|
||||
"name": "epiphany-phase6-planning-smoke",
|
||||
"title": "Epiphany Phase 6 Planning Smoke",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
"capabilities": {"experimentalApi": true},
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
client.send("initialized", None, false)?;
|
||||
let started = client.send(
|
||||
"thread/start",
|
||||
Some(json!({"cwd": root.join("epiphany-core"), "ephemeral": true})),
|
||||
true,
|
||||
)?;
|
||||
let thread_id = started
|
||||
.pointer("/thread/id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("thread/start response missing thread.id"))?
|
||||
.to_string();
|
||||
|
||||
let missing_notification_start = client.notification_len();
|
||||
let missing_view = client.send(
|
||||
"thread/epiphany/view",
|
||||
Some(json!({"threadId": thread_id, "lenses": ["planning"]})),
|
||||
true,
|
||||
)?;
|
||||
let missing_response = missing_view
|
||||
.get("planning")
|
||||
.ok_or_else(|| anyhow!("view response missing planning lens"))?;
|
||||
require(
|
||||
missing_response.get("threadId").and_then(Value::as_str) == Some(&thread_id),
|
||||
"planning response should echo thread id",
|
||||
)?;
|
||||
assert_missing_planning(&missing_response)?;
|
||||
client.require_no_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
missing_notification_start,
|
||||
Duration::from_secs(1),
|
||||
)?;
|
||||
|
||||
let update_notification_start = client.notification_len();
|
||||
let update = client.send(
|
||||
"thread/epiphany/update",
|
||||
Some(json!({
|
||||
"threadId": thread_id,
|
||||
"expectedRevision": 0,
|
||||
"patch": planning_patch(&root),
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
update.get("revision").and_then(Value::as_u64) == Some(1),
|
||||
"planning patch should advance revision to 1",
|
||||
)?;
|
||||
let update_notification = client.wait_for_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
update_notification_start,
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
require(
|
||||
string_array_contains(
|
||||
update_notification
|
||||
.pointer("/params/changedFields")
|
||||
.and_then(Value::as_array),
|
||||
"planning",
|
||||
),
|
||||
"planning update should report the planning changed field",
|
||||
)?;
|
||||
|
||||
let planning_notification_start = client.notification_len();
|
||||
let ready_view = client.send(
|
||||
"thread/epiphany/view",
|
||||
Some(json!({"threadId": thread_id, "lenses": ["planning"]})),
|
||||
true,
|
||||
)?;
|
||||
let ready_response = ready_view
|
||||
.get("planning")
|
||||
.ok_or_else(|| anyhow!("view response missing planning lens"))?;
|
||||
assert_ready_planning(&ready_response)?;
|
||||
client.require_no_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
planning_notification_start,
|
||||
Duration::from_secs(1),
|
||||
)?;
|
||||
|
||||
let final_read = client.send(
|
||||
"thread/read",
|
||||
Some(json!({"threadId": thread_id, "includeTurns": false})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
final_read
|
||||
.pointer("/thread/epiphanyState/revision")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(1),
|
||||
"planning reflection should not mutate state revision",
|
||||
)?;
|
||||
|
||||
let result = json!({
|
||||
"threadId": thread_id,
|
||||
"codexHome": codex_home,
|
||||
"missingStateStatus": missing_response["stateStatus"],
|
||||
"readyStateStatus": ready_response["stateStatus"],
|
||||
"readyRevision": ready_response["stateRevision"],
|
||||
"captureCount": ready_response["summary"]["captureCount"],
|
||||
"githubIssueCaptureCount": ready_response["summary"]["githubIssueCaptureCount"],
|
||||
"backlogItemCount": ready_response["summary"]["backlogItemCount"],
|
||||
"objectiveDraftCount": ready_response["summary"]["objectiveDraftCount"],
|
||||
"planningNotificationCount": client.notification_count(
|
||||
"thread/epiphany/stateUpdated",
|
||||
planning_notification_start,
|
||||
),
|
||||
"finalReadRevision": final_read["thread"]["epiphanyState"]["revision"],
|
||||
});
|
||||
write_json(&result_path, &result)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn planning_patch(root: &Path) -> Value {
|
||||
let github_source = json!({
|
||||
"kind": "github_issue",
|
||||
"provider": "github",
|
||||
"repo": "GameCult/EpiphanyAgent",
|
||||
"issue_number": 42,
|
||||
"url": "https://github.com/GameCult/EpiphanyAgent/issues/42",
|
||||
"state": "open",
|
||||
"labels": ["planning", "mvp"],
|
||||
"assignees": ["Meta"],
|
||||
"author": "Meta",
|
||||
"created_at": "2026-05-01T10:00:00Z",
|
||||
"updated_at": "2026-05-01T10:05:00Z",
|
||||
"imported_at": "2026-05-01T10:10:00Z"
|
||||
});
|
||||
let chat_source = json!({
|
||||
"kind": "chat",
|
||||
"uri": "codex://threads/planning-smoke",
|
||||
"external_id": "turn-planning-smoke"
|
||||
});
|
||||
json!({
|
||||
"objective": "Keep planning state reviewable before adopting an implementation objective.",
|
||||
"planning": {
|
||||
"workspace_root": root,
|
||||
"captures": [
|
||||
{
|
||||
"id": "capture-github-planning",
|
||||
"title": "Import GitHub issue into planning inbox",
|
||||
"body": "Issues should become planning captures before becoming objectives.",
|
||||
"confidence": "observed",
|
||||
"status": "inbox",
|
||||
"speaker": "human",
|
||||
"tags": ["github", "backlog"],
|
||||
"source": github_source.clone(),
|
||||
"created_at": "2026-05-01T10:10:00Z",
|
||||
"updated_at": "2026-05-01T10:10:00Z"
|
||||
},
|
||||
{
|
||||
"id": "capture-chat-planning",
|
||||
"title": "Draft objectives from chat only after bounding them",
|
||||
"body": "User discussion remains planning material until it becomes a firm artifact.",
|
||||
"confidence": "medium",
|
||||
"status": "triaged",
|
||||
"speaker": "human",
|
||||
"tags": ["chat", "objective"],
|
||||
"source": chat_source.clone(),
|
||||
"created_at": "2026-05-01T10:12:00Z",
|
||||
"updated_at": "2026-05-01T10:12:00Z"
|
||||
}
|
||||
],
|
||||
"backlog_items": [{
|
||||
"id": "backlog-planning-dashboard",
|
||||
"title": "Expose planning state in the Epiphany dashboard",
|
||||
"kind": "feature",
|
||||
"summary": "Show captures, backlog, roadmap streams, and objective drafts.",
|
||||
"status": "ready",
|
||||
"horizon": "near",
|
||||
"priority": {
|
||||
"value": "high",
|
||||
"rationale": "Planning needs a visible user review loop before automation.",
|
||||
"impact": "user-steering",
|
||||
"urgency": "soon",
|
||||
"confidence": "medium",
|
||||
"effort": "small",
|
||||
"unblocks": ["objective-adoption"]
|
||||
},
|
||||
"confidence": "medium",
|
||||
"product_area": "epiphany-gui",
|
||||
"lane_hints": ["imagination", "hands", "soul"],
|
||||
"acceptance_sketch": ["Dashboard can render typed planning records."],
|
||||
"source_refs": [github_source, chat_source],
|
||||
"updated_at": "2026-05-01T10:15:00Z"
|
||||
}],
|
||||
"roadmap_streams": [{
|
||||
"id": "stream-user-steering",
|
||||
"title": "Human-steered planning",
|
||||
"purpose": "Keep future work visible and bounded before implementation starts.",
|
||||
"status": "active",
|
||||
"item_ids": ["backlog-planning-dashboard"],
|
||||
"near_term_focus": "backlog-planning-dashboard",
|
||||
"review_cadence": "per objective"
|
||||
}],
|
||||
"objective_drafts": [{
|
||||
"id": "draft-planning-dashboard",
|
||||
"title": "Build the planning dashboard slice",
|
||||
"summary": "Render planning records and let the user adopt one bounded objective.",
|
||||
"source_item_ids": ["backlog-planning-dashboard"],
|
||||
"scope": {
|
||||
"includes": ["read-only planning projection", "objective draft review"],
|
||||
"excludes": ["automatic objective adoption"]
|
||||
},
|
||||
"acceptance_criteria": [
|
||||
"Planning records render without changing thread state.",
|
||||
"A draft objective remains review-gated."
|
||||
],
|
||||
"evidence_required": ["live smoke", "GUI screenshot"],
|
||||
"lane_plan": {
|
||||
"imagination": "organize planning records into a bounded objective candidate",
|
||||
"eyes": "check prior art and GitHub issue metadata shape",
|
||||
"hands": "wire dashboard controls after the read-only surface is stable",
|
||||
"soul": "verify no planning record silently becomes active objective"
|
||||
},
|
||||
"risks": ["accidental objective adoption"],
|
||||
"review_gates": ["human adoption"],
|
||||
"status": "draft"
|
||||
}]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_missing_planning(response: &Value) -> Result<()> {
|
||||
require(
|
||||
response.get("source").and_then(Value::as_str) == Some("live"),
|
||||
"missing planning should report live source",
|
||||
)?;
|
||||
require(
|
||||
response.get("stateStatus").and_then(Value::as_str) == Some("missing"),
|
||||
"missing planning should report missing state",
|
||||
)?;
|
||||
require(
|
||||
response.get("stateRevision").is_none(),
|
||||
"missing planning should not invent a revision",
|
||||
)?;
|
||||
require(
|
||||
response.get("planning") == Some(&json!({})),
|
||||
"missing planning should return empty planning",
|
||||
)?;
|
||||
let summary = &response["summary"];
|
||||
require(
|
||||
summary.get("captureCount").and_then(Value::as_u64) == Some(0),
|
||||
"missing planning should not invent captures",
|
||||
)?;
|
||||
require(
|
||||
summary.get("backlogItemCount").and_then(Value::as_u64) == Some(0),
|
||||
"missing planning should not invent backlog",
|
||||
)?;
|
||||
require(
|
||||
summary.get("activeObjective").is_none(),
|
||||
"missing planning should not invent objective",
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_ready_planning(response: &Value) -> Result<()> {
|
||||
require(
|
||||
response.get("source").and_then(Value::as_str) == Some("live"),
|
||||
"ready planning should report live source",
|
||||
)?;
|
||||
require(
|
||||
response.get("stateStatus").and_then(Value::as_str) == Some("ready"),
|
||||
"ready planning should report ready state",
|
||||
)?;
|
||||
require(
|
||||
response.get("stateRevision").and_then(Value::as_u64) == Some(1),
|
||||
"ready planning should expose current revision",
|
||||
)?;
|
||||
let planning = &response["planning"];
|
||||
require(
|
||||
ids_eq(
|
||||
planning.get("captures").and_then(Value::as_array),
|
||||
&["capture-github-planning", "capture-chat-planning"],
|
||||
),
|
||||
"planning should preserve captures",
|
||||
)?;
|
||||
require(
|
||||
planning
|
||||
.pointer("/captures/0/source/kind")
|
||||
.and_then(Value::as_str)
|
||||
== Some("github_issue"),
|
||||
"planning should preserve GitHub issue source kind",
|
||||
)?;
|
||||
require(
|
||||
planning
|
||||
.pointer("/captures/0/source/issue_number")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(42),
|
||||
"planning should preserve GitHub issue number",
|
||||
)?;
|
||||
require(
|
||||
ids_eq(
|
||||
planning.get("backlog_items").and_then(Value::as_array),
|
||||
&["backlog-planning-dashboard"],
|
||||
),
|
||||
"planning should preserve backlog items",
|
||||
)?;
|
||||
require(
|
||||
planning
|
||||
.pointer("/roadmap_streams/0/near_term_focus")
|
||||
.and_then(Value::as_str)
|
||||
== Some("backlog-planning-dashboard"),
|
||||
"planning should preserve roadmap near-term focus",
|
||||
)?;
|
||||
require(
|
||||
planning
|
||||
.pointer("/objective_drafts/0/status")
|
||||
.and_then(Value::as_str)
|
||||
== Some("draft"),
|
||||
"planning should preserve draft objective state",
|
||||
)?;
|
||||
let summary = &response["summary"];
|
||||
require(
|
||||
summary.get("captureCount").and_then(Value::as_u64) == Some(2),
|
||||
"planning summary should count captures",
|
||||
)?;
|
||||
require(
|
||||
summary.get("pendingCaptureCount").and_then(Value::as_u64) == Some(1),
|
||||
"planning summary should count inbox captures",
|
||||
)?;
|
||||
require(
|
||||
summary
|
||||
.get("githubIssueCaptureCount")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(1),
|
||||
"planning summary should count GitHub captures",
|
||||
)?;
|
||||
require(
|
||||
summary.get("backlogItemCount").and_then(Value::as_u64) == Some(1),
|
||||
"planning summary should count backlog",
|
||||
)?;
|
||||
require(
|
||||
summary.get("readyBacklogItemCount").and_then(Value::as_u64) == Some(1),
|
||||
"planning summary should count ready backlog",
|
||||
)?;
|
||||
require(
|
||||
summary.get("roadmapStreamCount").and_then(Value::as_u64) == Some(1),
|
||||
"planning summary should count roadmap streams",
|
||||
)?;
|
||||
require(
|
||||
summary.get("objectiveDraftCount").and_then(Value::as_u64) == Some(1),
|
||||
"planning summary should count drafts",
|
||||
)?;
|
||||
require(
|
||||
summary.get("draftObjectiveCount").and_then(Value::as_u64) == Some(1),
|
||||
"planning summary should count draft status",
|
||||
)?;
|
||||
require(
|
||||
summary
|
||||
.get("activeObjective")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|objective| objective.starts_with("Keep planning state reviewable")),
|
||||
"planning summary should expose the active thread objective separately",
|
||||
)?;
|
||||
require(
|
||||
summary
|
||||
.get("note")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|note| note.contains("human explicitly adopts an objective")),
|
||||
"planning summary should remind clients that adoption is explicit",
|
||||
)
|
||||
}
|
||||
|
||||
fn ids_eq(values: Option<&Vec<Value>>, expected: &[&str]) -> bool {
|
||||
values.is_some_and(|values| {
|
||||
values.len() == expected.len()
|
||||
&& values
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(value, expected)| value.get("id").and_then(Value::as_str) == Some(*expected))
|
||||
})
|
||||
}
|
||||
|
||||
fn string_array_contains(values: Option<&Vec<Value>>, expected: &str) -> bool {
|
||||
values.is_some_and(|values| values.iter().any(|value| value.as_str() == Some(expected)))
|
||||
}
|
||||
|
||||
fn reset_smoke_paths(root: &Path, paths: &[PathBuf]) -> Result<()> {
|
||||
let smoke_root = root.join(".epiphany-smoke").canonicalize().or_else(|_| {
|
||||
let smoke_root = root.join(".epiphany-smoke");
|
||||
fs::create_dir_all(&smoke_root)?;
|
||||
smoke_root.canonicalize()
|
||||
})?;
|
||||
for path in paths {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let resolved = path.canonicalize()?;
|
||||
if resolved == smoke_root || !resolved.starts_with(&smoke_root) {
|
||||
return Err(anyhow!(
|
||||
"refusing to delete non-smoke path: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(path)?;
|
||||
} else {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require(condition: bool, message: &str) -> Result<()> {
|
||||
if condition {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("{message}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn take_path(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
args.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
241
epiphany-core/src/bin/epiphany-phase6-pressure-smoke.rs
Normal file
241
epiphany-core/src/bin/epiphany-phase6-pressure-smoke.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[path = "epiphany-mvp-status.rs"]
|
||||
mod status_cli;
|
||||
|
||||
const DEFAULT_APP_SERVER: &str = r"C:\Users\Meta\.cargo-target-codex\debug\codex-app-server.exe";
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
let result = run_smoke(&args)?;
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
app_server: PathBuf,
|
||||
codex_home: PathBuf,
|
||||
result: PathBuf,
|
||||
transcript: PathBuf,
|
||||
stderr: PathBuf,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let mut parsed = Self {
|
||||
app_server: PathBuf::from(DEFAULT_APP_SERVER),
|
||||
codex_home: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-pressure-codex-home"),
|
||||
result: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-pressure-smoke-result.json"),
|
||||
transcript: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-pressure-smoke-transcript.jsonl"),
|
||||
stderr: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-pressure-smoke-server.stderr.log"),
|
||||
};
|
||||
let mut args = env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--app-server" => parsed.app_server = take_path(&mut args, "--app-server")?,
|
||||
"--codex-home" => parsed.codex_home = take_path(&mut args, "--codex-home")?,
|
||||
"--result" => parsed.result = take_path(&mut args, "--result")?,
|
||||
"--transcript" => parsed.transcript = take_path(&mut args, "--transcript")?,
|
||||
"--stderr" => parsed.stderr = take_path(&mut args, "--stderr")?,
|
||||
other => return Err(anyhow!("unknown argument: {other}")),
|
||||
}
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_smoke(args: &Args) -> Result<Value> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let app_server = status_cli::absolute_path(&args.app_server)?;
|
||||
if !app_server.exists() {
|
||||
return Err(anyhow!(
|
||||
"codex app-server binary not found: {}",
|
||||
app_server.display()
|
||||
));
|
||||
}
|
||||
let codex_home = status_cli::absolute_path(&args.codex_home)?;
|
||||
let result_path = status_cli::absolute_path(&args.result)?;
|
||||
let transcript_path = status_cli::absolute_path(&args.transcript)?;
|
||||
let stderr_path = status_cli::absolute_path(&args.stderr)?;
|
||||
reset_smoke_paths(
|
||||
&root,
|
||||
&[
|
||||
codex_home.clone(),
|
||||
result_path.clone(),
|
||||
transcript_path.clone(),
|
||||
stderr_path.clone(),
|
||||
],
|
||||
)?;
|
||||
fs::create_dir_all(&codex_home)
|
||||
.with_context(|| format!("failed to create {}", codex_home.display()))?;
|
||||
|
||||
let mut client = status_cli::AppServerClient::start(
|
||||
&app_server,
|
||||
&codex_home,
|
||||
&transcript_path,
|
||||
&stderr_path,
|
||||
)?;
|
||||
client.send(
|
||||
"initialize",
|
||||
Some(json!({
|
||||
"clientInfo": {
|
||||
"name": "epiphany-phase6-pressure-smoke",
|
||||
"title": "Epiphany Phase 6 Pressure Smoke",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
"capabilities": {"experimentalApi": true},
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
client.send("initialized", None, false)?;
|
||||
let started = client.send(
|
||||
"thread/start",
|
||||
Some(json!({"cwd": root.join("epiphany-core"), "ephemeral": true})),
|
||||
true,
|
||||
)?;
|
||||
let thread_id = started
|
||||
.pointer("/thread/id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("thread/start response missing thread.id"))?
|
||||
.to_string();
|
||||
|
||||
let notification_start = client.notification_len();
|
||||
let response = client.send(
|
||||
"thread/epiphany/view",
|
||||
Some(json!({"threadId": thread_id, "lenses": ["pressure"]})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
response.get("threadId").and_then(Value::as_str) == Some(&thread_id),
|
||||
"pressure response should echo thread id",
|
||||
)?;
|
||||
assert_unknown_pressure(&response)?;
|
||||
client.require_no_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
notification_start,
|
||||
Duration::from_secs(1),
|
||||
)?;
|
||||
|
||||
let final_read = client.send(
|
||||
"thread/read",
|
||||
Some(json!({"threadId": thread_id, "includeTurns": false})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
final_read.pointer("/thread/epiphanyState").is_none(),
|
||||
"pressure reflection should not create Epiphany state",
|
||||
)?;
|
||||
|
||||
let result = json!({
|
||||
"threadId": thread_id,
|
||||
"codexHome": codex_home,
|
||||
"source": "thread/epiphany/view",
|
||||
"status": response["pressure"]["status"],
|
||||
"level": response["pressure"]["level"],
|
||||
"basis": response["pressure"]["basis"],
|
||||
"shouldPrepareCompaction": response["pressure"]["shouldPrepareCompaction"],
|
||||
"stateUpdatedNotificationCount": client.notification_count(
|
||||
"thread/epiphany/stateUpdated",
|
||||
notification_start,
|
||||
),
|
||||
"finalReadHasEpiphanyState": final_read.pointer("/thread/epiphanyState").is_some(),
|
||||
});
|
||||
write_json(&result_path, &result)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn assert_unknown_pressure(response: &Value) -> Result<()> {
|
||||
let pressure = response
|
||||
.get("pressure")
|
||||
.ok_or_else(|| anyhow!("pressure response missing pressure object"))?;
|
||||
require(
|
||||
pressure.get("status").and_then(Value::as_str) == Some("unknown"),
|
||||
"fresh pressure should be unknown",
|
||||
)?;
|
||||
require(
|
||||
pressure.get("level").and_then(Value::as_str) == Some("unknown"),
|
||||
"fresh pressure level should be unknown",
|
||||
)?;
|
||||
require(
|
||||
pressure.get("basis").and_then(Value::as_str) == Some("unknown"),
|
||||
"fresh pressure basis should be unknown",
|
||||
)?;
|
||||
require(
|
||||
pressure
|
||||
.get("shouldPrepareCompaction")
|
||||
.and_then(Value::as_bool)
|
||||
== Some(false),
|
||||
"unknown pressure must not recommend compaction prep",
|
||||
)?;
|
||||
require(
|
||||
pressure.get("usedTokens").is_none(),
|
||||
"fresh pressure should not invent token usage",
|
||||
)
|
||||
}
|
||||
|
||||
fn reset_smoke_paths(root: &Path, paths: &[PathBuf]) -> Result<()> {
|
||||
let smoke_root = root.join(".epiphany-smoke").canonicalize().or_else(|_| {
|
||||
let smoke_root = root.join(".epiphany-smoke");
|
||||
fs::create_dir_all(&smoke_root)?;
|
||||
smoke_root.canonicalize()
|
||||
})?;
|
||||
for path in paths {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let resolved = path.canonicalize()?;
|
||||
if resolved == smoke_root || !resolved.starts_with(&smoke_root) {
|
||||
return Err(anyhow!(
|
||||
"refusing to delete non-smoke path: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(path)?;
|
||||
} else {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require(condition: bool, message: &str) -> Result<()> {
|
||||
if condition {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("{message}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn take_path(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
args.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
498
epiphany-core/src/bin/epiphany-phase6-reorient-smoke.rs
Normal file
498
epiphany-core/src/bin/epiphany-phase6-reorient-smoke.rs
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[path = "epiphany-mvp-status.rs"]
|
||||
mod status_cli;
|
||||
|
||||
const DEFAULT_APP_SERVER: &str = r"C:\Users\Meta\.cargo-target-codex\debug\codex-app-server.exe";
|
||||
const WATCHED_RELATIVE_PATH: &str = "src/reorient_target.rs";
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
let result = run_smoke(&args)?;
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
app_server: PathBuf,
|
||||
codex_home: PathBuf,
|
||||
workspace: PathBuf,
|
||||
result: PathBuf,
|
||||
transcript: PathBuf,
|
||||
stderr: PathBuf,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let mut parsed = Self {
|
||||
app_server: PathBuf::from(DEFAULT_APP_SERVER),
|
||||
codex_home: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-reorient-codex-home"),
|
||||
workspace: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-reorient-workspace"),
|
||||
result: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-reorient-smoke-result.json"),
|
||||
transcript: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-reorient-smoke-transcript.jsonl"),
|
||||
stderr: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-reorient-smoke-server.stderr.log"),
|
||||
};
|
||||
let mut args = env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--app-server" => parsed.app_server = take_path(&mut args, "--app-server")?,
|
||||
"--codex-home" => parsed.codex_home = take_path(&mut args, "--codex-home")?,
|
||||
"--workspace" => parsed.workspace = take_path(&mut args, "--workspace")?,
|
||||
"--result" => parsed.result = take_path(&mut args, "--result")?,
|
||||
"--transcript" => parsed.transcript = take_path(&mut args, "--transcript")?,
|
||||
"--stderr" => parsed.stderr = take_path(&mut args, "--stderr")?,
|
||||
other => return Err(anyhow!("unknown argument: {other}")),
|
||||
}
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_smoke(args: &Args) -> Result<Value> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let app_server = status_cli::absolute_path(&args.app_server)?;
|
||||
if !app_server.exists() {
|
||||
return Err(anyhow!(
|
||||
"codex app-server binary not found: {}",
|
||||
app_server.display()
|
||||
));
|
||||
}
|
||||
let codex_home = status_cli::absolute_path(&args.codex_home)?;
|
||||
let workspace = status_cli::absolute_path(&args.workspace)?;
|
||||
let result_path = status_cli::absolute_path(&args.result)?;
|
||||
let transcript_path = status_cli::absolute_path(&args.transcript)?;
|
||||
let stderr_path = status_cli::absolute_path(&args.stderr)?;
|
||||
reset_smoke_paths(
|
||||
&root,
|
||||
&[
|
||||
codex_home.clone(),
|
||||
workspace.clone(),
|
||||
result_path.clone(),
|
||||
transcript_path.clone(),
|
||||
stderr_path.clone(),
|
||||
],
|
||||
)?;
|
||||
fs::create_dir_all(&codex_home)
|
||||
.with_context(|| format!("failed to create {}", codex_home.display()))?;
|
||||
let watched_file = prepare_workspace(&workspace)?;
|
||||
|
||||
let mut client = status_cli::AppServerClient::start(
|
||||
&app_server,
|
||||
&codex_home,
|
||||
&transcript_path,
|
||||
&stderr_path,
|
||||
)?;
|
||||
client.send(
|
||||
"initialize",
|
||||
Some(json!({
|
||||
"clientInfo": {
|
||||
"name": "epiphany-phase6-reorient-smoke",
|
||||
"title": "Epiphany Phase 6 Reorient Smoke",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
"capabilities": {"experimentalApi": true},
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
client.send("initialized", None, false)?;
|
||||
let started = client.send(
|
||||
"thread/start",
|
||||
Some(json!({"cwd": workspace, "ephemeral": true})),
|
||||
true,
|
||||
)?;
|
||||
let thread_id = started
|
||||
.pointer("/thread/id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("thread/start response missing thread.id"))?
|
||||
.to_string();
|
||||
|
||||
let missing_notification_start = client.notification_len();
|
||||
let missing_view = client.send(
|
||||
"thread/epiphany/view",
|
||||
Some(json!({"threadId": thread_id, "lenses": ["reorient"]})),
|
||||
true,
|
||||
)?;
|
||||
let missing_response = missing_view
|
||||
.get("reorient")
|
||||
.ok_or_else(|| anyhow!("view response missing reorient lens"))?;
|
||||
assert_missing_reorient(&missing_response)?;
|
||||
client.require_no_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
missing_notification_start,
|
||||
Duration::from_secs(1),
|
||||
)?;
|
||||
|
||||
let update_notification_start = client.notification_len();
|
||||
let update = client.send(
|
||||
"thread/epiphany/update",
|
||||
Some(json!({
|
||||
"threadId": thread_id,
|
||||
"expectedRevision": 0,
|
||||
"patch": reorient_patch(),
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
update.get("revision").and_then(Value::as_u64) == Some(1),
|
||||
"reorient smoke patch should advance revision to 1",
|
||||
)?;
|
||||
client.wait_for_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
update_notification_start,
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
|
||||
let ready_notification_start = client.notification_len();
|
||||
let ready_view = client.send(
|
||||
"thread/epiphany/view",
|
||||
Some(json!({"threadId": thread_id, "lenses": ["reorient"]})),
|
||||
true,
|
||||
)?;
|
||||
let ready_response = ready_view
|
||||
.get("reorient")
|
||||
.ok_or_else(|| anyhow!("view response missing reorient lens"))?;
|
||||
assert_ready_reorient(&ready_response)?;
|
||||
client.require_no_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
ready_notification_start,
|
||||
Duration::from_secs(1),
|
||||
)?;
|
||||
|
||||
fs::write(
|
||||
watched_file,
|
||||
"pub fn reorient_target() -> &'static str {\n \"after\"\n}\n",
|
||||
)?;
|
||||
|
||||
let regather_notification_start = client.notification_len();
|
||||
let regather_response = wait_for_regather_reorient(&mut client, &thread_id)?;
|
||||
assert_regather_reorient(®ather_response)?;
|
||||
client.require_no_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
regather_notification_start,
|
||||
Duration::from_secs(1),
|
||||
)?;
|
||||
|
||||
let final_read = client.send(
|
||||
"thread/read",
|
||||
Some(json!({"threadId": thread_id, "includeTurns": false})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
final_read
|
||||
.pointer("/thread/epiphanyState/revision")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(1),
|
||||
"reorient reflection should not mutate durable state",
|
||||
)?;
|
||||
|
||||
let result = json!({
|
||||
"threadId": thread_id,
|
||||
"codexHome": codex_home,
|
||||
"workspace": workspace,
|
||||
"missingAction": missing_response["decision"]["action"],
|
||||
"readyAction": ready_response["decision"]["action"],
|
||||
"regatherAction": regather_response["decision"]["action"],
|
||||
"regatherReasons": regather_response["decision"]["reasons"],
|
||||
"checkpointChangedPaths": regather_response["decision"]["checkpointChangedPaths"],
|
||||
"activeFrontierNodeIds": regather_response["decision"]["activeFrontierNodeIds"],
|
||||
"stateUpdatedNotificationCount": client.notification_count(
|
||||
"thread/epiphany/stateUpdated",
|
||||
regather_notification_start,
|
||||
),
|
||||
"finalReadRevision": final_read["thread"]["epiphanyState"]["revision"],
|
||||
});
|
||||
write_json(&result_path, &result)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn prepare_workspace(workspace: &Path) -> Result<PathBuf> {
|
||||
fs::create_dir_all(workspace)?;
|
||||
let watched_file = workspace.join(WATCHED_RELATIVE_PATH);
|
||||
fs::create_dir_all(watched_file.parent().expect("watched path has parent"))?;
|
||||
fs::write(
|
||||
&watched_file,
|
||||
"pub fn reorient_target() -> &'static str {\n \"before\"\n}\n",
|
||||
)?;
|
||||
Ok(watched_file)
|
||||
}
|
||||
|
||||
fn reorient_patch() -> Value {
|
||||
let code_ref = json!({
|
||||
"path": WATCHED_RELATIVE_PATH,
|
||||
"start_line": 1,
|
||||
"end_line": 3,
|
||||
"symbol": "reorient_target"
|
||||
});
|
||||
json!({
|
||||
"objective": "Decide whether a durable checkpoint still deserves to be resumed after rehydrate.",
|
||||
"activeSubgoalId": "phase6-reorient-smoke",
|
||||
"subgoals": [{
|
||||
"id": "phase6-reorient-smoke",
|
||||
"title": "Live-smoke CRRC reorientation policy",
|
||||
"status": "active",
|
||||
"summary": "Resume when the checkpoint is still aligned; regather when the touched file proves it isn't."
|
||||
}],
|
||||
"graphs": {
|
||||
"architecture": {
|
||||
"nodes": [{
|
||||
"id": "reorient-target",
|
||||
"title": "Reorient target",
|
||||
"purpose": "Map the file the watcher will touch so reorientation can notice drift.",
|
||||
"code_refs": [code_ref.clone()]
|
||||
}]
|
||||
},
|
||||
"dataflow": {"nodes": []},
|
||||
"links": []
|
||||
},
|
||||
"graphFrontier": {
|
||||
"active_node_ids": ["reorient-target"],
|
||||
"dirty_paths": []
|
||||
},
|
||||
"graphCheckpoint": {
|
||||
"checkpoint_id": "ck-reorient-1",
|
||||
"graph_revision": 1,
|
||||
"summary": "Reorientation smoke graph checkpoint",
|
||||
"frontier_node_ids": ["reorient-target"]
|
||||
},
|
||||
"investigationCheckpoint": {
|
||||
"checkpoint_id": "ix-reorient-1",
|
||||
"kind": "source_gathering",
|
||||
"disposition": "resume_ready",
|
||||
"focus": "Verify the touched file before broad edits.",
|
||||
"summary": "This checkpoint should remain resumable until the watched source moves.",
|
||||
"next_action": "Resume the bounded slice if the watched source still matches the checkpoint.",
|
||||
"captured_at_turn_id": "turn-phase6-reorient",
|
||||
"code_refs": [code_ref]
|
||||
},
|
||||
"churn": {
|
||||
"understanding_status": "ready",
|
||||
"diff_pressure": "low",
|
||||
"graph_freshness": "fresh",
|
||||
"unexplained_writes": 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_missing_reorient(response: &Value) -> Result<()> {
|
||||
require(
|
||||
response.get("source").and_then(Value::as_str) == Some("live"),
|
||||
"missing reorient response should report live source",
|
||||
)?;
|
||||
require(
|
||||
response.get("stateStatus").and_then(Value::as_str) == Some("missing"),
|
||||
"missing reorient response should report missing state",
|
||||
)?;
|
||||
let decision = &response["decision"];
|
||||
require(
|
||||
decision.get("action").and_then(Value::as_str) == Some("regather"),
|
||||
"missing reorient response should regather",
|
||||
)?;
|
||||
require(
|
||||
decision.get("checkpointStatus").and_then(Value::as_str) == Some("missing"),
|
||||
"missing reorient response should report missing checkpoint",
|
||||
)?;
|
||||
require(
|
||||
string_array_eq(
|
||||
decision.get("reasons").and_then(Value::as_array),
|
||||
&["missingState", "missingCheckpoint"],
|
||||
),
|
||||
"missing reorient response should explain missing state and checkpoint",
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_ready_reorient(response: &Value) -> Result<()> {
|
||||
require(
|
||||
response.get("source").and_then(Value::as_str) == Some("live"),
|
||||
"ready reorient response should report live source",
|
||||
)?;
|
||||
require(
|
||||
response.get("stateStatus").and_then(Value::as_str) == Some("ready"),
|
||||
"ready reorient response should report ready state",
|
||||
)?;
|
||||
require(
|
||||
response.get("stateRevision").and_then(Value::as_u64) == Some(1),
|
||||
"ready reorient response should preserve revision identity",
|
||||
)?;
|
||||
let decision = &response["decision"];
|
||||
require(
|
||||
decision.get("action").and_then(Value::as_str) == Some("resume"),
|
||||
"clean checkpoint should remain resumable",
|
||||
)?;
|
||||
require(
|
||||
decision.get("checkpointStatus").and_then(Value::as_str) == Some("resumeReady"),
|
||||
"ready reorient response should report resume-ready checkpoint status",
|
||||
)?;
|
||||
require(
|
||||
string_array_eq(
|
||||
decision.get("reasons").and_then(Value::as_array),
|
||||
&["checkpointReady"],
|
||||
),
|
||||
"ready reorient response should explain that the checkpoint is still aligned",
|
||||
)?;
|
||||
require(
|
||||
decision.get("checkpointId").and_then(Value::as_str) == Some("ix-reorient-1"),
|
||||
"ready reorient response should expose the checkpoint id",
|
||||
)?;
|
||||
require(
|
||||
decision.get("nextAction").and_then(Value::as_str)
|
||||
== Some("Resume the bounded slice if the watched source still matches the checkpoint."),
|
||||
"ready reorient response should preserve checkpoint next action",
|
||||
)?;
|
||||
require(
|
||||
decision.get("watcherStatus").and_then(Value::as_str) == Some("clean"),
|
||||
"ready reorient response should report a clean watcher before drift",
|
||||
)
|
||||
}
|
||||
|
||||
fn wait_for_regather_reorient(
|
||||
client: &mut status_cli::AppServerClient,
|
||||
thread_id: &str,
|
||||
) -> Result<Value> {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let mut last_response = Value::Null;
|
||||
while Instant::now() < deadline {
|
||||
let response = client.send(
|
||||
"thread/epiphany/view",
|
||||
Some(json!({"threadId": thread_id, "lenses": ["reorient"]})),
|
||||
true,
|
||||
)?;
|
||||
let reorient = response
|
||||
.get("reorient")
|
||||
.ok_or_else(|| anyhow!("view response missing reorient lens"))?;
|
||||
if reorient.pointer("/decision/action").and_then(Value::as_str) == Some("regather") {
|
||||
return Ok(reorient.clone());
|
||||
}
|
||||
last_response = response;
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
Err(anyhow!(
|
||||
"reorientation policy did not switch to regather before timeout; last response: {last_response}"
|
||||
))
|
||||
}
|
||||
|
||||
fn assert_regather_reorient(response: &Value) -> Result<()> {
|
||||
let decision = &response["decision"];
|
||||
require(
|
||||
decision.get("action").and_then(Value::as_str) == Some("regather"),
|
||||
"touched checkpoint path should force regather",
|
||||
)?;
|
||||
require(
|
||||
decision.get("checkpointStatus").and_then(Value::as_str) == Some("resumeReady"),
|
||||
"regather response should preserve the underlying checkpoint disposition",
|
||||
)?;
|
||||
require(
|
||||
string_array_eq(
|
||||
decision.get("reasons").and_then(Value::as_array),
|
||||
&["checkpointPathsChanged", "frontierChanged"],
|
||||
),
|
||||
"regather response should explain watcher path and frontier drift",
|
||||
)?;
|
||||
let changed_paths = decision
|
||||
.get("checkpointChangedPaths")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| anyhow!("regather response missing changed paths"))?;
|
||||
require(
|
||||
changed_paths
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(|path| path.replace('\\', "/"))
|
||||
.any(|path| path == WATCHED_RELATIVE_PATH),
|
||||
"regather response should report the changed checkpoint path",
|
||||
)?;
|
||||
require(
|
||||
string_array_eq(
|
||||
decision
|
||||
.get("activeFrontierNodeIds")
|
||||
.and_then(Value::as_array),
|
||||
&["reorient-target"],
|
||||
),
|
||||
"regather response should report touched frontier nodes",
|
||||
)?;
|
||||
require(
|
||||
decision
|
||||
.get("note")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|note| note.starts_with("Re-gather before editing:")),
|
||||
"regather response should explain why the checkpoint is no longer safe to resume",
|
||||
)
|
||||
}
|
||||
|
||||
fn string_array_eq(values: Option<&Vec<Value>>, expected: &[&str]) -> bool {
|
||||
values.is_some_and(|values| {
|
||||
values.len() == expected.len()
|
||||
&& values
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(value, expected)| value.as_str() == Some(*expected))
|
||||
})
|
||||
}
|
||||
|
||||
fn reset_smoke_paths(root: &Path, paths: &[PathBuf]) -> Result<()> {
|
||||
let smoke_root = root.join(".epiphany-smoke").canonicalize().or_else(|_| {
|
||||
let smoke_root = root.join(".epiphany-smoke");
|
||||
fs::create_dir_all(&smoke_root)?;
|
||||
smoke_root.canonicalize()
|
||||
})?;
|
||||
for path in paths {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let resolved = path.canonicalize()?;
|
||||
if resolved == smoke_root || !resolved.starts_with(&smoke_root) {
|
||||
return Err(anyhow!(
|
||||
"refusing to delete non-smoke path: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(path)?;
|
||||
} else {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require(condition: bool, message: &str) -> Result<()> {
|
||||
if condition {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("{message}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn take_path(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
args.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
646
epiphany-core/src/bin/epiphany-phase6-scene-smoke.rs
Normal file
646
epiphany-core/src/bin/epiphany-phase6-scene-smoke.rs
Normal file
|
|
@ -0,0 +1,646 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[path = "epiphany-mvp-status.rs"]
|
||||
mod status_cli;
|
||||
|
||||
const DEFAULT_APP_SERVER: &str = r"C:\Users\Meta\.cargo-target-codex\debug\codex-app-server.exe";
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
let result = run_smoke(&args)?;
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
app_server: PathBuf,
|
||||
codex_home: PathBuf,
|
||||
result: PathBuf,
|
||||
transcript: PathBuf,
|
||||
stderr: PathBuf,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let mut parsed = Self {
|
||||
app_server: PathBuf::from(DEFAULT_APP_SERVER),
|
||||
codex_home: root.join(".epiphany-smoke").join("phase6-scene-codex-home"),
|
||||
result: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-scene-smoke-result.json"),
|
||||
transcript: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-scene-smoke-transcript.jsonl"),
|
||||
stderr: root
|
||||
.join(".epiphany-smoke")
|
||||
.join("phase6-scene-smoke-server.stderr.log"),
|
||||
};
|
||||
let mut args = env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--app-server" => parsed.app_server = take_path(&mut args, "--app-server")?,
|
||||
"--codex-home" => parsed.codex_home = take_path(&mut args, "--codex-home")?,
|
||||
"--result" => parsed.result = take_path(&mut args, "--result")?,
|
||||
"--transcript" => parsed.transcript = take_path(&mut args, "--transcript")?,
|
||||
"--stderr" => parsed.stderr = take_path(&mut args, "--stderr")?,
|
||||
other => return Err(anyhow!("unknown argument: {other}")),
|
||||
}
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_smoke(args: &Args) -> Result<Value> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let app_server = status_cli::absolute_path(&args.app_server)?;
|
||||
if !app_server.exists() {
|
||||
return Err(anyhow!(
|
||||
"codex app-server binary not found: {}",
|
||||
app_server.display()
|
||||
));
|
||||
}
|
||||
let codex_home = status_cli::absolute_path(&args.codex_home)?;
|
||||
let result_path = status_cli::absolute_path(&args.result)?;
|
||||
let transcript_path = status_cli::absolute_path(&args.transcript)?;
|
||||
let stderr_path = status_cli::absolute_path(&args.stderr)?;
|
||||
reset_smoke_paths(
|
||||
&root,
|
||||
&[
|
||||
codex_home.clone(),
|
||||
result_path.clone(),
|
||||
transcript_path.clone(),
|
||||
stderr_path.clone(),
|
||||
],
|
||||
)?;
|
||||
fs::create_dir_all(&codex_home)
|
||||
.with_context(|| format!("failed to create {}", codex_home.display()))?;
|
||||
|
||||
let mut client = status_cli::AppServerClient::start(
|
||||
&app_server,
|
||||
&codex_home,
|
||||
&transcript_path,
|
||||
&stderr_path,
|
||||
)?;
|
||||
client.send(
|
||||
"initialize",
|
||||
Some(json!({
|
||||
"clientInfo": {
|
||||
"name": "epiphany-phase6-scene-smoke",
|
||||
"title": "Epiphany Phase 6 Scene Smoke",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
"capabilities": {"experimentalApi": true},
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
client.send("initialized", None, false)?;
|
||||
let started = client.send(
|
||||
"thread/start",
|
||||
Some(json!({"cwd": root.join("epiphany-core"), "ephemeral": true})),
|
||||
true,
|
||||
)?;
|
||||
let thread_id = started
|
||||
.pointer("/thread/id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("thread/start response missing thread.id"))?
|
||||
.to_string();
|
||||
|
||||
let missing_response = client.send(
|
||||
"thread/epiphany/view",
|
||||
Some(json!({"threadId": thread_id, "lenses": ["scene"]})),
|
||||
true,
|
||||
)?;
|
||||
assert_missing_scene(&missing_response["scene"])?;
|
||||
|
||||
let update_notification_start = client.notification_len();
|
||||
let update = client.send(
|
||||
"thread/epiphany/update",
|
||||
Some(json!({
|
||||
"threadId": thread_id,
|
||||
"expectedRevision": 0,
|
||||
"patch": initial_scene_patch(),
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
update.get("revision").and_then(Value::as_u64) == Some(1),
|
||||
"initial scene patch should advance revision to 1",
|
||||
)?;
|
||||
let update_notification = client.wait_for_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
update_notification_start,
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
require(
|
||||
update_notification
|
||||
.pointer("/params/revision")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(1),
|
||||
"initial update notification should expose revision 1",
|
||||
)?;
|
||||
|
||||
for index in 2..=6 {
|
||||
let update_notification_start = client.notification_len();
|
||||
let update = client.send(
|
||||
"thread/epiphany/update",
|
||||
Some(json!({
|
||||
"threadId": thread_id,
|
||||
"expectedRevision": index - 1,
|
||||
"patch": {
|
||||
"evidence": [evidence_record(index)],
|
||||
"observations": [observation_record(index)],
|
||||
},
|
||||
})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
update.get("revision").and_then(Value::as_u64) == Some(index),
|
||||
&format!("record update {index} should advance revision to {index}"),
|
||||
)?;
|
||||
let update_notification = client.wait_for_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
update_notification_start as usize,
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
require(
|
||||
update_notification
|
||||
.pointer("/params/revision")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(index),
|
||||
&format!("record update {index} notification should expose revision {index}"),
|
||||
)?;
|
||||
}
|
||||
|
||||
let scene_notification_start = client.notification_len();
|
||||
let ready_response = client.send(
|
||||
"thread/epiphany/view",
|
||||
Some(json!({"threadId": thread_id, "lenses": ["scene"]})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
ready_response.get("threadId").and_then(Value::as_str) == Some(&thread_id),
|
||||
"scene response should echo thread id",
|
||||
)?;
|
||||
assert_ready_scene(&ready_response["scene"], 6)?;
|
||||
client.require_no_notification(
|
||||
"thread/epiphany/stateUpdated",
|
||||
scene_notification_start,
|
||||
Duration::from_secs(1),
|
||||
)?;
|
||||
|
||||
let final_read = client.send(
|
||||
"thread/read",
|
||||
Some(json!({"threadId": thread_id, "includeTurns": false})),
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
final_read
|
||||
.pointer("/thread/epiphanyState/revision")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(6),
|
||||
"scene should not mutate state revision",
|
||||
)?;
|
||||
|
||||
let scene = &ready_response["scene"];
|
||||
let result = json!({
|
||||
"threadId": thread_id,
|
||||
"codexHome": codex_home,
|
||||
"missingStateStatus": missing_response["scene"]["stateStatus"],
|
||||
"missingAvailableActions": missing_response["scene"]["availableActions"],
|
||||
"readyStateStatus": scene["stateStatus"],
|
||||
"readySource": scene["source"],
|
||||
"readyRevision": scene["revision"],
|
||||
"readyAvailableActions": scene["availableActions"],
|
||||
"investigationCheckpointId": scene["investigationCheckpoint"]["checkpointId"],
|
||||
"investigationDisposition": scene["investigationCheckpoint"]["disposition"],
|
||||
"latestObservationIds": ids(&scene["observations"]["latest"]),
|
||||
"latestEvidenceIds": ids(&scene["evidence"]["latest"]),
|
||||
"retrievalStatus": scene["retrieval"]["status"],
|
||||
"sceneNotificationCount": client.notification_count(
|
||||
"thread/epiphany/stateUpdated",
|
||||
scene_notification_start,
|
||||
),
|
||||
"finalReadRevision": final_read["thread"]["epiphanyState"]["revision"],
|
||||
});
|
||||
write_json(&result_path, &result)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn mapper_code_ref() -> Value {
|
||||
json!({
|
||||
"path": "app-server/src/codex_message_processor.rs",
|
||||
"start_line": 10573,
|
||||
"end_line": 10689,
|
||||
"symbol": "map_epiphany_scene"
|
||||
})
|
||||
}
|
||||
|
||||
fn evidence_record(index: u64) -> Value {
|
||||
json!({
|
||||
"id": format!("ev-phase6-scene-{index}"),
|
||||
"kind": "smoke-test",
|
||||
"status": "ok",
|
||||
"summary": format!("Scene smoke evidence {index} should appear newest-first."),
|
||||
"code_refs": [mapper_code_ref()]
|
||||
})
|
||||
}
|
||||
|
||||
fn observation_record(index: u64) -> Value {
|
||||
json!({
|
||||
"id": format!("obs-phase6-scene-{index}"),
|
||||
"summary": format!("Scene smoke observation {index} should appear newest-first."),
|
||||
"source_kind": "smoke",
|
||||
"status": "ok",
|
||||
"code_refs": [mapper_code_ref()],
|
||||
"evidence_ids": [format!("ev-phase6-scene-{index}")]
|
||||
})
|
||||
}
|
||||
|
||||
fn initial_scene_patch() -> Value {
|
||||
json!({
|
||||
"objective": "Expose live Epiphany scene reflection without creating a second source of truth.",
|
||||
"activeSubgoalId": "phase6-scene-smoke",
|
||||
"investigationCheckpoint": {
|
||||
"checkpoint_id": "phase6-scene-investigation",
|
||||
"kind": "slice_planning",
|
||||
"disposition": "resume_ready",
|
||||
"focus": "Bank the active scene-smoke packet before the lights go out.",
|
||||
"summary": "Scene reflection should surface the durable investigation packet.",
|
||||
"next_action": "Patch the remaining smoke coverage and rerun the focused verifier pass.",
|
||||
"captured_at_turn_id": "turn-phase6-scene",
|
||||
"open_questions": ["Should scene reflection compress checkpoint counts or expose full detail?"],
|
||||
"code_refs": [mapper_code_ref()],
|
||||
"evidence_ids": ["ev-phase6-scene-1"]
|
||||
},
|
||||
"subgoals": [
|
||||
{
|
||||
"id": "phase6-scene-smoke",
|
||||
"title": "Live-smoke scene reflection",
|
||||
"status": "active",
|
||||
"summary": "The app-server scene surface should reflect the live typed state."
|
||||
},
|
||||
{
|
||||
"id": "phase6-job-surface",
|
||||
"title": "Design job/progress reflection",
|
||||
"status": "queued",
|
||||
"summary": "The next larger organ after scene smoke."
|
||||
}
|
||||
],
|
||||
"invariants": [
|
||||
{
|
||||
"id": "inv-scene-read-only",
|
||||
"description": "thread/epiphany/view scene lens must not mutate Epiphany state.",
|
||||
"status": "ok"
|
||||
},
|
||||
{
|
||||
"id": "inv-gui-not-source",
|
||||
"description": "Scene projection may reflect state but must not become canonical understanding.",
|
||||
"status": "ok"
|
||||
}
|
||||
],
|
||||
"graphs": {
|
||||
"architecture": {
|
||||
"nodes": [{
|
||||
"id": "scene-projection",
|
||||
"title": "Scene projection",
|
||||
"purpose": "Compress authoritative Epiphany state into a client-readable reflection.",
|
||||
"code_refs": [mapper_code_ref()]
|
||||
}]
|
||||
},
|
||||
"dataflow": {
|
||||
"nodes": [{
|
||||
"id": "typed-state",
|
||||
"title": "Typed Epiphany state",
|
||||
"purpose": "Remain the authoritative source behind scene reflection."
|
||||
}]
|
||||
},
|
||||
"links": [{
|
||||
"dataflow_node_id": "typed-state",
|
||||
"architecture_node_id": "scene-projection",
|
||||
"relationship": "derived-reflection"
|
||||
}]
|
||||
},
|
||||
"graphFrontier": {
|
||||
"active_node_ids": ["scene-projection"],
|
||||
"dirty_paths": ["app-server/src/codex_message_processor.rs"]
|
||||
},
|
||||
"graphCheckpoint": {
|
||||
"checkpoint_id": "phase6-scene-smoke",
|
||||
"graph_revision": 1,
|
||||
"summary": "Scene reflection is the active Phase 6 smoke target.",
|
||||
"frontier_node_ids": ["scene-projection"]
|
||||
},
|
||||
"churn": {
|
||||
"understanding_status": "ready",
|
||||
"diff_pressure": "low",
|
||||
"graph_freshness": "fresh",
|
||||
"unexplained_writes": 0
|
||||
},
|
||||
"evidence": [evidence_record(1)],
|
||||
"observations": [observation_record(1)]
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_missing_scene(scene: &Value) -> Result<()> {
|
||||
require(
|
||||
scene.get("stateStatus").and_then(Value::as_str) == Some("missing"),
|
||||
"initial scene should report missing state",
|
||||
)?;
|
||||
require(
|
||||
scene.get("source").and_then(Value::as_str) == Some("live"),
|
||||
"loaded missing scene should report live source",
|
||||
)?;
|
||||
require(
|
||||
string_array_eq(
|
||||
scene.get("availableActions").and_then(Value::as_array),
|
||||
&[
|
||||
"index",
|
||||
"retrieve",
|
||||
"distill",
|
||||
"context",
|
||||
"planning",
|
||||
"graphQuery",
|
||||
"jobs",
|
||||
"roles",
|
||||
"coordinator",
|
||||
"roleLaunch",
|
||||
"roleResult",
|
||||
"roleAccept",
|
||||
"jobLaunch",
|
||||
"freshness",
|
||||
"pressure",
|
||||
"reorient",
|
||||
"crrc",
|
||||
"update",
|
||||
],
|
||||
),
|
||||
"missing live scene should expose only bootstrap actions",
|
||||
)?;
|
||||
require(
|
||||
scene
|
||||
.pointer("/observations/totalCount")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(0),
|
||||
"missing scene should not report observations",
|
||||
)?;
|
||||
require(
|
||||
scene
|
||||
.pointer("/evidence/totalCount")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(0),
|
||||
"missing scene should not report evidence",
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_ready_scene(scene: &Value, expected_revision: u64) -> Result<()> {
|
||||
require(
|
||||
scene.get("stateStatus").and_then(Value::as_str) == Some("ready"),
|
||||
"scene should report ready state after update",
|
||||
)?;
|
||||
require(
|
||||
scene.get("source").and_then(Value::as_str) == Some("live"),
|
||||
"loaded scene should report live source",
|
||||
)?;
|
||||
require(
|
||||
scene.get("revision").and_then(Value::as_u64) == Some(expected_revision),
|
||||
"scene should expose current revision",
|
||||
)?;
|
||||
require(
|
||||
scene
|
||||
.get("objective")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|objective| {
|
||||
objective.starts_with("Expose live Epiphany scene reflection")
|
||||
}),
|
||||
"scene should expose objective",
|
||||
)?;
|
||||
require(
|
||||
scene.pointer("/activeSubgoal/id").and_then(Value::as_str) == Some("phase6-scene-smoke"),
|
||||
"scene should expose active subgoal",
|
||||
)?;
|
||||
require(
|
||||
scene.get("invariantStatusCounts") == Some(&json!([{"status": "ok", "count": 2}])),
|
||||
"scene should summarize invariant status counts",
|
||||
)?;
|
||||
require(
|
||||
scene
|
||||
.pointer("/graph/architectureNodeCount")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(1),
|
||||
"scene should count architecture nodes",
|
||||
)?;
|
||||
require(
|
||||
scene
|
||||
.pointer("/graph/dataflowNodeCount")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(1),
|
||||
"scene should count dataflow nodes",
|
||||
)?;
|
||||
require(
|
||||
scene.pointer("/graph/linkCount").and_then(Value::as_u64) == Some(1),
|
||||
"scene should count graph links",
|
||||
)?;
|
||||
require(
|
||||
string_array_eq(
|
||||
scene
|
||||
.pointer("/graph/activeNodeIds")
|
||||
.and_then(Value::as_array),
|
||||
&["scene-projection"],
|
||||
),
|
||||
"scene should expose graph frontier active nodes",
|
||||
)?;
|
||||
require(
|
||||
scene.pointer("/graph/checkpointId").and_then(Value::as_str) == Some("phase6-scene-smoke"),
|
||||
"scene should expose graph checkpoint",
|
||||
)?;
|
||||
require(
|
||||
scene
|
||||
.pointer("/investigationCheckpoint/checkpointId")
|
||||
.and_then(Value::as_str)
|
||||
== Some("phase6-scene-investigation"),
|
||||
"scene should expose the investigation checkpoint id",
|
||||
)?;
|
||||
require(
|
||||
scene
|
||||
.pointer("/investigationCheckpoint/disposition")
|
||||
.and_then(Value::as_str)
|
||||
== Some("resume_ready"),
|
||||
"scene should expose the checkpoint disposition",
|
||||
)?;
|
||||
require(
|
||||
scene
|
||||
.pointer("/retrieval/workspaceRoot")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|root| root.ends_with("epiphany-core")),
|
||||
"scene should include live retrieval summary backfill",
|
||||
)?;
|
||||
require(
|
||||
scene
|
||||
.pointer("/observations/totalCount")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(6),
|
||||
"scene should count all observations",
|
||||
)?;
|
||||
require(
|
||||
scene
|
||||
.pointer("/evidence/totalCount")
|
||||
.and_then(Value::as_u64)
|
||||
== Some(6),
|
||||
"scene should count all evidence",
|
||||
)?;
|
||||
require(
|
||||
ids_eq(
|
||||
scene
|
||||
.pointer("/observations/latest")
|
||||
.and_then(Value::as_array),
|
||||
&[
|
||||
"obs-phase6-scene-6",
|
||||
"obs-phase6-scene-5",
|
||||
"obs-phase6-scene-4",
|
||||
"obs-phase6-scene-3",
|
||||
"obs-phase6-scene-2",
|
||||
],
|
||||
),
|
||||
"scene latest observations should be newest-first and bounded",
|
||||
)?;
|
||||
require(
|
||||
ids_eq(
|
||||
scene.pointer("/evidence/latest").and_then(Value::as_array),
|
||||
&[
|
||||
"ev-phase6-scene-6",
|
||||
"ev-phase6-scene-5",
|
||||
"ev-phase6-scene-4",
|
||||
"ev-phase6-scene-3",
|
||||
"ev-phase6-scene-2",
|
||||
],
|
||||
),
|
||||
"scene latest evidence should be newest-first and bounded",
|
||||
)?;
|
||||
require(
|
||||
scene.pointer("/churn/diffPressure").and_then(Value::as_str) == Some("low"),
|
||||
"scene should expose churn pressure",
|
||||
)?;
|
||||
require(
|
||||
string_array_eq(
|
||||
scene.get("availableActions").and_then(Value::as_array),
|
||||
&[
|
||||
"index",
|
||||
"retrieve",
|
||||
"distill",
|
||||
"context",
|
||||
"planning",
|
||||
"graphQuery",
|
||||
"jobs",
|
||||
"roles",
|
||||
"coordinator",
|
||||
"roleLaunch",
|
||||
"roleResult",
|
||||
"roleAccept",
|
||||
"jobLaunch",
|
||||
"freshness",
|
||||
"pressure",
|
||||
"reorient",
|
||||
"crrc",
|
||||
"reorientLaunch",
|
||||
"update",
|
||||
"jobInterrupt",
|
||||
"propose",
|
||||
"promote",
|
||||
],
|
||||
),
|
||||
"ready live scene should expose full loaded-state actions",
|
||||
)
|
||||
}
|
||||
|
||||
fn ids(value: &Value) -> Value {
|
||||
json!(
|
||||
value
|
||||
.as_array()
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.get("id").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
fn ids_eq(values: Option<&Vec<Value>>, expected: &[&str]) -> bool {
|
||||
values.is_some_and(|values| {
|
||||
values.len() == expected.len()
|
||||
&& values
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(value, expected)| value.get("id").and_then(Value::as_str) == Some(*expected))
|
||||
})
|
||||
}
|
||||
|
||||
fn string_array_eq(values: Option<&Vec<Value>>, expected: &[&str]) -> bool {
|
||||
values.is_some_and(|values| {
|
||||
values.len() == expected.len()
|
||||
&& values
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(value, expected)| value.as_str() == Some(*expected))
|
||||
})
|
||||
}
|
||||
|
||||
fn reset_smoke_paths(root: &Path, paths: &[PathBuf]) -> Result<()> {
|
||||
let smoke_root = root.join(".epiphany-smoke").canonicalize().or_else(|_| {
|
||||
let smoke_root = root.join(".epiphany-smoke");
|
||||
fs::create_dir_all(&smoke_root)?;
|
||||
smoke_root.canonicalize()
|
||||
})?;
|
||||
for path in paths {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let resolved = path.canonicalize()?;
|
||||
if resolved == smoke_root || !resolved.starts_with(&smoke_root) {
|
||||
return Err(anyhow!(
|
||||
"refusing to delete non-smoke path: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(path)?;
|
||||
} else {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require(condition: bool, message: &str) -> Result<()> {
|
||||
if condition {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("{message}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn take_path(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
args.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
366
epiphany-core/src/bin/epiphany-prepare-compaction.rs
Normal file
366
epiphany-core/src/bin/epiphany-prepare-compaction.rs
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use epiphany_core::EpiphanyLedgerEvidenceRecord;
|
||||
use epiphany_core::load_state_ledger;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Finding {
|
||||
level: &'static str,
|
||||
message: String,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let strict = env::args().skip(1).any(|arg| arg == "--strict");
|
||||
let root = env::current_dir().context("failed to resolve current directory")?;
|
||||
let state_dir = root.join("state");
|
||||
let notes_dir = root.join("notes");
|
||||
let map_path = state_dir.join("map.yaml");
|
||||
let scratch_path = state_dir.join("scratch.md");
|
||||
let ledger_path = state_dir.join("ledgers.cc");
|
||||
let handoff_path = notes_dir.join("fresh-workspace-handoff.md");
|
||||
let plan_path = notes_dir.join("epiphany-fork-implementation-plan.md");
|
||||
let algo_map_path = notes_dir.join("epiphany-current-algorithmic-map.md");
|
||||
let agents_path = root.join("AGENTS.md");
|
||||
|
||||
let mut findings = Vec::new();
|
||||
for path in [
|
||||
&map_path,
|
||||
&scratch_path,
|
||||
&ledger_path,
|
||||
&handoff_path,
|
||||
&plan_path,
|
||||
&algo_map_path,
|
||||
&agents_path,
|
||||
] {
|
||||
if path.exists() {
|
||||
findings.push(ok(format!("found {}", relative(&root, path))));
|
||||
} else {
|
||||
findings.push(error(format!("missing {}", relative(&root, path))));
|
||||
}
|
||||
}
|
||||
|
||||
add_content_checks(&mut findings, &map_path, &scratch_path, &ledger_path);
|
||||
add_handoff_checks(&mut findings, &handoff_path);
|
||||
add_agents_checks(&mut findings, &agents_path);
|
||||
let (status, log) = match (
|
||||
run_git(&root, ["status", "--short", "--branch"]),
|
||||
run_git(&root, ["log", "--oneline", "-5"]),
|
||||
) {
|
||||
(Ok(status), Ok(log)) => {
|
||||
if status.lines().skip(1).any(|line| !line.trim().is_empty()) {
|
||||
findings.push(warn(
|
||||
"git worktree has uncommitted changes; commit or explain before compaction",
|
||||
));
|
||||
} else {
|
||||
findings.push(ok("git worktree is clean"));
|
||||
}
|
||||
(status, log)
|
||||
}
|
||||
_ => {
|
||||
findings.push(error("git check failed"));
|
||||
(
|
||||
"(git status unavailable)".to_string(),
|
||||
"(git log unavailable)".to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let latest_evidence = load_state_ledger(&ledger_path)
|
||||
.ok()
|
||||
.and_then(|entry| entry.evidence.last().cloned());
|
||||
println!(
|
||||
"{}",
|
||||
render_report(
|
||||
&root,
|
||||
&map_path,
|
||||
&findings,
|
||||
&status,
|
||||
&log,
|
||||
latest_evidence.as_ref(),
|
||||
)?
|
||||
);
|
||||
|
||||
let has_error = findings.iter().any(|finding| finding.level == "error");
|
||||
let has_warning = findings.iter().any(|finding| finding.level == "warn");
|
||||
if has_error || (strict && has_warning) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_content_checks(
|
||||
findings: &mut Vec<Finding>,
|
||||
map_path: &Path,
|
||||
scratch_path: &Path,
|
||||
ledger_path: &Path,
|
||||
) {
|
||||
match extract_map_field(map_path, "summary") {
|
||||
Ok(Some(_)) => findings.push(ok("state/map.yaml has current_status.summary")),
|
||||
Ok(None) => findings.push(error("state/map.yaml is missing current_status.summary")),
|
||||
Err(err) => findings.push(error(format!("map summary check failed: {err}"))),
|
||||
}
|
||||
match extract_map_field(map_path, "next_action") {
|
||||
Ok(Some(_)) => findings.push(ok("state/map.yaml has current_status.next_action")),
|
||||
Ok(None) => findings.push(error(
|
||||
"state/map.yaml is missing current_status.next_action",
|
||||
)),
|
||||
Err(err) => findings.push(error(format!("map next_action check failed: {err}"))),
|
||||
}
|
||||
match extract_active_subgoals(map_path) {
|
||||
Ok(subgoals) if !subgoals.is_empty() => {
|
||||
findings.push(ok(format!(
|
||||
"state/map.yaml has {} active subgoal(s)",
|
||||
subgoals.len()
|
||||
)));
|
||||
}
|
||||
Ok(_) => findings.push(warn("state/map.yaml has no active_subgoals entries")),
|
||||
Err(err) => findings.push(error(format!("active_subgoals check failed: {err}"))),
|
||||
}
|
||||
match current_scratch_subgoal(scratch_path) {
|
||||
Ok(Some(value)) if value == "No active scratch subgoal." => {
|
||||
findings.push(ok("state/scratch.md has no stale active scratch subgoal"));
|
||||
}
|
||||
Ok(Some(value)) => findings.push(warn(format!(
|
||||
"state/scratch.md has active scratch subgoal: {value}"
|
||||
))),
|
||||
Ok(None) => findings.push(warn("state/scratch.md has no Current Subgoal value")),
|
||||
Err(err) => findings.push(error(format!("scratch check failed: {err}"))),
|
||||
}
|
||||
match load_state_ledger(ledger_path) {
|
||||
Ok(entry) => {
|
||||
let active = entry
|
||||
.branches
|
||||
.iter()
|
||||
.filter(|branch| branch.status == "active")
|
||||
.count();
|
||||
findings.push(ok(format!(
|
||||
"state/ledgers.cc parses ({} evidence record(s))",
|
||||
entry.evidence.len()
|
||||
)));
|
||||
findings.push(ok(format!(
|
||||
"state/ledgers.cc has {active} active branch(es)"
|
||||
)));
|
||||
}
|
||||
Err(err) => findings.push(error(format!("state ledger parse failed: {err}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_handoff_checks(findings: &mut Vec<Finding>, handoff_path: &Path) {
|
||||
let Ok(text) = fs::read_to_string(handoff_path) else {
|
||||
findings.push(error("handoff check failed"));
|
||||
return;
|
||||
};
|
||||
if text.contains("Current branch before") || text.contains("Current HEAD before") {
|
||||
findings.push(error(
|
||||
"handoff embeds an exact branch or HEAD snapshot; use git commands instead",
|
||||
));
|
||||
} else {
|
||||
findings.push(ok("handoff avoids exact branch/HEAD snapshots"));
|
||||
}
|
||||
for phrase in [
|
||||
"Do not continue implementation automatically from a rehydrate-only request.",
|
||||
"Do not trust this file for the exact live HEAD.",
|
||||
"Immediate Re-entry Instruction",
|
||||
] {
|
||||
if text.contains(phrase) {
|
||||
findings.push(ok(format!("handoff contains: {phrase}")));
|
||||
} else {
|
||||
findings.push(warn(format!("handoff missing: {phrase}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_agents_checks(findings: &mut Vec<Finding>, agents_path: &Path) {
|
||||
let Ok(text) = fs::read_to_string(agents_path) else {
|
||||
findings.push(error("AGENTS check failed"));
|
||||
return;
|
||||
};
|
||||
if text.contains("epiphany-prepare-compaction") {
|
||||
findings.push(ok("AGENTS.md tells agents to use the compaction helper"));
|
||||
} else {
|
||||
findings.push(error(
|
||||
"AGENTS.md does not mention epiphany-prepare-compaction",
|
||||
));
|
||||
}
|
||||
if text
|
||||
.to_lowercase()
|
||||
.contains("prepare for imminent compaction")
|
||||
{
|
||||
findings.push(ok("AGENTS.md names the imminent-compaction trigger"));
|
||||
} else {
|
||||
findings.push(warn(
|
||||
"AGENTS.md does not name the imminent-compaction trigger phrase",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn render_report(
|
||||
root: &Path,
|
||||
map_path: &Path,
|
||||
findings: &[Finding],
|
||||
status: &str,
|
||||
log: &str,
|
||||
latest: Option<&EpiphanyLedgerEvidenceRecord>,
|
||||
) -> Result<String> {
|
||||
let ok_count = findings
|
||||
.iter()
|
||||
.filter(|finding| finding.level == "ok")
|
||||
.count();
|
||||
let warn_count = findings
|
||||
.iter()
|
||||
.filter(|finding| finding.level == "warn")
|
||||
.count();
|
||||
let error_count = findings
|
||||
.iter()
|
||||
.filter(|finding| finding.level == "error")
|
||||
.count();
|
||||
let mut lines = vec![
|
||||
"Epiphany pre-compaction persistence check".to_string(),
|
||||
format!("Workspace: {}", root.display()),
|
||||
format!("Findings: {ok_count} ok, {warn_count} warn, {error_count} error"),
|
||||
String::new(),
|
||||
"Git status:".to_string(),
|
||||
status.to_string(),
|
||||
String::new(),
|
||||
"Recent commits:".to_string(),
|
||||
log.to_string(),
|
||||
String::new(),
|
||||
format!(
|
||||
"Summary: {}",
|
||||
extract_map_field(map_path, "summary")?.unwrap_or_else(|| "(missing)".to_string())
|
||||
),
|
||||
format!(
|
||||
"Next action: {}",
|
||||
extract_map_field(map_path, "next_action")?.unwrap_or_else(|| "(missing)".to_string())
|
||||
),
|
||||
];
|
||||
let subgoals = extract_active_subgoals(map_path)?;
|
||||
if !subgoals.is_empty() {
|
||||
lines.push("Active subgoals:".to_string());
|
||||
lines.extend(subgoals.into_iter().map(|item| format!("- {item}")));
|
||||
}
|
||||
if let Some(latest) = latest {
|
||||
lines.extend([
|
||||
String::new(),
|
||||
"Latest distilled evidence:".to_string(),
|
||||
format!(
|
||||
"- {} {}/{}: {}",
|
||||
latest.ts, latest.evidence_type, latest.status, latest.note
|
||||
),
|
||||
]);
|
||||
}
|
||||
lines.push(String::new());
|
||||
lines.push("Findings:".to_string());
|
||||
for finding in findings {
|
||||
lines.push(format!(
|
||||
"[{}] {}",
|
||||
finding.level.to_uppercase(),
|
||||
finding.message
|
||||
));
|
||||
}
|
||||
lines.extend([
|
||||
String::new(),
|
||||
"Pre-compaction checklist:".to_string(),
|
||||
"- Update state/map.yaml only if current understanding changed.".to_string(),
|
||||
"- Refresh notes/fresh-workspace-handoff.md if re-entry instructions changed.".to_string(),
|
||||
"- Add distilled evidence only for a belief-changing lesson, verification, rejected path, or scar.".to_string(),
|
||||
"- Keep exact branch/HEAD out of handoff prose; git commands own volatile truth.".to_string(),
|
||||
"- Commit completed persistence changes, or state why the worktree must stay dirty.".to_string(),
|
||||
"- Re-run this helper after edits before yielding to compaction.".to_string(),
|
||||
]);
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn extract_map_field(path: &Path, name: &str) -> Result<Option<String>> {
|
||||
let prefix = format!(" {name}:");
|
||||
for line in fs::read_to_string(path)?.lines() {
|
||||
if line.starts_with(&prefix) {
|
||||
return Ok(Some(
|
||||
line.split_once(':')
|
||||
.map(|(_, value)| value.trim().to_string())
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn extract_active_subgoals(path: &Path) -> Result<Vec<String>> {
|
||||
let text = fs::read_to_string(path)?;
|
||||
let mut results = Vec::new();
|
||||
let mut in_section = false;
|
||||
for line in text.lines() {
|
||||
if !in_section {
|
||||
if line.trim() == "active_subgoals:" {
|
||||
in_section = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix(" - ") {
|
||||
results.push(rest.trim().to_string());
|
||||
} else if !line.is_empty() && !line.starts_with(' ') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn current_scratch_subgoal(path: &Path) -> Result<Option<String>> {
|
||||
let lines = fs::read_to_string(path)?;
|
||||
let mut found_header = false;
|
||||
for line in lines.lines() {
|
||||
if found_header {
|
||||
let stripped = line.trim();
|
||||
if !stripped.is_empty() {
|
||||
return Ok(Some(stripped.to_string()));
|
||||
}
|
||||
} else if line.trim() == "## Current Subgoal" {
|
||||
found_header = true;
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn run_git<const N: usize>(root: &Path, args: [&str; N]) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.context("failed to run git")?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn relative(root: &Path, path: &Path) -> String {
|
||||
path.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.display()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn ok(message: impl Into<String>) -> Finding {
|
||||
Finding {
|
||||
level: "ok",
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn warn(message: impl Into<String>) -> Finding {
|
||||
Finding {
|
||||
level: "warn",
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn error(message: impl Into<String>) -> Finding {
|
||||
Finding {
|
||||
level: "error",
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
180
epiphany-core/src/bin/epiphany-prompt-context-smoke.rs
Normal file
180
epiphany-core/src/bin/epiphany-prompt-context-smoke.rs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
use anyhow::Result;
|
||||
use epiphany_core::EpiphanyMemoryContextQuery;
|
||||
use epiphany_core::EpiphanyMemoryDomain;
|
||||
use epiphany_core::EpiphanyMemoryFreshnessStatus;
|
||||
use epiphany_core::EpiphanyMemoryGraphSnapshot;
|
||||
use epiphany_core::EpiphanyMemoryLifecycle;
|
||||
use epiphany_core::EpiphanyMemoryNode;
|
||||
use epiphany_core::EpiphanyMemoryNodeKind;
|
||||
use epiphany_core::EpiphanyMemoryProfile;
|
||||
use epiphany_core::EpiphanyMemorySummary;
|
||||
use epiphany_core::EpiphanyPromptContextInput;
|
||||
use epiphany_core::EpiphanyRoleResultRoleId;
|
||||
use epiphany_core::EpiphanyThreadState;
|
||||
use epiphany_core::EpiphanyWorkerLaunchDocument;
|
||||
use epiphany_core::build_epiphany_role_launch_request_with_dynamic_context;
|
||||
use epiphany_core::memory_graph_domain_id;
|
||||
use epiphany_core::memory_graph_node_id;
|
||||
use epiphany_core::plan_memory_graph_context_cut;
|
||||
use epiphany_core::query_epiphany_local_verse_context;
|
||||
use epiphany_core::render_epiphany_prompt_context;
|
||||
use epiphany_core::seed_epiphany_local_verse_context;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let store = env::args().nth(1).map(PathBuf::from).unwrap_or_else(|| {
|
||||
PathBuf::from(".epiphany-smoke")
|
||||
.join("cultmesh")
|
||||
.join("epiphany-prompt-context.cc")
|
||||
});
|
||||
if let Some(parent) = store.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
seed_epiphany_local_verse_context(
|
||||
&store,
|
||||
"epiphany-prompt-context-smoke",
|
||||
"2026-06-02T00:00:00Z",
|
||||
)?;
|
||||
let local_verse = query_epiphany_local_verse_context(&store, "epiphany-prompt-context-smoke")?;
|
||||
let memory_context = plan_memory_graph_context_cut(
|
||||
&smoke_snapshot(),
|
||||
&EpiphanyMemoryContextQuery {
|
||||
id: "prompt-context-smoke-query".to_string(),
|
||||
profile: Some(EpiphanyMemoryProfile::RepoArchitecture),
|
||||
text: Some("Verse prompt assembly".to_string()),
|
||||
budget: Some(4),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let rendered = render_epiphany_prompt_context(&EpiphanyPromptContextInput {
|
||||
focus: "Build a bounded worker prompt from local Verse and memory context.".to_string(),
|
||||
local_verse,
|
||||
memory_context,
|
||||
});
|
||||
|
||||
let checks = [
|
||||
rendered.contains("<epiphany_dynamic_context>"),
|
||||
rendered.contains("Odin"),
|
||||
rendered.contains("Yggdrasil"),
|
||||
rendered.contains("Bifrost"),
|
||||
rendered.contains("gamecult-local"),
|
||||
rendered.contains("Verse-backed prompt assembly"),
|
||||
rendered.contains("Mind reviews durable state effects"),
|
||||
!rendered.contains("raw private worker transcript should never render"),
|
||||
];
|
||||
if checks.iter().any(|passed| !passed) {
|
||||
anyhow::bail!("prompt context smoke failed rendered packet checks:\n{rendered}");
|
||||
}
|
||||
|
||||
let launch_request = build_epiphany_role_launch_request_with_dynamic_context(
|
||||
"thread-prompt-context-smoke",
|
||||
EpiphanyRoleResultRoleId::Modeling,
|
||||
Some(1),
|
||||
Some(60),
|
||||
&EpiphanyThreadState {
|
||||
revision: 1,
|
||||
objective: Some("Launch a worker with bounded dynamic context.".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
Some(rendered.clone()),
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let EpiphanyWorkerLaunchDocument::Role(role_document) = &launch_request.launch_document else {
|
||||
anyhow::bail!("prompt context smoke expected a role launch document");
|
||||
};
|
||||
if role_document.dynamic_prompt_context.as_deref() != Some(rendered.as_str()) {
|
||||
anyhow::bail!("prompt context smoke failed to preserve dynamic launch context");
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"status": "ok",
|
||||
"store": store,
|
||||
"renderedBytes": rendered.len(),
|
||||
"containsVerseContext": true,
|
||||
"containsMemoryContext": true,
|
||||
"launchDocumentCarriesContext": true,
|
||||
"omitsUnselectedPrivateText": true,
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn smoke_snapshot() -> EpiphanyMemoryGraphSnapshot {
|
||||
let domain_id = memory_graph_domain_id(
|
||||
EpiphanyMemoryProfile::RepoArchitecture,
|
||||
"crate",
|
||||
"epiphany-core",
|
||||
);
|
||||
let selected_node = memory_graph_node_id(
|
||||
&domain_id,
|
||||
"module",
|
||||
"epiphany-core/src/prompt_context.rs",
|
||||
Some("prompt_context"),
|
||||
);
|
||||
let unselected_node = memory_graph_node_id(
|
||||
&domain_id,
|
||||
"module",
|
||||
"sealed/raw_worker_transcript.rs",
|
||||
Some("sealed_private_text"),
|
||||
);
|
||||
|
||||
EpiphanyMemoryGraphSnapshot {
|
||||
graph_id: "prompt-context-smoke".to_string(),
|
||||
domains: vec![EpiphanyMemoryDomain {
|
||||
id: domain_id.clone(),
|
||||
profile: EpiphanyMemoryProfile::RepoArchitecture,
|
||||
title: "epiphany-core".to_string(),
|
||||
lifecycle: EpiphanyMemoryLifecycle::Accepted,
|
||||
..Default::default()
|
||||
}],
|
||||
nodes: vec![
|
||||
EpiphanyMemoryNode {
|
||||
id: selected_node.clone(),
|
||||
domain_id: domain_id.clone(),
|
||||
profile: EpiphanyMemoryProfile::RepoArchitecture,
|
||||
kind: EpiphanyMemoryNodeKind::Module,
|
||||
title: "prompt_context".to_string(),
|
||||
claim: "Verse-backed prompt assembly injects bounded local Verse and semantic memory packets.".to_string(),
|
||||
action_implication:
|
||||
"Use compact dynamic context before launching broad swarm turns."
|
||||
.to_string(),
|
||||
lifecycle: EpiphanyMemoryLifecycle::Accepted,
|
||||
confidence: 90,
|
||||
salience: 90,
|
||||
..Default::default()
|
||||
},
|
||||
EpiphanyMemoryNode {
|
||||
id: unselected_node,
|
||||
domain_id: domain_id.clone(),
|
||||
profile: EpiphanyMemoryProfile::RepoArchitecture,
|
||||
kind: EpiphanyMemoryNodeKind::Module,
|
||||
title: "sealed_private_text".to_string(),
|
||||
claim: "raw private worker transcript should never render".to_string(),
|
||||
action_implication: "This text must stay absent from the smoke output.".to_string(),
|
||||
lifecycle: EpiphanyMemoryLifecycle::Accepted,
|
||||
confidence: 90,
|
||||
salience: 10,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
summaries: vec![EpiphanyMemorySummary {
|
||||
id: "summary-prompt-context".to_string(),
|
||||
domain_id,
|
||||
covers_node_ids: vec![selected_node],
|
||||
target: "prompt_context".to_string(),
|
||||
claim: "Verse-backed prompt assembly can ask the local Verse and memory graph for compact context.".to_string(),
|
||||
action_implication:
|
||||
"Render this packet into worker prompts, then keep Mind as the durable-state gate."
|
||||
.to_string(),
|
||||
freshness: EpiphanyMemoryFreshnessStatus::Ready,
|
||||
confidence: 95,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
use anyhow::{Context, Result, bail};
|
||||
use epiphany_core::{
|
||||
EPIPHANY_PACKAGED_RELEASE_WITNESS_FILE, PackageReleaseRequest,
|
||||
epiphany_packaged_release_witness_sha256, inspect_epiphany_packaged_release_witness,
|
||||
package_epiphany_release, publish_epiphany_packaged_release,
|
||||
};
|
||||
use std::{env, path::PathBuf, process::Command};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = env::args().skip(1);
|
||||
let command = args
|
||||
.next()
|
||||
.context("missing command: package, inspect, or publish")?;
|
||||
let mut repo = None;
|
||||
let mut destination = None;
|
||||
let mut build_cache = None;
|
||||
let mut witness = None;
|
||||
let mut store = None;
|
||||
let mut runtime = None;
|
||||
let mut source_commit = None;
|
||||
let mut target = None;
|
||||
while let Some(flag) = args.next() {
|
||||
let value = args
|
||||
.next()
|
||||
.with_context(|| format!("missing value for {flag}"))?;
|
||||
match flag.as_str() {
|
||||
"--repo" => repo = Some(PathBuf::from(value)),
|
||||
"--destination" => destination = Some(PathBuf::from(value)),
|
||||
"--build-cache-root" => build_cache = Some(PathBuf::from(value)),
|
||||
"--witness" => witness = Some(PathBuf::from(value)),
|
||||
"--store" => store = Some(PathBuf::from(value)),
|
||||
"--runtime-id" => runtime = Some(value),
|
||||
"--source-commit" => source_commit = Some(value),
|
||||
"--target-triple" => target = Some(value),
|
||||
_ => bail!("unknown argument {flag}"),
|
||||
}
|
||||
}
|
||||
let destination = destination.context("missing --destination")?;
|
||||
let runtime = runtime.context("missing --runtime-id")?;
|
||||
let entry = match command.as_str() {
|
||||
"package" => {
|
||||
let repo = repo.context("missing --repo")?;
|
||||
let build_cache = build_cache.context("missing --build-cache-root")?;
|
||||
let target = target
|
||||
.or_else(host_target)
|
||||
.context("rustc did not report host target")?;
|
||||
package_epiphany_release(PackageReleaseRequest {
|
||||
repo: &repo,
|
||||
destination: &destination,
|
||||
build_cache_root: &build_cache,
|
||||
runtime_id: &runtime,
|
||||
target_triple: &target,
|
||||
})?
|
||||
}
|
||||
"inspect" | "publish" => {
|
||||
let witness = witness.context("missing --witness")?;
|
||||
let source_commit = source_commit.context("missing --source-commit")?;
|
||||
let entry = inspect_epiphany_packaged_release_witness(
|
||||
&witness,
|
||||
&destination,
|
||||
&runtime,
|
||||
&source_commit,
|
||||
)?;
|
||||
if command == "publish" {
|
||||
publish_epiphany_packaged_release(
|
||||
&store.context("missing --store")?,
|
||||
&runtime,
|
||||
entry,
|
||||
)?
|
||||
} else {
|
||||
entry
|
||||
}
|
||||
}
|
||||
_ => bail!("unknown command {command}; expected package, inspect, or publish"),
|
||||
};
|
||||
let digest = epiphany_packaged_release_witness_sha256(&entry)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"schemaVersion": entry.schema_version,
|
||||
"status": command,
|
||||
"releaseId": entry.release_id,
|
||||
"witnessSha256": digest,
|
||||
"sourceCommit": entry.source_commit_sha,
|
||||
"packageRoot": entry.package_root,
|
||||
"witnessPath": PathBuf::from(&entry.package_root).join(EPIPHANY_PACKAGED_RELEASE_WITNESS_FILE),
|
||||
"binaryCount": entry.binaries.len(),
|
||||
"privateStateExposed": false
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn host_target() -> Option<String> {
|
||||
command_output("rustc", &["-vV"])
|
||||
.ok()?
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("host: ").map(str::to_string))
|
||||
}
|
||||
|
||||
fn command_output(command: &str, args: &[&str]) -> Result<String> {
|
||||
let output = Command::new(command).args(args).output()?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"{command} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8(output.stdout)?.trim().to_string())
|
||||
}
|
||||
533
epiphany-core/src/bin/epiphany-repo-birth-runner.rs
Normal file
533
epiphany-core/src/bin/epiphany-repo-birth-runner.rs
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Child;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_MODEL_RUNTIME_BIN: &str = "epiphany-model-runtime";
|
||||
const DEFAULT_TIMEOUT_SECONDS: u64 = 900;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
let summary = run(args)?;
|
||||
println!("{}", serde_json::to_string_pretty(&summary)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Args {
|
||||
repo: PathBuf,
|
||||
baseline: PathBuf,
|
||||
artifact_dir: PathBuf,
|
||||
init_store: PathBuf,
|
||||
agent_store: PathBuf,
|
||||
heartbeat_store: PathBuf,
|
||||
runtime_store: PathBuf,
|
||||
model_runtime_bin: String,
|
||||
model_provider: String,
|
||||
executor: String,
|
||||
mode: String,
|
||||
model: Option<String>,
|
||||
timeout_seconds: u64,
|
||||
auto_accept: bool,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let mut args = env::args().skip(1);
|
||||
let mut parsed = Args {
|
||||
repo: root.clone(),
|
||||
baseline: root
|
||||
.join(".epiphany-imports")
|
||||
.join("repo-personality-terrain")
|
||||
.join("baseline.cc"),
|
||||
artifact_dir: root.join(".epiphany-birth-runner"),
|
||||
init_store: root.join("state").join("repo-initialization.cc"),
|
||||
agent_store: root.join("state").join("agents.cc"),
|
||||
heartbeat_store: root.join("state").join("agent-heartbeats.cc"),
|
||||
runtime_store: root.join("state").join("runtime-spine.cc"),
|
||||
model_runtime_bin: DEFAULT_MODEL_RUNTIME_BIN.to_string(),
|
||||
model_provider: "openai-codex".to_string(),
|
||||
executor: "model-runtime".to_string(),
|
||||
mode: "plan".to_string(),
|
||||
model: None,
|
||||
timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
|
||||
auto_accept: false,
|
||||
};
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--repo" => parsed.repo = take_path(&mut args, "--repo")?,
|
||||
"--baseline" => parsed.baseline = take_path(&mut args, "--baseline")?,
|
||||
"--artifact-dir" => parsed.artifact_dir = take_path(&mut args, "--artifact-dir")?,
|
||||
"--init-store" => parsed.init_store = take_path(&mut args, "--init-store")?,
|
||||
"--agent-store" => parsed.agent_store = take_path(&mut args, "--agent-store")?,
|
||||
"--heartbeat-store" => {
|
||||
parsed.heartbeat_store = take_path(&mut args, "--heartbeat-store")?;
|
||||
}
|
||||
"--runtime-store" => {
|
||||
parsed.runtime_store = take_path(&mut args, "--runtime-store")?
|
||||
}
|
||||
"--model-runtime-bin" => {
|
||||
parsed.model_runtime_bin = take_string(&mut args, "--model-runtime-bin")?;
|
||||
}
|
||||
"--openai-runtime-bin" => {
|
||||
parsed.model_runtime_bin = take_string(&mut args, "--openai-runtime-bin")?;
|
||||
}
|
||||
"--model-provider" => {
|
||||
parsed.model_provider = take_string(&mut args, "--model-provider")?;
|
||||
}
|
||||
"--executor" => parsed.executor = take_string(&mut args, "--executor")?,
|
||||
"--mode" => parsed.mode = take_string(&mut args, "--mode")?,
|
||||
"--model" => parsed.model = Some(take_string(&mut args, "--model")?),
|
||||
"--timeout-seconds" => {
|
||||
parsed.timeout_seconds =
|
||||
take_string(&mut args, "--timeout-seconds")?.parse()?;
|
||||
}
|
||||
"--auto-accept" => parsed.auto_accept = true,
|
||||
other => return Err(anyhow!("unknown argument {other:?}")),
|
||||
}
|
||||
}
|
||||
if !matches!(parsed.mode.as_str(), "plan" | "run") {
|
||||
return Err(anyhow!("--mode must be plan or run"));
|
||||
}
|
||||
if parsed.executor == "openai-runtime" {
|
||||
parsed.executor = "model-runtime".to_string();
|
||||
}
|
||||
if parsed.executor != "model-runtime" {
|
||||
return Err(anyhow!(
|
||||
"--executor must be model-runtime; Codex CLI execution is not an Epiphany agent prompt path"
|
||||
));
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
fn run(args: Args) -> Result<Value> {
|
||||
fs::create_dir_all(&args.artifact_dir)
|
||||
.with_context(|| format!("failed to create {}", args.artifact_dir.display()))?;
|
||||
let startup_dir = args.artifact_dir.join("startup");
|
||||
fs::create_dir_all(&startup_dir)?;
|
||||
let startup = run_repo_personality_json(&[
|
||||
"startup".to_string(),
|
||||
"--repo".to_string(),
|
||||
args.repo.display().to_string(),
|
||||
"--baseline".to_string(),
|
||||
args.baseline.display().to_string(),
|
||||
"--artifact-dir".to_string(),
|
||||
startup_dir.display().to_string(),
|
||||
"--init-store".to_string(),
|
||||
args.init_store.display().to_string(),
|
||||
])?;
|
||||
let packets = startup["generatedPackets"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut executions = Vec::new();
|
||||
for packet in packets {
|
||||
let kind = packet["kind"].as_str().unwrap_or("unknown");
|
||||
let packet_path = path_from_value(&packet["packetPath"])?;
|
||||
let execution_dir = args.artifact_dir.join(sanitize(kind));
|
||||
fs::create_dir_all(&execution_dir)?;
|
||||
let packet_value = read_json(&packet_path)?;
|
||||
let prompt_path = execution_dir.join("prompt.md");
|
||||
let schema_path = execution_dir.join("output-schema.json");
|
||||
let result_path = execution_dir.join("result.json");
|
||||
let request_path = execution_dir.join("model-request.json");
|
||||
let runtime_summary_path = execution_dir.join("model-runtime-summary.json");
|
||||
let stdout_path = execution_dir.join("executor.stdout.log");
|
||||
let stderr_path = execution_dir.join("executor.stderr.log");
|
||||
let prompt = render_specialist_prompt(kind, &packet_value)?;
|
||||
let schema = output_schema_for_kind(kind, &packet_value)?;
|
||||
write_text(&prompt_path, &prompt)?;
|
||||
write_json(&schema_path, &schema)?;
|
||||
let mut execution = json!({
|
||||
"kind": kind,
|
||||
"birthOnly": true,
|
||||
"executionOwner": "repo-initialization-startup-runner",
|
||||
"heartbeatParticipant": Value::Null,
|
||||
"packetPath": packet_path,
|
||||
"promptPath": prompt_path,
|
||||
"schemaPath": schema_path,
|
||||
"resultPath": result_path,
|
||||
"requestPath": request_path,
|
||||
"runtimeSummaryPath": runtime_summary_path,
|
||||
"stdoutPath": stdout_path,
|
||||
"stderrPath": stderr_path,
|
||||
"executor": args.executor.clone(),
|
||||
"modelRuntimeBin": args.model_runtime_bin.clone(),
|
||||
"modelProvider": args.model_provider.clone(),
|
||||
"status": "planned",
|
||||
"acceptCommand": accept_command(kind, &args, &packet_path, &result_path),
|
||||
});
|
||||
if args.mode == "run" {
|
||||
let run_result = run_openai_runtime_specialist(
|
||||
&args,
|
||||
kind,
|
||||
&prompt,
|
||||
&schema_path,
|
||||
&request_path,
|
||||
&result_path,
|
||||
&runtime_summary_path,
|
||||
&stdout_path,
|
||||
&stderr_path,
|
||||
)?;
|
||||
execution["status"] = run_result["status"].clone();
|
||||
execution["exitCode"] = run_result["exitCode"].clone();
|
||||
execution["timedOut"] = run_result["timedOut"].clone();
|
||||
if result_path.exists() {
|
||||
execution["result"] = read_json(&result_path).unwrap_or_else(|err| {
|
||||
json!({
|
||||
"status": "invalid-json",
|
||||
"error": err.to_string(),
|
||||
})
|
||||
});
|
||||
}
|
||||
if args.auto_accept && execution["status"] == "completed" {
|
||||
execution["acceptance"] = run_repo_personality_json(&accept_args(
|
||||
kind,
|
||||
&args,
|
||||
&packet_path,
|
||||
&result_path,
|
||||
))?;
|
||||
}
|
||||
}
|
||||
executions.push(execution);
|
||||
}
|
||||
let summary = json!({
|
||||
"schemaVersion": "epiphany.repo_birth_runner.v0",
|
||||
"createdAt": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
||||
"mode": args.mode,
|
||||
"repo": args.repo,
|
||||
"baseline": args.baseline,
|
||||
"artifactDir": args.artifact_dir,
|
||||
"initStore": args.init_store,
|
||||
"agentStore": args.agent_store,
|
||||
"heartbeatStore": args.heartbeat_store,
|
||||
"runtimeStore": args.runtime_store,
|
||||
"executor": args.executor,
|
||||
"modelRuntimeBin": args.model_runtime_bin,
|
||||
"modelProvider": args.model_provider,
|
||||
"startup": startup,
|
||||
"executions": executions,
|
||||
"requiresReview": !args.auto_accept,
|
||||
"nextSafeMove": if executions.is_empty() {
|
||||
"Birth initialization records are present; continue startup."
|
||||
} else if args.mode == "plan" {
|
||||
"Review planned startup-only birth executions, then rerun with --mode run."
|
||||
} else if args.auto_accept {
|
||||
"Review auto-accepted smoke output and continue startup."
|
||||
} else {
|
||||
"Review result.json for each birth specialist, then run the shown accept-init command."
|
||||
}
|
||||
});
|
||||
write_json(
|
||||
&args.artifact_dir.join("birth-runner-summary.json"),
|
||||
&summary,
|
||||
)?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_openai_runtime_specialist(
|
||||
args: &Args,
|
||||
kind: &str,
|
||||
prompt: &str,
|
||||
schema_path: &Path,
|
||||
request_path: &Path,
|
||||
result_path: &Path,
|
||||
runtime_summary_path: &Path,
|
||||
stdout_path: &Path,
|
||||
stderr_path: &Path,
|
||||
) -> Result<Value> {
|
||||
let schema_text = fs::read_to_string(schema_path)
|
||||
.with_context(|| format!("failed to read {}", schema_path.display()))?;
|
||||
let model = args.model.clone().unwrap_or_else(|| "gpt-5.4".to_string());
|
||||
let request_id = format!(
|
||||
"repo-birth-{}-{}",
|
||||
sanitize(kind),
|
||||
Utc::now().timestamp_millis()
|
||||
);
|
||||
let request = json!({
|
||||
"schema_id": "epiphany.model_request.v0",
|
||||
"request_id": request_id,
|
||||
"conversation_id": format!("repo-birth-{}", sanitize(kind)),
|
||||
"provider": args.model_provider.clone(),
|
||||
"model": model,
|
||||
"instructions": format!(
|
||||
"{prompt}\n\nReturn only a single JSON object matching this output schema. No Markdown, no commentary.\n\n```json\n{schema_text}\n```"
|
||||
),
|
||||
"input": [
|
||||
{
|
||||
"UserText": {
|
||||
"text": "Execute this startup-only Epiphany birth distiller packet and return the reviewable JSON result."
|
||||
}
|
||||
}
|
||||
],
|
||||
"reasoning_effort": Value::Null,
|
||||
"reasoning_summary": Value::Null,
|
||||
"service_tier": Value::Null,
|
||||
"output_contract_id": format!("repo-birth-{kind}")
|
||||
});
|
||||
write_json(request_path, &request)?;
|
||||
|
||||
let mut command = Command::new(&args.model_runtime_bin);
|
||||
command
|
||||
.arg("model-turn")
|
||||
.arg("--provider")
|
||||
.arg(&args.model_provider)
|
||||
.arg("--store")
|
||||
.arg(&args.runtime_store)
|
||||
.arg("--request")
|
||||
.arg(request_path)
|
||||
.arg("--objective")
|
||||
.arg(format!("Run startup-only repo birth specialist {kind}"))
|
||||
.arg("--default-model")
|
||||
.arg(&model)
|
||||
.arg("--output-last-message")
|
||||
.arg(result_path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let child = command
|
||||
.spawn()
|
||||
.context("failed to spawn model runtime model-turn")?;
|
||||
wait_for_child(
|
||||
child,
|
||||
args.timeout_seconds,
|
||||
stdout_path,
|
||||
stderr_path,
|
||||
Some(runtime_summary_path),
|
||||
)
|
||||
}
|
||||
|
||||
fn wait_for_child(
|
||||
mut child: Child,
|
||||
timeout_seconds: u64,
|
||||
stdout_path: &Path,
|
||||
stderr_path: &Path,
|
||||
stdout_json_copy_path: Option<&Path>,
|
||||
) -> Result<Value> {
|
||||
let deadline = Instant::now() + Duration::from_secs(timeout_seconds);
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
let output = child.wait_with_output()?;
|
||||
write_bytes(stdout_path, &output.stdout)?;
|
||||
write_bytes(stderr_path, &output.stderr)?;
|
||||
if let Some(path) = stdout_json_copy_path {
|
||||
write_bytes(path, &output.stdout)?;
|
||||
}
|
||||
return Ok(json!({
|
||||
"status": if status.success() { "completed" } else { "failed" },
|
||||
"exitCode": status.code(),
|
||||
"timedOut": false,
|
||||
}));
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let output = child.wait_with_output()?;
|
||||
write_bytes(stdout_path, &output.stdout)?;
|
||||
write_bytes(stderr_path, &output.stderr)?;
|
||||
if let Some(path) = stdout_json_copy_path {
|
||||
write_bytes(path, &output.stdout)?;
|
||||
}
|
||||
return Ok(json!({
|
||||
"status": "timeout",
|
||||
"exitCode": Value::Null,
|
||||
"timedOut": true,
|
||||
}));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
|
||||
fn render_specialist_prompt(kind: &str, packet: &Value) -> Result<String> {
|
||||
Ok(format!(
|
||||
"{}\n\n# Startup-Only Birth Packet\n\nYou are executing exactly one repo initialization birth specialist packet. Do not edit files. Do not mutate state. Return only JSON that matches the provided schema. The coordinator/Self will review and decide whether to accept the result.\n\n```json\n{}\n```\n",
|
||||
packet["prompt"].as_str().unwrap_or(match kind {
|
||||
"repo-trajectory" => "Act as the Epiphany Repo Trajectory Distiller.",
|
||||
"repo-personality" => "Act as the Epiphany Repo Personality Distiller.",
|
||||
"repo-memory" => "Act as the Epiphany Repo Memory Distiller.",
|
||||
_ => "Act as a startup-only Epiphany birth specialist.",
|
||||
}),
|
||||
serde_json::to_string_pretty(packet)?
|
||||
))
|
||||
}
|
||||
|
||||
fn output_schema_for_kind(kind: &str, packet: &Value) -> Result<Value> {
|
||||
let expected = packet.get("expectedOutput").cloned().unwrap_or(Value::Null);
|
||||
let schema = match kind {
|
||||
"repo-trajectory" => json!({
|
||||
"type": "object",
|
||||
"required": ["verdict", "summary", "confidence", "selfImage", "trajectoryNarrative", "implicitGoals", "antiGoals", "roleBiases", "selfPatchCandidates", "initializationRecord", "doNotMutate", "nextSafeMove"],
|
||||
"properties": {
|
||||
"verdict": {"type": "string", "enum": ["ready-for-review", "needs-more-history", "reject"]},
|
||||
"summary": {"type": "string"},
|
||||
"confidence": {"type": "number"},
|
||||
"selfImage": {"type": "string"},
|
||||
"trajectoryNarrative": {"type": "string"},
|
||||
"implicitGoals": {"type": "array", "items": {"type": "string"}},
|
||||
"antiGoals": {"type": "array", "items": {"type": "string"}},
|
||||
"roleBiases": {"type": "array", "items": {"type": "object"}},
|
||||
"selfPatchCandidates": {"type": "array", "items": {"type": "object"}},
|
||||
"initializationRecord": {"type": "object"},
|
||||
"doNotMutate": {"type": "array", "items": {"type": "string"}},
|
||||
"nextSafeMove": {"type": "string"}
|
||||
},
|
||||
"additionalProperties": true,
|
||||
"xEpiphanyExpectedOutput": expected,
|
||||
}),
|
||||
"repo-personality" => json!({
|
||||
"type": "object",
|
||||
"required": ["verdict", "summary", "confidence", "roleQuirks", "selfPatchCandidates", "initializationRecord", "doNotMutate", "nextSafeMove"],
|
||||
"properties": {
|
||||
"verdict": {"type": "string", "enum": ["ready-for-review", "needs-more-terrain", "reject"]},
|
||||
"summary": {"type": "string"},
|
||||
"confidence": {"type": "number"},
|
||||
"roleQuirks": {"type": "array", "items": {"type": "object"}},
|
||||
"selfPatchCandidates": {"type": "array", "items": {"type": "object"}},
|
||||
"initializationRecord": {"type": "object"},
|
||||
"doNotMutate": {"type": "array", "items": {"type": "string"}},
|
||||
"nextSafeMove": {"type": "string"}
|
||||
},
|
||||
"additionalProperties": true,
|
||||
"xEpiphanyExpectedOutput": expected,
|
||||
}),
|
||||
"repo-memory" => json!({
|
||||
"type": "object",
|
||||
"required": ["verdict", "summary", "confidence", "roleMemoryPatches", "initializationRecord", "doNotMutate", "nextSafeMove"],
|
||||
"properties": {
|
||||
"verdict": {"type": "string", "enum": ["ready-for-review", "needs-more-terrain", "reject"]},
|
||||
"summary": {"type": "string"},
|
||||
"confidence": {"type": "number"},
|
||||
"roleMemoryPatches": {"type": "array", "items": {"type": "object"}},
|
||||
"globalMemoryCandidates": {"type": "array", "items": {"type": "object"}},
|
||||
"initializationRecord": {"type": "object"},
|
||||
"doNotMutate": {"type": "array", "items": {"type": "string"}},
|
||||
"nextSafeMove": {"type": "string"}
|
||||
},
|
||||
"additionalProperties": true,
|
||||
"xEpiphanyExpectedOutput": expected,
|
||||
}),
|
||||
other => return Err(anyhow!("unsupported birth specialist kind {other:?}")),
|
||||
};
|
||||
Ok(schema)
|
||||
}
|
||||
|
||||
fn accept_command(kind: &str, args: &Args, packet_path: &Path, result_path: &Path) -> Vec<String> {
|
||||
accept_args(kind, args, packet_path, result_path)
|
||||
}
|
||||
|
||||
fn accept_args(kind: &str, args: &Args, packet_path: &Path, result_path: &Path) -> Vec<String> {
|
||||
let mut out = vec![
|
||||
"accept-init".to_string(),
|
||||
"--init-store".to_string(),
|
||||
args.init_store.display().to_string(),
|
||||
"--packet".to_string(),
|
||||
packet_path.display().to_string(),
|
||||
"--kind".to_string(),
|
||||
kind.to_string(),
|
||||
"--accepted-by".to_string(),
|
||||
"Self".to_string(),
|
||||
"--summary".to_string(),
|
||||
format!("Accepted {kind} birth specialist result after review."),
|
||||
"--result".to_string(),
|
||||
result_path.display().to_string(),
|
||||
"--agent-store".to_string(),
|
||||
args.agent_store.display().to_string(),
|
||||
"--apply-self-patches".to_string(),
|
||||
"true".to_string(),
|
||||
];
|
||||
if kind == "repo-personality" {
|
||||
out.extend([
|
||||
"--heartbeat-store".to_string(),
|
||||
args.heartbeat_store.display().to_string(),
|
||||
"--apply-heartbeat-seeds".to_string(),
|
||||
"true".to_string(),
|
||||
]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn run_repo_personality_json(args: &[String]) -> Result<Value> {
|
||||
let exe = native_exe("epiphany-repo-personality");
|
||||
let output = Command::new(&exe)
|
||||
.args(args)
|
||||
.output()
|
||||
.with_context(|| format!("failed to run {}", exe.display()))?;
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"epiphany-repo-personality failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
serde_json::from_slice(&output.stdout)
|
||||
.with_context(|| format!("epiphany-repo-personality returned invalid JSON"))
|
||||
}
|
||||
|
||||
fn native_exe(name: &str) -> PathBuf {
|
||||
let target_dir = env::var_os("CARGO_TARGET_DIR")
|
||||
.unwrap_or_else(|| r"C:\Users\Meta\.cargo-target-codex".into());
|
||||
PathBuf::from(target_dir)
|
||||
.join("debug")
|
||||
.join(format!("{name}{}", env::consts::EXE_SUFFIX))
|
||||
}
|
||||
|
||||
fn read_json(path: &Path) -> Result<Value> {
|
||||
let raw =
|
||||
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
|
||||
serde_json::from_str(&raw).with_context(|| format!("failed to decode {}", path.display()))
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, serde_json::to_vec_pretty(value)?)
|
||||
.with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn write_text(path: &Path, value: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, value).with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn write_bytes(path: &Path, value: &[u8]) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, value).with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn path_from_value(value: &Value) -> Result<PathBuf> {
|
||||
value
|
||||
.as_str()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("expected path string, got {value}"))
|
||||
}
|
||||
|
||||
fn sanitize(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn take_string(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
args.next()
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
|
||||
fn take_path(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(take_string(args, name)?))
|
||||
}
|
||||
754
epiphany-core/src/bin/epiphany-repo-personality-smoke.rs
Normal file
754
epiphany-core/src/bin/epiphany-repo-personality-smoke.rs
Normal file
|
|
@ -0,0 +1,754 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use epiphany_core::load_agent_memory_entry_for_role;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let result = run_smoke()?;
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_smoke() -> Result<Value> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let workspace = root
|
||||
.join(".epiphany-smoke")
|
||||
.join("repo-personality-workspace");
|
||||
let artifacts = root
|
||||
.join(".epiphany-smoke")
|
||||
.join("repo-personality-artifacts");
|
||||
reset_path(&workspace)?;
|
||||
reset_path(&artifacts)?;
|
||||
let cult_repo = create_repo(
|
||||
&workspace.join("CultTiny"),
|
||||
&[
|
||||
(
|
||||
"AGENTS.md",
|
||||
"Prefer typed schemas and tiny public contracts.\n",
|
||||
),
|
||||
("src/lib.rs", "pub fn schema_contract() {}\n"),
|
||||
(
|
||||
"docs/architecture.md",
|
||||
"# Architecture\nCultTiny stores typed contract surfaces.\n",
|
||||
),
|
||||
(
|
||||
"research/prior-art.md",
|
||||
"# Prior Art\nPrefer known schema formats before invention.\n",
|
||||
),
|
||||
(
|
||||
"tests/contract_smoke.rs",
|
||||
"#[test] fn contract_smoke() {}\n",
|
||||
),
|
||||
("schemas/message.schema.json", "{\"type\":\"object\"}\n"),
|
||||
],
|
||||
"Add schema contract smoke",
|
||||
)?;
|
||||
let lore_repo = create_repo(
|
||||
&workspace.join("LoreTiny"),
|
||||
&[
|
||||
(
|
||||
"AGENTS.md",
|
||||
"Respect canon, source notes, and public voice.\n",
|
||||
),
|
||||
("state/map.yaml", "objective: preserve canon\n"),
|
||||
("notes/fresh-workspace-handoff.md", "Read the map first.\n"),
|
||||
("Chronicles/City.md", "# City\nCanon material.\n"),
|
||||
],
|
||||
"Polish canon handoff",
|
||||
)?;
|
||||
|
||||
let scout = run_personality(
|
||||
&root,
|
||||
&[
|
||||
"scout",
|
||||
"--root",
|
||||
workspace.to_str().unwrap_or_default(),
|
||||
"--artifact-dir",
|
||||
artifacts.join("scout").to_str().unwrap_or_default(),
|
||||
],
|
||||
)?;
|
||||
require(scout["repoCount"] == 2, "scout should find two git repos")?;
|
||||
let baseline = path_value(&scout, "store")?;
|
||||
require(baseline.exists(), "scout should write typed baseline store")?;
|
||||
|
||||
let projection = run_personality(
|
||||
&root,
|
||||
&[
|
||||
"project",
|
||||
"--repo",
|
||||
cult_repo.to_str().unwrap_or_default(),
|
||||
"--baseline",
|
||||
baseline.to_str().unwrap_or_default(),
|
||||
"--artifact-dir",
|
||||
artifacts.join("cult-project").to_str().unwrap_or_default(),
|
||||
],
|
||||
)?;
|
||||
require(
|
||||
projection["profile"]["roleProjectionCount"] == 8,
|
||||
"project should emit all standing role projections",
|
||||
)?;
|
||||
let store = path_value(&projection, "store")?;
|
||||
require(
|
||||
store.exists(),
|
||||
"project should write typed projection store",
|
||||
)?;
|
||||
|
||||
let packet = run_personality(
|
||||
&root,
|
||||
&[
|
||||
"agent-packet",
|
||||
"--store",
|
||||
store.to_str().unwrap_or_default(),
|
||||
"--artifact-dir",
|
||||
artifacts.join("cult-packet").to_str().unwrap_or_default(),
|
||||
],
|
||||
)?;
|
||||
require(
|
||||
packet["roleProjectionCount"] == 8,
|
||||
"agent packet should carry eight role projections",
|
||||
)?;
|
||||
let packet_path = path_value(&packet, "packetPath")?;
|
||||
let prompt_path = path_value(&packet, "promptPath")?;
|
||||
let packet_summary_path = path_value(&packet, "summaryPath")?;
|
||||
require(
|
||||
packet_path.exists(),
|
||||
"agent packet should write packet json",
|
||||
)?;
|
||||
require(
|
||||
prompt_path.exists(),
|
||||
"agent packet should write prompt markdown",
|
||||
)?;
|
||||
require(
|
||||
packet_summary_path.exists(),
|
||||
"agent packet should write summary markdown",
|
||||
)?;
|
||||
|
||||
let memory_packet = run_personality(
|
||||
&root,
|
||||
&[
|
||||
"memory-packet",
|
||||
"--store",
|
||||
store.to_str().unwrap_or_default(),
|
||||
"--artifact-dir",
|
||||
artifacts
|
||||
.join("cult-memory-packet")
|
||||
.to_str()
|
||||
.unwrap_or_default(),
|
||||
],
|
||||
)?;
|
||||
require(
|
||||
memory_packet["memorySourceCount"].as_u64().unwrap_or(0) > 0,
|
||||
"memory packet should carry source excerpts",
|
||||
)?;
|
||||
require(
|
||||
memory_packet["roleDistillerCount"] == 8,
|
||||
"memory packet should carry all role distiller lanes",
|
||||
)?;
|
||||
let memory_packet_path = path_value(&memory_packet, "packetPath")?;
|
||||
let memory_prompt_path = path_value(&memory_packet, "promptPath")?;
|
||||
let memory_summary_path = path_value(&memory_packet, "summaryPath")?;
|
||||
require(
|
||||
memory_packet_path.exists(),
|
||||
"memory packet should write packet json",
|
||||
)?;
|
||||
require(
|
||||
memory_prompt_path.exists(),
|
||||
"memory packet should write prompt markdown",
|
||||
)?;
|
||||
require(
|
||||
memory_summary_path.exists(),
|
||||
"memory packet should write summary markdown",
|
||||
)?;
|
||||
|
||||
let trajectory_packet = run_personality(
|
||||
&root,
|
||||
&[
|
||||
"trajectory-packet",
|
||||
"--store",
|
||||
store.to_str().unwrap_or_default(),
|
||||
"--artifact-dir",
|
||||
artifacts
|
||||
.join("cult-trajectory-packet")
|
||||
.to_str()
|
||||
.unwrap_or_default(),
|
||||
],
|
||||
)?;
|
||||
require(
|
||||
trajectory_packet["trajectoryThemeCount"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
> 0,
|
||||
"trajectory packet should carry trajectory themes",
|
||||
)?;
|
||||
let trajectory_packet_path = path_value(&trajectory_packet, "packetPath")?;
|
||||
let trajectory_prompt_path = path_value(&trajectory_packet, "promptPath")?;
|
||||
let trajectory_summary_path = path_value(&trajectory_packet, "summaryPath")?;
|
||||
require(
|
||||
trajectory_packet_path.exists(),
|
||||
"trajectory packet should write packet json",
|
||||
)?;
|
||||
require(
|
||||
trajectory_prompt_path.exists(),
|
||||
"trajectory packet should write prompt markdown",
|
||||
)?;
|
||||
require(
|
||||
trajectory_summary_path.exists(),
|
||||
"trajectory packet should write summary markdown",
|
||||
)?;
|
||||
|
||||
let init_store = artifacts.join("repo-initialization.cc");
|
||||
let startup = run_personality(
|
||||
&root,
|
||||
&[
|
||||
"startup",
|
||||
"--repo",
|
||||
cult_repo.to_str().unwrap_or_default(),
|
||||
"--baseline",
|
||||
baseline.to_str().unwrap_or_default(),
|
||||
"--artifact-dir",
|
||||
artifacts.join("startup-first").to_str().unwrap_or_default(),
|
||||
"--init-store",
|
||||
init_store.to_str().unwrap_or_default(),
|
||||
],
|
||||
)?;
|
||||
require(
|
||||
startup["action"] == "reviewInitializationPackets",
|
||||
"startup should request birth packet review before accepted records exist",
|
||||
)?;
|
||||
require(
|
||||
startup["generatedPackets"].as_array().map_or(0, Vec::len) == 3,
|
||||
"startup should generate trajectory, personality, and memory packets",
|
||||
)?;
|
||||
let startup_trajectory_packet = packet_for_kind(&startup, "repo-trajectory")?;
|
||||
let startup_personality_packet = packet_for_kind(&startup, "repo-personality")?;
|
||||
let startup_memory_packet = packet_for_kind(&startup, "repo-memory")?;
|
||||
require(
|
||||
startup_trajectory_packet.exists(),
|
||||
"startup should write trajectory packet",
|
||||
)?;
|
||||
require(
|
||||
startup_personality_packet.exists(),
|
||||
"startup should write personality packet",
|
||||
)?;
|
||||
require(
|
||||
startup_memory_packet.exists(),
|
||||
"startup should write memory packet",
|
||||
)?;
|
||||
let birth_runner = run_birth_runner(
|
||||
&root,
|
||||
&[
|
||||
"--repo",
|
||||
cult_repo.to_str().unwrap_or_default(),
|
||||
"--baseline",
|
||||
baseline.to_str().unwrap_or_default(),
|
||||
"--artifact-dir",
|
||||
artifacts
|
||||
.join("birth-runner-plan")
|
||||
.to_str()
|
||||
.unwrap_or_default(),
|
||||
"--init-store",
|
||||
artifacts
|
||||
.join("birth-runner-plan")
|
||||
.join("repo-initialization.cc")
|
||||
.to_str()
|
||||
.unwrap_or_default(),
|
||||
"--agent-store",
|
||||
root.join("state")
|
||||
.join("agents.cc")
|
||||
.to_str()
|
||||
.unwrap_or_default(),
|
||||
"--heartbeat-store",
|
||||
root.join("state")
|
||||
.join("agent-heartbeats.cc")
|
||||
.to_str()
|
||||
.unwrap_or_default(),
|
||||
"--mode",
|
||||
"plan",
|
||||
],
|
||||
)?;
|
||||
require(
|
||||
birth_runner["schemaVersion"] == "epiphany.repo_birth_runner.v0",
|
||||
"birth runner should return its schema",
|
||||
)?;
|
||||
require(
|
||||
birth_runner["executions"].as_array().map_or(0, Vec::len) == 3,
|
||||
"birth runner plan should expose all startup-only specialist executions",
|
||||
)?;
|
||||
require(
|
||||
birth_runner["executions"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.all(|execution| execution["heartbeatParticipant"].is_null()),
|
||||
"birth runner executions should not be heartbeat participants",
|
||||
)?;
|
||||
let heartbeat_store = artifacts.join("startup-heartbeats.cc");
|
||||
run_heartbeat(
|
||||
&root,
|
||||
&[
|
||||
"init",
|
||||
"--store",
|
||||
heartbeat_store.to_str().unwrap_or_default(),
|
||||
],
|
||||
)?;
|
||||
let trajectory_result_path = artifacts.join("startup-trajectory-result.json");
|
||||
fs::write(
|
||||
&trajectory_result_path,
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"verdict": "ready-for-review",
|
||||
"summary": "Smoke trajectory birth distillation.",
|
||||
"confidence": 0.88,
|
||||
"selfImage": "CultTiny is becoming a typed-contract workspace that prefers receipts to swagger.",
|
||||
"trajectoryNarrative": "The tiny history and docs both lean toward explicit schema boundaries, known formats, and reviewable proof over improvising private protocol sludge.",
|
||||
"implicitGoals": ["Keep future expansion grounded in explicit schemas and receipts."],
|
||||
"antiGoals": ["Do not drift into ad hoc protocol glue just because the fixture is small."],
|
||||
"roleBiases": [{
|
||||
"roleId": "coordinator",
|
||||
"bias": "Self should treat typed-schema growth as part of the repo's grain.",
|
||||
"trajectorySignals": ["systems_formalization", "protocol_intolerance"],
|
||||
"behavioralEffect": "Challenge loose glue early.",
|
||||
"risk": "Could become stiff if later work needs play.",
|
||||
"evidenceRefs": ["AGENTS.md", "docs/architecture.md"]
|
||||
}],
|
||||
"selfPatchCandidates": [{
|
||||
"roleId": "coordinator",
|
||||
"selfPatch": {
|
||||
"agentId": "epiphany.self",
|
||||
"reason": "Trajectory birth should teach Self that CultTiny has been moving toward typed receipts and explicit contracts.",
|
||||
"semanticMemories": [{
|
||||
"memoryId": "mem-self-startup-trajectory-smoke",
|
||||
"summary": "CultTiny's trajectory favors typed contract growth and evidence-backed explicitness over casual glue.",
|
||||
"salience": 0.74,
|
||||
"confidence": 0.87
|
||||
}]
|
||||
}
|
||||
}],
|
||||
"initializationRecord": {
|
||||
"repoId": "culttiny",
|
||||
"distillerKind": "repo-trajectory",
|
||||
"acceptedOnce": true
|
||||
},
|
||||
"doNotMutate": [],
|
||||
"nextSafeMove": "Self reviews the trajectory petition before it colors later memory drift."
|
||||
}))?,
|
||||
)?;
|
||||
let accepted_trajectory = run_personality(
|
||||
&root,
|
||||
&[
|
||||
"accept-init",
|
||||
"--init-store",
|
||||
init_store.to_str().unwrap_or_default(),
|
||||
"--packet",
|
||||
startup_trajectory_packet.to_str().unwrap_or_default(),
|
||||
"--kind",
|
||||
"repo-trajectory",
|
||||
"--accepted-by",
|
||||
"smoke-self",
|
||||
"--summary",
|
||||
"Smoke accepted repo trajectory birth packet after review.",
|
||||
"--result",
|
||||
trajectory_result_path.to_str().unwrap_or_default(),
|
||||
"--agent-store",
|
||||
root.join("state")
|
||||
.join("agents.cc")
|
||||
.to_str()
|
||||
.unwrap_or_default(),
|
||||
"--apply-self-patches",
|
||||
"false",
|
||||
],
|
||||
)?;
|
||||
require(
|
||||
accepted_trajectory["record"]["kind"] == "repo-trajectory",
|
||||
"accept-init should record trajectory birth",
|
||||
)?;
|
||||
let agent_store = artifacts.join("startup-agents.cc");
|
||||
fs::copy(root.join("state").join("agents.cc"), &agent_store)
|
||||
.context("failed to copy role memory store for startup smoke")?;
|
||||
let accepted_personality = run_personality(
|
||||
&root,
|
||||
&[
|
||||
"accept-init",
|
||||
"--init-store",
|
||||
init_store.to_str().unwrap_or_default(),
|
||||
"--packet",
|
||||
startup_personality_packet.to_str().unwrap_or_default(),
|
||||
"--kind",
|
||||
"repo-personality",
|
||||
"--accepted-by",
|
||||
"smoke-self",
|
||||
"--summary",
|
||||
"Smoke accepted repo personality birth packet after review.",
|
||||
"--agent-store",
|
||||
agent_store.to_str().unwrap_or_default(),
|
||||
"--apply-trait-seeds",
|
||||
"true",
|
||||
"--heartbeat-store",
|
||||
heartbeat_store.to_str().unwrap_or_default(),
|
||||
"--apply-heartbeat-seeds",
|
||||
"true",
|
||||
],
|
||||
)?;
|
||||
require(
|
||||
accepted_personality["record"]["kind"] == "repo-personality",
|
||||
"accept-init should record personality birth",
|
||||
)?;
|
||||
require(
|
||||
accepted_personality["heartbeatSeeds"]["applied"]
|
||||
.as_u64()
|
||||
.unwrap_or_default()
|
||||
> 0,
|
||||
"accept-init should apply heartbeat seed mutations",
|
||||
)?;
|
||||
require(
|
||||
accepted_personality["traitLatticePersistence"]["applied"]
|
||||
.as_u64()
|
||||
.unwrap_or_default()
|
||||
> 0,
|
||||
"accept-init should stamp newborn canonical trait lattice seeds",
|
||||
)?;
|
||||
let coordinator = load_agent_memory_entry_for_role(&agent_store, "coordinator")?
|
||||
.ok_or_else(|| anyhow!("startup smoke lost coordinator role memory entry"))?;
|
||||
require(
|
||||
!coordinator
|
||||
.agent
|
||||
.canonical_state
|
||||
.underlying_organization
|
||||
.contains_key("baseline"),
|
||||
"personality accept-init should replace baseline canonical traits for coordinator",
|
||||
)?;
|
||||
require(
|
||||
coordinator
|
||||
.agent
|
||||
.canonical_state
|
||||
.underlying_organization
|
||||
.contains_key("routing_discipline"),
|
||||
"personality accept-init should seed coordinator routing_discipline trait",
|
||||
)?;
|
||||
let memory_result_path = artifacts.join("startup-memory-result.json");
|
||||
fs::write(
|
||||
&memory_result_path,
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"verdict": "ready-for-review",
|
||||
"summary": "Smoke newborn memory distillation.",
|
||||
"confidence": 0.9,
|
||||
"roleMemoryPatches": [{
|
||||
"roleId": "modeling",
|
||||
"roleName": "Proprioception",
|
||||
"verdict": "ready-for-review",
|
||||
"selfPatch": {
|
||||
"agentId": "epiphany.proprioception",
|
||||
"reason": "Birth memory should teach Proprioception that repo initialization memory is reviewed and typed.",
|
||||
"semanticMemories": [{
|
||||
"memoryId": "mem-body-startup-birth-memory-smoke",
|
||||
"summary": "Repo memory birth packets can produce role-specific selfPatch candidates that are applied only after Self review.",
|
||||
"salience": 0.72,
|
||||
"confidence": 0.88
|
||||
}]
|
||||
},
|
||||
"sourceRefs": ["AGENTS.md"],
|
||||
"whyThisBelongsInMemory": "Proprioception needs the startup memory route to stay source-grounded.",
|
||||
"stalenessRisk": "smoke fixture",
|
||||
"doNotStore": []
|
||||
}]
|
||||
}))?,
|
||||
)?;
|
||||
let accepted_memory = run_personality(
|
||||
&root,
|
||||
&[
|
||||
"accept-init",
|
||||
"--init-store",
|
||||
init_store.to_str().unwrap_or_default(),
|
||||
"--packet",
|
||||
startup_memory_packet.to_str().unwrap_or_default(),
|
||||
"--kind",
|
||||
"repo-memory",
|
||||
"--accepted-by",
|
||||
"smoke-self",
|
||||
"--summary",
|
||||
"Smoke accepted repo memory birth packet after review.",
|
||||
"--result",
|
||||
memory_result_path.to_str().unwrap_or_default(),
|
||||
"--agent-store",
|
||||
agent_store.to_str().unwrap_or_default(),
|
||||
"--apply-self-patches",
|
||||
"true",
|
||||
],
|
||||
)?;
|
||||
require(
|
||||
accepted_memory["record"]["kind"] == "repo-memory",
|
||||
"accept-init should record memory birth",
|
||||
)?;
|
||||
require(
|
||||
accepted_memory["selfPersistence"]["applied"] == 1,
|
||||
"accept-init should apply reviewed memory selfPatch candidates",
|
||||
)?;
|
||||
let startup_after_accept = run_personality(
|
||||
&root,
|
||||
&[
|
||||
"startup",
|
||||
"--repo",
|
||||
cult_repo.to_str().unwrap_or_default(),
|
||||
"--baseline",
|
||||
baseline.to_str().unwrap_or_default(),
|
||||
"--artifact-dir",
|
||||
artifacts
|
||||
.join("startup-after-accept")
|
||||
.to_str()
|
||||
.unwrap_or_default(),
|
||||
"--init-store",
|
||||
init_store.to_str().unwrap_or_default(),
|
||||
],
|
||||
)?;
|
||||
require(
|
||||
startup_after_accept["action"] == "continueStartup",
|
||||
"startup should not rerun birth packets after accepted records exist",
|
||||
)?;
|
||||
require(
|
||||
startup_after_accept["generatedPackets"]
|
||||
.as_array()
|
||||
.is_some_and(Vec::is_empty),
|
||||
"startup should generate no packets after accepted records exist",
|
||||
)?;
|
||||
|
||||
let status = run_personality(
|
||||
&root,
|
||||
&["status", "--store", store.to_str().unwrap_or_default()],
|
||||
)?;
|
||||
require(
|
||||
status["reports"] == 1,
|
||||
"projection store should have one report",
|
||||
)?;
|
||||
require(
|
||||
status["profiles"] == 1,
|
||||
"projection store should have one profile",
|
||||
)?;
|
||||
require(
|
||||
status["trajectoryReports"] == 1,
|
||||
"projection store should have one trajectory report",
|
||||
)?;
|
||||
require(
|
||||
status["roleProjections"] == 8,
|
||||
"projection store should have eight role projections",
|
||||
)?;
|
||||
|
||||
Ok(json!({
|
||||
"workspace": workspace,
|
||||
"artifactRoot": artifacts,
|
||||
"cultRepo": cult_repo,
|
||||
"loreRepo": lore_repo,
|
||||
"baseline": baseline,
|
||||
"projectionStore": store,
|
||||
"packetPath": packet_path,
|
||||
"promptPath": prompt_path,
|
||||
"packetSummaryPath": packet_summary_path,
|
||||
"trajectoryPacketPath": trajectory_packet_path,
|
||||
"trajectoryPromptPath": trajectory_prompt_path,
|
||||
"trajectorySummaryPath": trajectory_summary_path,
|
||||
"memoryPacketPath": memory_packet_path,
|
||||
"memoryPromptPath": memory_prompt_path,
|
||||
"memorySummaryPath": memory_summary_path,
|
||||
"initStore": init_store,
|
||||
"agentStore": agent_store,
|
||||
"heartbeatStore": heartbeat_store,
|
||||
"birthRunnerSummary": artifacts.join("birth-runner-plan").join("birth-runner-summary.json"),
|
||||
"startupTrajectoryPacket": startup_trajectory_packet,
|
||||
"startupPersonalityPacket": startup_personality_packet,
|
||||
"startupMemoryPacket": startup_memory_packet,
|
||||
"startupTrajectoryResult": trajectory_result_path,
|
||||
"startupMemoryResult": memory_result_path,
|
||||
"startupAfterAcceptAction": startup_after_accept["action"],
|
||||
"repoCount": scout["repoCount"],
|
||||
"trajectoryReports": status["trajectoryReports"],
|
||||
"roleProjections": status["roleProjections"],
|
||||
}))
|
||||
}
|
||||
|
||||
fn create_repo(root: &Path, files: &[(&str, &str)], commit_message: &str) -> Result<PathBuf> {
|
||||
fs::create_dir_all(root)?;
|
||||
run_git(root, &["init"])?;
|
||||
run_git(
|
||||
root,
|
||||
&["config", "user.email", "epiphany-smoke@example.invalid"],
|
||||
)?;
|
||||
run_git(root, &["config", "user.name", "Epiphany Smoke"])?;
|
||||
for (path, content) in files {
|
||||
let path = root.join(path);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, content)?;
|
||||
}
|
||||
run_git(root, &["add", "."])?;
|
||||
run_git(root, &["commit", "-m", commit_message])?;
|
||||
Ok(root.to_path_buf())
|
||||
}
|
||||
|
||||
fn run_personality(root: &Path, args: &[&str]) -> Result<Value> {
|
||||
let exe = native_personality_exe()?;
|
||||
ensure_built(root, "epiphany-repo-personality", &exe)?;
|
||||
let output = Command::new(exe)
|
||||
.current_dir(root)
|
||||
.args(args)
|
||||
.output()
|
||||
.context("failed to run epiphany-repo-personality")?;
|
||||
require(
|
||||
output.status.success(),
|
||||
&format!(
|
||||
"epiphany-repo-personality failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
),
|
||||
)?;
|
||||
serde_json::from_slice(&output.stdout).with_context(|| {
|
||||
format!(
|
||||
"epiphany-repo-personality did not return JSON: {}",
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn run_birth_runner(root: &Path, args: &[&str]) -> Result<Value> {
|
||||
let exe = native_birth_runner_exe()?;
|
||||
ensure_built(root, "epiphany-repo-birth-runner", &exe)?;
|
||||
let output = Command::new(exe)
|
||||
.current_dir(root)
|
||||
.args(args)
|
||||
.output()
|
||||
.context("failed to run epiphany-repo-birth-runner")?;
|
||||
require(
|
||||
output.status.success(),
|
||||
&format!(
|
||||
"epiphany-repo-birth-runner failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
),
|
||||
)?;
|
||||
serde_json::from_slice(&output.stdout).with_context(|| {
|
||||
format!(
|
||||
"epiphany-repo-birth-runner did not return JSON: {}",
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn run_heartbeat(root: &Path, args: &[&str]) -> Result<Value> {
|
||||
let exe = native_heartbeat_exe()?;
|
||||
ensure_built(root, "epiphany-heartbeat-store", &exe)?;
|
||||
let output = Command::new(exe)
|
||||
.current_dir(root)
|
||||
.args(args)
|
||||
.output()
|
||||
.context("failed to run epiphany-heartbeat-store")?;
|
||||
require(
|
||||
output.status.success(),
|
||||
&format!(
|
||||
"epiphany-heartbeat-store failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
),
|
||||
)?;
|
||||
serde_json::from_slice(&output.stdout).with_context(|| {
|
||||
format!(
|
||||
"epiphany-heartbeat-store did not return JSON: {}",
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn native_personality_exe() -> Result<PathBuf> {
|
||||
let target_dir = env::var_os("CARGO_TARGET_DIR")
|
||||
.unwrap_or_else(|| r"C:\Users\Meta\.cargo-target-codex".into());
|
||||
Ok(PathBuf::from(target_dir).join("debug").join(format!(
|
||||
"epiphany-repo-personality{}",
|
||||
env::consts::EXE_SUFFIX
|
||||
)))
|
||||
}
|
||||
|
||||
fn native_birth_runner_exe() -> Result<PathBuf> {
|
||||
let target_dir = env::var_os("CARGO_TARGET_DIR")
|
||||
.unwrap_or_else(|| r"C:\Users\Meta\.cargo-target-codex".into());
|
||||
Ok(PathBuf::from(target_dir).join("debug").join(format!(
|
||||
"epiphany-repo-birth-runner{}",
|
||||
env::consts::EXE_SUFFIX
|
||||
)))
|
||||
}
|
||||
|
||||
fn native_heartbeat_exe() -> Result<PathBuf> {
|
||||
let target_dir = env::var_os("CARGO_TARGET_DIR")
|
||||
.unwrap_or_else(|| r"C:\Users\Meta\.cargo-target-codex".into());
|
||||
Ok(PathBuf::from(target_dir).join("debug").join(format!(
|
||||
"epiphany-heartbeat-store{}",
|
||||
env::consts::EXE_SUFFIX
|
||||
)))
|
||||
}
|
||||
|
||||
fn ensure_built(root: &Path, bin: &str, exe: &Path) -> Result<()> {
|
||||
let status = Command::new("cargo")
|
||||
.current_dir(root)
|
||||
.arg("build")
|
||||
.arg("--manifest-path")
|
||||
.arg(root.join("epiphany-core").join("Cargo.toml"))
|
||||
.arg("--bin")
|
||||
.arg(bin)
|
||||
.status()
|
||||
.with_context(|| format!("failed to build {bin}"))?;
|
||||
require(
|
||||
status.success() && exe.exists(),
|
||||
&format!("{bin} executable was not built at {}", exe.display()),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_git(repo: &Path, args: &[&str]) -> Result<()> {
|
||||
let output = Command::new("git")
|
||||
.current_dir(repo)
|
||||
.args(args)
|
||||
.output()
|
||||
.with_context(|| format!("failed to run git {}", args.join(" ")))?;
|
||||
require(
|
||||
output.status.success(),
|
||||
&format!(
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn reset_path(path: &Path) -> Result<()> {
|
||||
if path.exists() {
|
||||
fs::remove_dir_all(path).with_context(|| format!("failed to reset {}", path.display()))?;
|
||||
}
|
||||
fs::create_dir_all(path).with_context(|| format!("failed to create {}", path.display()))
|
||||
}
|
||||
|
||||
fn path_value(value: &Value, key: &str) -> Result<PathBuf> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("missing path value {key}"))
|
||||
}
|
||||
|
||||
fn packet_for_kind(value: &Value, kind: &str) -> Result<PathBuf> {
|
||||
value["generatedPackets"]
|
||||
.as_array()
|
||||
.and_then(|packets| {
|
||||
packets
|
||||
.iter()
|
||||
.find(|packet| packet["kind"] == kind)
|
||||
.and_then(|packet| packet["packetPath"].as_str())
|
||||
})
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("missing generated packet for {kind}"))
|
||||
}
|
||||
|
||||
fn require(condition: bool, message: &str) -> Result<()> {
|
||||
if condition {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!(message.to_string()))
|
||||
}
|
||||
}
|
||||
4515
epiphany-core/src/bin/epiphany-repo-personality.rs
Normal file
4515
epiphany-core/src/bin/epiphany-repo-personality.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,169 +0,0 @@
|
|||
use anyhow::{Result, bail};
|
||||
use chrono::Utc;
|
||||
use epiphany_core::{
|
||||
EpiphanyMemoryAnchor, EpiphanyMemoryDomain, EpiphanyMemoryLifecycle, EpiphanyMemoryNode,
|
||||
EpiphanyMemoryNodeKind, EpiphanyRepoModelSeed, EpiphanyRepoModelSeedDocuments, ObserveOutcome,
|
||||
RuntimeSpineInitOptions, admit_repository_body_observation, bind_repository_body,
|
||||
bind_runtime_to_swarm, initialize_keyed_repo_model, initialize_runtime_spine,
|
||||
load_current_runtime_repository_body_basis, load_repository_body_status,
|
||||
observe_repository_body,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||
let Some(command) = args.first().map(String::as_str) else {
|
||||
return usage();
|
||||
};
|
||||
match command {
|
||||
"bootstrap" => {
|
||||
let repo = PathBuf::from(required(&args, "--repo")?);
|
||||
let store = PathBuf::from(required(&args, "--store")?);
|
||||
let runtime_store = PathBuf::from(required(&args, "--runtime-store")?);
|
||||
let workspace_id = required(&args, "--workspace-id")?;
|
||||
let runtime_id = required(&args, "--runtime-id")?;
|
||||
let swarm_id = required(&args, "--swarm-id")?;
|
||||
let at = Utc::now().to_rfc3339();
|
||||
initialize_runtime_spine(
|
||||
&runtime_store,
|
||||
RuntimeSpineInitOptions {
|
||||
runtime_id: runtime_id.to_string(),
|
||||
display_name: format!("Epiphany {runtime_id}"),
|
||||
created_at: at.clone(),
|
||||
},
|
||||
)?;
|
||||
bind_runtime_to_swarm(&runtime_store, swarm_id, &at)?;
|
||||
let binding = bind_repository_body(&repo, &store, &runtime_store, workspace_id)?;
|
||||
observe_repository_body(&repo, &store, &runtime_store)?;
|
||||
let body_basis = load_current_runtime_repository_body_basis(&runtime_store)?;
|
||||
admit_repository_body_observation(&runtime_store, &body_basis)?;
|
||||
let seed = EpiphanyRepoModelSeed::new(
|
||||
format!("repo-model-seed-{}", binding.runtime_id),
|
||||
format!("{}-repo-model", binding.runtime_id),
|
||||
binding.swarm_id.clone(),
|
||||
binding.workspace_id.clone(),
|
||||
body_basis.body_binding_sha256.clone(),
|
||||
EpiphanyRepoModelSeedDocuments {
|
||||
domains: vec![EpiphanyMemoryDomain {
|
||||
id: "repository-body".to_string(),
|
||||
title: "Deployed repository Body".to_string(),
|
||||
description: Some(
|
||||
"Cold-start substrate binding; live Modeling owns architectural expansion."
|
||||
.to_string(),
|
||||
),
|
||||
lifecycle: EpiphanyMemoryLifecycle::Accepted,
|
||||
}],
|
||||
nodes: vec![EpiphanyMemoryNode {
|
||||
id: "claim-deployed-repository-body".to_string(),
|
||||
domain_id: "repository-body".to_string(),
|
||||
kind: EpiphanyMemoryNodeKind::RuntimeContract,
|
||||
title: "Runtime is bound to the deployed repository Body".to_string(),
|
||||
claim: format!(
|
||||
"Runtime {} models workspace {} at its authenticated Git Body.",
|
||||
binding.runtime_id, binding.workspace_id
|
||||
),
|
||||
question: "What architecture does live Modeling admit from this Body?"
|
||||
.to_string(),
|
||||
action_implication: "Expand only through Body-grounded Modeling admission."
|
||||
.to_string(),
|
||||
anchors: vec![EpiphanyMemoryAnchor {
|
||||
id: "anchor-deployed-repository-body".to_string(),
|
||||
kind: "repository_body_binding".to_string(),
|
||||
target: binding.git_top_level.clone(),
|
||||
source_hash: Some(binding.source_identity_sha256.clone()),
|
||||
note: Some(
|
||||
"Cold-start anchor to the authenticated deployed Git Body."
|
||||
.to_string(),
|
||||
),
|
||||
..Default::default()
|
||||
}],
|
||||
lifecycle: EpiphanyMemoryLifecycle::Accepted,
|
||||
..Default::default()
|
||||
}],
|
||||
edges: Vec::new(),
|
||||
frontier: Vec::new(),
|
||||
},
|
||||
)?;
|
||||
initialize_keyed_repo_model(&runtime_store, &seed, &at)?;
|
||||
println!(
|
||||
"bootstrapped workspace={} swarm={} runtime={} scope={} root={}",
|
||||
binding.workspace_id,
|
||||
binding.swarm_id,
|
||||
binding.runtime_id,
|
||||
binding.scope,
|
||||
binding.git_top_level
|
||||
);
|
||||
}
|
||||
"bind" => {
|
||||
let binding = bind_repository_body(
|
||||
&PathBuf::from(required(&args, "--repo")?),
|
||||
&PathBuf::from(required(&args, "--store")?),
|
||||
&PathBuf::from(required(&args, "--runtime-store")?),
|
||||
required(&args, "--workspace-id")?,
|
||||
)?;
|
||||
println!(
|
||||
"bound workspace={} swarm={} runtime={} scope={} root={}",
|
||||
binding.workspace_id,
|
||||
binding.swarm_id,
|
||||
binding.runtime_id,
|
||||
binding.scope,
|
||||
binding.git_top_level
|
||||
);
|
||||
}
|
||||
"observe" => {
|
||||
let repo = required(&args, "--repo")?;
|
||||
let store = required(&args, "--store")?;
|
||||
let runtime_store = required(&args, "--runtime-store")?;
|
||||
let outcome = observe_repository_body(
|
||||
&PathBuf::from(repo),
|
||||
&PathBuf::from(store),
|
||||
&PathBuf::from(runtime_store),
|
||||
)?;
|
||||
let basis = load_current_runtime_repository_body_basis(&PathBuf::from(runtime_store))?;
|
||||
admit_repository_body_observation(&PathBuf::from(runtime_store), &basis)?;
|
||||
match outcome {
|
||||
ObserveOutcome::Created(value) => println!(
|
||||
"created generation={} tree={}",
|
||||
value.generation, value.tree_oid
|
||||
),
|
||||
ObserveOutcome::Unchanged(value) => println!(
|
||||
"unchanged generation={} tree={}",
|
||||
value.generation, value.tree_oid
|
||||
),
|
||||
}
|
||||
}
|
||||
"status" => {
|
||||
let store = PathBuf::from(required(&args, "--store")?);
|
||||
match load_repository_body_status(&store)? {
|
||||
None => println!("missing"),
|
||||
Some((binding, value)) => println!(
|
||||
"observed workspace={} swarm={} runtime={} scope={} generation={} tree={} head={}",
|
||||
binding.workspace_id,
|
||||
binding.swarm_id,
|
||||
binding.runtime_id,
|
||||
binding.scope,
|
||||
value.generation,
|
||||
value.tree_oid,
|
||||
value.head_oid.as_deref().unwrap_or("unborn")
|
||||
),
|
||||
}
|
||||
}
|
||||
_ => return usage(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn required<'a>(args: &'a [String], name: &str) -> Result<&'a str> {
|
||||
let index = args
|
||||
.iter()
|
||||
.position(|arg| arg == name)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing {name}"))?;
|
||||
args.get(index + 1)
|
||||
.map(String::as_str)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing value for {name}"))
|
||||
}
|
||||
fn usage<T>() -> Result<T> {
|
||||
bail!(
|
||||
"usage: epiphany-repository-body bootstrap --repo PATH --store PATH --runtime-store PATH --workspace-id ID --runtime-id ID --swarm-id ID | bind --repo PATH --store PATH --runtime-store PATH --workspace-id ID | observe --repo PATH --store PATH --runtime-store PATH | status --store PATH"
|
||||
)
|
||||
}
|
||||
253
epiphany-core/src/bin/epiphany-rider-bridge-smoke.rs
Normal file
253
epiphany-core/src/bin/epiphany-rider-bridge-smoke.rs
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let result = run_smoke()?;
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_smoke() -> Result<Value> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let workspace = root.join(".epiphany-smoke").join("rider-bridge-workspace");
|
||||
let artifact_root = root.join(".epiphany-smoke").join("rider-bridge-artifacts");
|
||||
let fake_rider_root = root.join(".epiphany-smoke").join("rider-bridge-install");
|
||||
let result_path = root
|
||||
.join(".epiphany-smoke")
|
||||
.join("rider-bridge-smoke-result.json");
|
||||
let source = prepare_workspace(&workspace)?;
|
||||
reset_path(&artifact_root)?;
|
||||
reset_path(&fake_rider_root)?;
|
||||
let fake_rider = create_fake_rider(&fake_rider_root)?;
|
||||
|
||||
let status = run_bridge(&root, &workspace, &artifact_root, &fake_rider, &["status"])?;
|
||||
require(
|
||||
status["status"] == "ready",
|
||||
"fake Rider path should make status ready",
|
||||
)?;
|
||||
require(
|
||||
status["riderPath"] == fake_rider.to_string_lossy().as_ref(),
|
||||
"status should resolve fake Rider path",
|
||||
)?;
|
||||
require(
|
||||
status
|
||||
.get("solutionPath")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|path| path.ends_with("Aetheria.sln")),
|
||||
"status should find the workspace solution",
|
||||
)?;
|
||||
require_artifact(&status, "rider-bridge-summary.json")?;
|
||||
require_artifact(&status, "rider-bridge-status.md")?;
|
||||
|
||||
let context = run_bridge(
|
||||
&root,
|
||||
&workspace,
|
||||
&artifact_root,
|
||||
&fake_rider,
|
||||
&[
|
||||
"context",
|
||||
"--file",
|
||||
source.to_str().unwrap_or_default(),
|
||||
"--selection-start",
|
||||
"3",
|
||||
"--selection-end",
|
||||
"5",
|
||||
"--symbol-name",
|
||||
"GravityTileRenderer",
|
||||
"--symbol-kind",
|
||||
"class",
|
||||
"--symbol-namespace",
|
||||
"Aetheria.Rendering",
|
||||
],
|
||||
)?;
|
||||
require(
|
||||
context["status"] == "captured",
|
||||
"context command should capture a packet",
|
||||
)?;
|
||||
require(
|
||||
context["filePath"] == "Assets/Scripts/GravityTileRenderer.cs",
|
||||
"context file should be project-relative",
|
||||
)?;
|
||||
require_artifact(&context, "rider-context.json")?;
|
||||
require_artifact(&context, "rider-context.md")?;
|
||||
|
||||
let open_ref = run_bridge(
|
||||
&root,
|
||||
&workspace,
|
||||
&artifact_root,
|
||||
&fake_rider,
|
||||
&[
|
||||
"open-ref",
|
||||
"--file",
|
||||
source.to_str().unwrap_or_default(),
|
||||
"--line",
|
||||
"3",
|
||||
],
|
||||
)?;
|
||||
require(
|
||||
open_ref["status"] == "planned",
|
||||
"open-ref should plan without launch",
|
||||
)?;
|
||||
require(
|
||||
open_ref
|
||||
.get("command")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(Value::as_str)
|
||||
== Some(fake_rider.to_str().unwrap_or_default()),
|
||||
"open-ref should use discovered Rider path",
|
||||
)?;
|
||||
require_artifact(&open_ref, "rider-open-ref.json")?;
|
||||
|
||||
let result = json!({
|
||||
"workspace": workspace,
|
||||
"artifactRoot": artifact_root,
|
||||
"status": status["status"],
|
||||
"contextStatus": context["status"],
|
||||
"openRefStatus": open_ref["status"],
|
||||
"riderPath": status["riderPath"],
|
||||
});
|
||||
write_json(&result_path, &result)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn run_bridge(
|
||||
root: &Path,
|
||||
workspace: &Path,
|
||||
artifact_root: &Path,
|
||||
rider_path: &Path,
|
||||
args: &[&str],
|
||||
) -> Result<Value> {
|
||||
let bridge = native_bridge_exe()?;
|
||||
ensure_bridge_built(root, "epiphany-rider-bridge", &bridge)?;
|
||||
let output = Command::new(bridge)
|
||||
.current_dir(root)
|
||||
.env("EPIPHANY_RIDER_PATH", rider_path)
|
||||
.arg(args[0])
|
||||
.arg("--project-root")
|
||||
.arg(workspace)
|
||||
.arg("--artifact-root")
|
||||
.arg(artifact_root)
|
||||
.args(&args[1..])
|
||||
.output()
|
||||
.context("failed to run native rider bridge")?;
|
||||
require(
|
||||
output.status.success(),
|
||||
&format!(
|
||||
"rider bridge failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
),
|
||||
)?;
|
||||
serde_json::from_slice(&output.stdout).with_context(|| {
|
||||
format!(
|
||||
"rider bridge did not return JSON: {}",
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_workspace(workspace: &Path) -> Result<PathBuf> {
|
||||
reset_path(workspace)?;
|
||||
fs::write(
|
||||
workspace.join("Aetheria.sln"),
|
||||
"Microsoft Visual Studio Solution File\n",
|
||||
)?;
|
||||
let source = workspace
|
||||
.join("Assets")
|
||||
.join("Scripts")
|
||||
.join("GravityTileRenderer.cs");
|
||||
fs::create_dir_all(source.parent().expect("source path has parent"))?;
|
||||
fs::write(
|
||||
&source,
|
||||
"namespace Aetheria.Rendering\n{\n public sealed class GravityTileRenderer\n {\n }\n}\n",
|
||||
)?;
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
fn create_fake_rider(root: &Path) -> Result<PathBuf> {
|
||||
let rider = root
|
||||
.join("JetBrains Rider 2026.1.0.1")
|
||||
.join("bin")
|
||||
.join("rider64.exe");
|
||||
fs::create_dir_all(rider.parent().expect("rider path has parent"))?;
|
||||
fs::write(&rider, "fake rider binary for smoke planning only\n")?;
|
||||
Ok(rider)
|
||||
}
|
||||
|
||||
fn require_artifact(summary: &Value, name: &str) -> Result<()> {
|
||||
let artifact_path = summary
|
||||
.get("artifactPath")
|
||||
.and_then(Value::as_str)
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("missing artifactPath"))?;
|
||||
require(
|
||||
artifact_path.join(name).exists(),
|
||||
&format!("missing rider artifact: {name}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn native_bridge_exe() -> Result<PathBuf> {
|
||||
let target_dir = env::var_os("CARGO_TARGET_DIR")
|
||||
.unwrap_or_else(|| r"C:\Users\Meta\.cargo-target-codex".into());
|
||||
Ok(PathBuf::from(target_dir)
|
||||
.join("debug")
|
||||
.join(format!("epiphany-rider-bridge{}", env::consts::EXE_SUFFIX)))
|
||||
}
|
||||
|
||||
fn ensure_bridge_built(root: &Path, bin: &str, exe: &Path) -> Result<()> {
|
||||
if exe.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let status = Command::new("cargo")
|
||||
.current_dir(root)
|
||||
.arg("build")
|
||||
.arg("--manifest-path")
|
||||
.arg(root.join("epiphany-core").join("Cargo.toml"))
|
||||
.arg("--bin")
|
||||
.arg(bin)
|
||||
.status()
|
||||
.with_context(|| format!("failed to build native {bin}"))?;
|
||||
require(
|
||||
status.success() && exe.exists(),
|
||||
&format!("native {bin} executable was not built at {}", exe.display()),
|
||||
)
|
||||
}
|
||||
|
||||
fn reset_path(path: &Path) -> Result<()> {
|
||||
if path.exists() {
|
||||
let root = env::current_dir()?.join(".epiphany-smoke").canonicalize()?;
|
||||
let resolved = path.canonicalize()?;
|
||||
if resolved == root || !resolved.starts_with(&root) {
|
||||
return Err(anyhow!(
|
||||
"refusing to reset non-smoke path: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
fs::remove_dir_all(path)?;
|
||||
}
|
||||
fs::create_dir_all(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require(condition: bool, message: &str) -> Result<()> {
|
||||
if condition {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("{message}"))
|
||||
}
|
||||
}
|
||||
656
epiphany-core/src/bin/epiphany-rider-bridge.rs
Normal file
656
epiphany-core/src/bin/epiphany-rider-bridge.rs
Normal file
|
|
@ -0,0 +1,656 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const RIDER_EXECUTABLE_NAMES: &[&str] = &["rider64.exe", "rider.exe", "rider.bat", "rider"];
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = env::args().skip(1);
|
||||
let Some(command) = args.next() else {
|
||||
usage()?;
|
||||
return Ok(());
|
||||
};
|
||||
match run_command(&command, args.collect()) {
|
||||
Ok((code, value)) => {
|
||||
println!("{}", serde_json::to_string_pretty(&value)?);
|
||||
std::process::exit(code);
|
||||
}
|
||||
Err(error) => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"status": "error",
|
||||
"error": error.to_string(),
|
||||
}))?
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_command(command: &str, args: Vec<String>) -> Result<(i32, serde_json::Value)> {
|
||||
match command {
|
||||
"status" => {
|
||||
let options = RiderOptions::parse(args)?;
|
||||
Ok((0, status_rider(&options)?))
|
||||
}
|
||||
"context" => {
|
||||
let options = RiderOptions::parse(args)?;
|
||||
Ok((0, context_rider(&options)?))
|
||||
}
|
||||
"open-ref" => {
|
||||
let options = RiderOptions::parse(args)?;
|
||||
Ok((0, open_ref_rider(&options)?))
|
||||
}
|
||||
"guidance" => {
|
||||
let options = RiderOptions::parse(args)?;
|
||||
Ok((
|
||||
0,
|
||||
json!({ "guidance": bridge_guidance(&options.project_root)? }),
|
||||
))
|
||||
}
|
||||
_ => usage(),
|
||||
}
|
||||
}
|
||||
|
||||
fn usage() -> Result<(i32, serde_json::Value)> {
|
||||
Err(anyhow!(
|
||||
"usage: epiphany-rider-bridge <status|context|open-ref|guidance> --project-root <path>"
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RiderOptions {
|
||||
project_root: PathBuf,
|
||||
artifact_root: PathBuf,
|
||||
artifact_dir: Option<PathBuf>,
|
||||
packet: Option<PathBuf>,
|
||||
file: Option<PathBuf>,
|
||||
line: Option<u32>,
|
||||
column: Option<u32>,
|
||||
selection_start: Option<u32>,
|
||||
selection_end: Option<u32>,
|
||||
symbol_name: Option<String>,
|
||||
symbol_kind: Option<String>,
|
||||
symbol_namespace: Option<String>,
|
||||
launch: bool,
|
||||
}
|
||||
|
||||
impl RiderOptions {
|
||||
fn parse(args: Vec<String>) -> Result<Self> {
|
||||
let root = repo_root()?;
|
||||
let mut options = Self {
|
||||
project_root: env::current_dir()?,
|
||||
artifact_root: root.join(".epiphany-gui").join("rider"),
|
||||
artifact_dir: None,
|
||||
packet: None,
|
||||
file: None,
|
||||
line: None,
|
||||
column: None,
|
||||
selection_start: None,
|
||||
selection_end: None,
|
||||
symbol_name: None,
|
||||
symbol_kind: None,
|
||||
symbol_namespace: None,
|
||||
launch: false,
|
||||
};
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--project-root" => options.project_root = next_path(&mut iter, &arg)?,
|
||||
"--artifact-root" => options.artifact_root = next_path(&mut iter, &arg)?,
|
||||
"--artifact-dir" => options.artifact_dir = Some(next_path(&mut iter, &arg)?),
|
||||
"--packet" => options.packet = Some(next_path(&mut iter, &arg)?),
|
||||
"--file" => options.file = Some(next_path(&mut iter, &arg)?),
|
||||
"--line" => options.line = Some(next_value(&mut iter, &arg)?.parse()?),
|
||||
"--column" => options.column = Some(next_value(&mut iter, &arg)?.parse()?),
|
||||
"--selection-start" => {
|
||||
options.selection_start = Some(next_value(&mut iter, &arg)?.parse()?)
|
||||
}
|
||||
"--selection-end" => {
|
||||
options.selection_end = Some(next_value(&mut iter, &arg)?.parse()?)
|
||||
}
|
||||
"--symbol-name" => options.symbol_name = Some(next_value(&mut iter, &arg)?),
|
||||
"--symbol-kind" => options.symbol_kind = Some(next_value(&mut iter, &arg)?),
|
||||
"--symbol-namespace" => {
|
||||
options.symbol_namespace = Some(next_value(&mut iter, &arg)?)
|
||||
}
|
||||
"--launch" => options.launch = true,
|
||||
_ => return Err(anyhow!("unknown argument {arg:?}")),
|
||||
}
|
||||
}
|
||||
Ok(options)
|
||||
}
|
||||
}
|
||||
|
||||
fn next_value(iter: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
iter.next()
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
|
||||
fn next_path(iter: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(next_value(iter, name)?))
|
||||
}
|
||||
|
||||
fn repo_root() -> Result<PathBuf> {
|
||||
Ok(Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("epiphany-core has no parent repo root"))?
|
||||
.to_path_buf())
|
||||
}
|
||||
|
||||
fn now_iso() -> String {
|
||||
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
fn artifact_dir(root: &Path, action: &str) -> Result<PathBuf> {
|
||||
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
|
||||
Ok(root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| root.to_path_buf())
|
||||
.join(format!("rider-{action}-{nanos}-{}", std::process::id())))
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &serde_json::Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))
|
||||
.with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn write_text(path: &Path, value: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, value).with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn maybe_resolve(path: &Path) -> String {
|
||||
clean_windows_path(
|
||||
&path
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| path.to_path_buf())
|
||||
.display()
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn clean_windows_path(value: &str) -> String {
|
||||
value.strip_prefix(r"\\?\").unwrap_or(value).to_string()
|
||||
}
|
||||
|
||||
fn is_inside(path: &Path, root: &Path) -> bool {
|
||||
let Ok(path) = path.canonicalize() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(root) = root.canonicalize() else {
|
||||
return false;
|
||||
};
|
||||
path.starts_with(root)
|
||||
}
|
||||
|
||||
fn relative_or_absolute(path: &Path, root: &Path) -> String {
|
||||
match (path.canonicalize(), root.canonicalize()) {
|
||||
(Ok(path), Ok(root)) => path
|
||||
.strip_prefix(root)
|
||||
.map(|value| value.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_else(|_| path.display().to_string()),
|
||||
_ => path.display().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut result = Vec::new();
|
||||
for path in paths {
|
||||
let key = maybe_resolve(&path).to_lowercase();
|
||||
if seen.insert(key) {
|
||||
result.push(path);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn rider_search_roots() -> Vec<PathBuf> {
|
||||
let mut roots = Vec::new();
|
||||
if let Ok(value) = env::var("EPIPHANY_RIDER_ROOTS") {
|
||||
roots.extend(env::split_paths(&value));
|
||||
}
|
||||
for (variable, fallback) in [
|
||||
("ProgramFiles", r"C:\Program Files".to_string()),
|
||||
("ProgramFiles(x86)", r"C:\Program Files (x86)".to_string()),
|
||||
(
|
||||
"LOCALAPPDATA",
|
||||
dirs_home()
|
||||
.join("AppData")
|
||||
.join("Local")
|
||||
.display()
|
||||
.to_string(),
|
||||
),
|
||||
] {
|
||||
let base = env::var(variable).unwrap_or(fallback);
|
||||
roots.push(PathBuf::from(base).join("JetBrains"));
|
||||
}
|
||||
dedupe_paths(roots)
|
||||
}
|
||||
|
||||
fn dirs_home() -> PathBuf {
|
||||
env::var_os("USERPROFILE")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| env::var_os("HOME").map(PathBuf::from))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn discover_rider_installations() -> Result<Vec<serde_json::Value>> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Ok(value) = env::var("EPIPHANY_RIDER_PATH") {
|
||||
candidates.push(PathBuf::from(value));
|
||||
}
|
||||
if let Some(path_var) = env::var_os("PATH") {
|
||||
for dir in env::split_paths(&path_var) {
|
||||
for name in RIDER_EXECUTABLE_NAMES {
|
||||
candidates.push(dir.join(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
for root in rider_search_roots() {
|
||||
if !root.exists() {
|
||||
continue;
|
||||
}
|
||||
for entry in fs::read_dir(root)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !path.is_dir() || !name.contains("Rider") {
|
||||
continue;
|
||||
}
|
||||
for exe in RIDER_EXECUTABLE_NAMES {
|
||||
candidates.push(path.join("bin").join(exe));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut installations = Vec::new();
|
||||
for path in dedupe_paths(candidates) {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let version_hint = path
|
||||
.components()
|
||||
.rev()
|
||||
.filter_map(|part| part.as_os_str().to_str())
|
||||
.find(|part| part.contains("Rider"))
|
||||
.map(ToOwned::to_owned);
|
||||
installations.push(json!({
|
||||
"path": maybe_resolve(&path),
|
||||
"exists": true,
|
||||
"versionHint": version_hint,
|
||||
}));
|
||||
}
|
||||
Ok(installations)
|
||||
}
|
||||
|
||||
fn discover_solution(project_root: &Path) -> Result<serde_json::Value> {
|
||||
let root = project_root.canonicalize()?;
|
||||
let mut top_level = Vec::new();
|
||||
for entry in fs::read_dir(&root)? {
|
||||
let path = entry?.path();
|
||||
if path.extension().and_then(|value| value.to_str()) == Some("sln") {
|
||||
top_level.push(path);
|
||||
}
|
||||
}
|
||||
top_level.sort();
|
||||
if !top_level.is_empty() {
|
||||
return Ok(json!({
|
||||
"status": "ready",
|
||||
"path": top_level[0],
|
||||
"candidates": top_level.iter().take(8).collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
let mut candidates = Vec::new();
|
||||
collect_solutions(&root, &root, &mut candidates)?;
|
||||
Ok(json!({
|
||||
"status": if candidates.is_empty() { "missingSolution" } else { "ready" },
|
||||
"path": candidates.first(),
|
||||
"candidates": candidates.iter().take(8).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn collect_solutions(root: &Path, current: &Path, candidates: &mut Vec<PathBuf>) -> Result<()> {
|
||||
if candidates.len() >= 8 {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(current)? {
|
||||
let path = entry?.path();
|
||||
let rel = path.strip_prefix(root).unwrap_or(&path);
|
||||
if rel.components().any(|component| {
|
||||
let value = component.as_os_str().to_string_lossy();
|
||||
value.starts_with('.')
|
||||
|| matches!(
|
||||
value.as_ref(),
|
||||
"Library" | "Temp" | "obj" | "bin" | "node_modules"
|
||||
)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
collect_solutions(root, &path, candidates)?;
|
||||
} else if path.extension().and_then(|value| value.to_str()) == Some("sln") {
|
||||
candidates.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_git(project_root: &Path, args: &[&str]) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(project_root)
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn vcs_summary(project_root: &Path) -> serde_json::Value {
|
||||
let branch = run_git(project_root, &["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
let status = run_git(project_root, &["status", "--porcelain=v1"]);
|
||||
let changed = run_git(project_root, &["diff", "--name-only"]);
|
||||
let staged = run_git(project_root, &["diff", "--cached", "--name-only"]);
|
||||
let mut changed_files: Vec<String> = changed
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
if let Some(status) = &status {
|
||||
for line in status.lines() {
|
||||
let path = line.get(3..).unwrap_or_default().trim().to_string();
|
||||
if !path.is_empty() && !changed_files.contains(&path) {
|
||||
changed_files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"status": if branch.is_some() { "ready" } else { "notGit" },
|
||||
"branch": branch,
|
||||
"dirty": status.as_deref().is_some_and(|value| !value.is_empty()),
|
||||
"changedFiles": changed_files,
|
||||
"stagedFiles": staged.as_deref().unwrap_or_default().lines().collect::<Vec<_>>(),
|
||||
"changedRangesKnown": branch.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
fn render_status(summary: &serde_json::Value) -> String {
|
||||
format!(
|
||||
"# Rider Bridge Status\n\n- status: {}\n- workspace: `{}`\n- solution: `{}`\n- rider: `{}`\n- installations: {}\n- branch: {}\n- dirty: {}\n- note: {}\n\nRider is a source-context organ. This artifact is an operator-safe projection, not durable Epiphany truth.\n",
|
||||
summary["status"].as_str().unwrap_or("unknown"),
|
||||
summary["workspace"].as_str().unwrap_or("unknown"),
|
||||
summary["solutionPath"].as_str().unwrap_or("none"),
|
||||
summary["riderPath"].as_str().unwrap_or("missing"),
|
||||
summary["installationCount"].as_u64().unwrap_or_default(),
|
||||
summary["vcs"]["branch"].as_str().unwrap_or("unknown"),
|
||||
summary["vcs"]["dirty"].as_bool().unwrap_or(false),
|
||||
summary["note"].as_str().unwrap_or(""),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_context(packet: &serde_json::Value, summary: &serde_json::Value) -> String {
|
||||
format!(
|
||||
"# Rider Context Packet\n\n- status: {}\n- project: `{}`\n- solution: `{}`\n- file: `{}`\n- selection: {}-{}\n- symbol: {}\n- artifact: `{}`\n\nModeling may use this packet as scratch/context. It is not accepted map state until reviewed.\n",
|
||||
summary["status"].as_str().unwrap_or("unknown"),
|
||||
packet["projectRoot"].as_str().unwrap_or("unknown"),
|
||||
packet["solutionPath"].as_str().unwrap_or("none"),
|
||||
packet["filePath"].as_str().unwrap_or("none"),
|
||||
packet["selection"]["startLine"]
|
||||
.as_i64()
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "none".to_string()),
|
||||
packet["selection"]["endLine"]
|
||||
.as_i64()
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "none".to_string()),
|
||||
packet["symbol"]["name"].as_str().unwrap_or("none"),
|
||||
summary["artifactPath"].as_str().unwrap_or("unknown"),
|
||||
)
|
||||
}
|
||||
|
||||
fn status_rider(options: &RiderOptions) -> Result<serde_json::Value> {
|
||||
let project_root = options.project_root.canonicalize()?;
|
||||
let directory = options
|
||||
.artifact_dir
|
||||
.clone()
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| artifact_dir(&options.artifact_root, "inspect"))?;
|
||||
let installations = discover_rider_installations()?;
|
||||
let solution = discover_solution(&project_root)?;
|
||||
let first_rider = installations.first();
|
||||
let status = if first_rider.is_some() {
|
||||
"ready"
|
||||
} else {
|
||||
"missingRider"
|
||||
};
|
||||
let note = if first_rider.is_some() {
|
||||
"Rider executable is available for explicit source navigation and context capture."
|
||||
} else {
|
||||
"No Rider executable was found. Install Rider or set EPIPHANY_RIDER_PATH."
|
||||
};
|
||||
let summary = json!({
|
||||
"kind": "riderBridgeStatus",
|
||||
"status": status,
|
||||
"workspace": project_root,
|
||||
"solutionPath": solution["path"],
|
||||
"solutionStatus": solution["status"],
|
||||
"solutionCandidates": solution["candidates"],
|
||||
"riderPath": first_rider.and_then(|value| value["path"].as_str()),
|
||||
"installationCount": installations.len(),
|
||||
"installations": installations,
|
||||
"searchRoots": rider_search_roots(),
|
||||
"vcs": vcs_summary(&project_root),
|
||||
"capturedAt": now_iso(),
|
||||
"artifactPath": directory,
|
||||
"note": note,
|
||||
});
|
||||
write_json(&directory.join("rider-bridge-summary.json"), &summary)?;
|
||||
write_text(
|
||||
&directory.join("rider-bridge-status.md"),
|
||||
&render_status(&summary),
|
||||
)?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn packet_from_options(options: &RiderOptions, project_root: &Path) -> Result<serde_json::Value> {
|
||||
if let Some(packet) = &options.packet {
|
||||
let value: serde_json::Value = serde_json::from_str(&fs::read_to_string(packet)?)?;
|
||||
if !value.is_object() {
|
||||
return Err(anyhow!("context packet must be a JSON object"));
|
||||
}
|
||||
return Ok(value);
|
||||
}
|
||||
let solution = discover_solution(project_root)?;
|
||||
let file_path = options
|
||||
.file
|
||||
.as_ref()
|
||||
.map(|path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf()));
|
||||
if let Some(file_path) = &file_path
|
||||
&& !is_inside(file_path, project_root)
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"context file is outside project root: {}",
|
||||
file_path.display()
|
||||
));
|
||||
}
|
||||
Ok(json!({
|
||||
"kind": "riderContext",
|
||||
"capturedAt": now_iso(),
|
||||
"projectRoot": project_root,
|
||||
"solutionPath": solution["path"],
|
||||
"filePath": file_path.as_ref().map(|path| relative_or_absolute(path, project_root)),
|
||||
"caret": options.line.map(|line| json!({ "line": line, "column": options.column })),
|
||||
"selection": if options.selection_start.is_some() || options.selection_end.is_some() {
|
||||
json!({ "startLine": options.selection_start, "endLine": options.selection_end })
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
},
|
||||
"symbol": options.symbol_name.as_ref().map(|name| json!({
|
||||
"name": name,
|
||||
"kind": options.symbol_kind,
|
||||
"namespace": options.symbol_namespace,
|
||||
})),
|
||||
"vcs": vcs_summary(project_root),
|
||||
}))
|
||||
}
|
||||
|
||||
fn context_rider(options: &RiderOptions) -> Result<serde_json::Value> {
|
||||
let project_root = options.project_root.canonicalize()?;
|
||||
let directory = options
|
||||
.artifact_dir
|
||||
.clone()
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| artifact_dir(&options.artifact_root, "context"))?;
|
||||
let packet = packet_from_options(options, &project_root)?;
|
||||
let mut status_options = RiderOptions {
|
||||
..options.clone_for_status(directory.clone())
|
||||
};
|
||||
status_options.artifact_dir = Some(directory.clone());
|
||||
let mut summary = status_rider(&status_options)?;
|
||||
summary["kind"] = json!("riderContextCapture");
|
||||
summary["status"] = json!("captured");
|
||||
summary["contextPath"] = json!(directory.join("rider-context.json"));
|
||||
summary["filePath"] = packet["filePath"].clone();
|
||||
summary["symbol"] = packet["symbol"].clone();
|
||||
write_json(&directory.join("rider-context.json"), &packet)?;
|
||||
write_json(&directory.join("rider-bridge-summary.json"), &summary)?;
|
||||
write_text(
|
||||
&directory.join("rider-context.md"),
|
||||
&render_context(&packet, &summary),
|
||||
)?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
impl RiderOptions {
|
||||
fn clone_for_status(&self, directory: PathBuf) -> Self {
|
||||
Self {
|
||||
project_root: self.project_root.clone(),
|
||||
artifact_root: self.artifact_root.clone(),
|
||||
artifact_dir: Some(directory),
|
||||
packet: None,
|
||||
file: None,
|
||||
line: None,
|
||||
column: None,
|
||||
selection_start: None,
|
||||
selection_end: None,
|
||||
symbol_name: None,
|
||||
symbol_kind: None,
|
||||
symbol_namespace: None,
|
||||
launch: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_ref_rider(options: &RiderOptions) -> Result<serde_json::Value> {
|
||||
let project_root = options.project_root.canonicalize()?;
|
||||
let directory = options
|
||||
.artifact_dir
|
||||
.clone()
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| artifact_dir(&options.artifact_root, "open-ref"))?;
|
||||
let status_options = options.clone_for_status(directory.clone());
|
||||
let status = status_rider(&status_options)?;
|
||||
let file_path = options
|
||||
.file
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("open-ref requires --file"))?
|
||||
.canonicalize()?;
|
||||
if !is_inside(&file_path, &project_root) {
|
||||
return Err(anyhow!(
|
||||
"code ref is outside project root: {}",
|
||||
file_path.display()
|
||||
));
|
||||
}
|
||||
let rider_path = status["riderPath"].as_str();
|
||||
let mut command = Vec::<String>::new();
|
||||
if let Some(path) = rider_path {
|
||||
command.push(path.to_string());
|
||||
command.push(file_path.display().to_string());
|
||||
if let Some(line) = options.line {
|
||||
command.push("--line".to_string());
|
||||
command.push(line.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(path) = rider_path
|
||||
&& options.launch
|
||||
{
|
||||
let mut launch = Command::new(path);
|
||||
launch.arg(&file_path);
|
||||
if let Some(line) = options.line {
|
||||
launch.arg("--line").arg(line.to_string());
|
||||
}
|
||||
launch.current_dir(&project_root).spawn()?;
|
||||
}
|
||||
let summary = json!({
|
||||
"kind": "riderOpenRef",
|
||||
"status": if rider_path.is_some() { if options.launch { "launched" } else { "planned" } } else { "missingRider" },
|
||||
"workspace": status["workspace"],
|
||||
"solutionPath": status["solutionPath"],
|
||||
"solutionStatus": status["solutionStatus"],
|
||||
"solutionCandidates": status["solutionCandidates"],
|
||||
"riderPath": status["riderPath"],
|
||||
"installationCount": status["installationCount"],
|
||||
"installations": status["installations"],
|
||||
"searchRoots": status["searchRoots"],
|
||||
"vcs": status["vcs"],
|
||||
"capturedAt": status["capturedAt"],
|
||||
"artifactPath": directory,
|
||||
"note": status["note"],
|
||||
"filePath": file_path,
|
||||
"line": options.line,
|
||||
"column": options.column,
|
||||
"command": command,
|
||||
"launched": options.launch && rider_path.is_some(),
|
||||
});
|
||||
write_json(&directory.join("rider-open-ref.json"), &summary)?;
|
||||
write_json(&directory.join("rider-bridge-summary.json"), &summary)?;
|
||||
write_text(
|
||||
&directory.join("rider-bridge-status.md"),
|
||||
&render_status(&summary),
|
||||
)?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn bridge_guidance(project_root: &Path) -> Result<String> {
|
||||
let root = repo_root()?;
|
||||
let installations = discover_rider_installations()?;
|
||||
let solution = discover_solution(project_root)?;
|
||||
let status_command = format!(
|
||||
"`epiphany-rider-bridge status --project-root {}`",
|
||||
project_root.display()
|
||||
);
|
||||
if let Some(first) = installations.first() {
|
||||
let solution_text = solution["path"].as_str().unwrap_or("no solution found yet");
|
||||
Ok(format!(
|
||||
"- Rider bridge: Rider is available at `{}` and solution context is `{}`. Use {} for an auditable source/IDE status receipt, and use `context --file <path> --selection-start <line> --selection-end <line> --symbol-name <name>` when the future plugin or operator sends a bounded source slice. Rider facts are scratch/context until modeling or verification accepts them.",
|
||||
first["path"].as_str().unwrap_or("unknown"),
|
||||
solution_text,
|
||||
status_command,
|
||||
))
|
||||
} else {
|
||||
Ok(format!(
|
||||
"- Rider bridge: no Rider executable was found. Use {} to write an auditable missing-IDE artifact, or set EPIPHANY_RIDER_PATH. Do not pretend IDE diagnostics, changed ranges, or source navigation were captured until the bridge has a receipt. Native bridge binary is rooted at `{}`.",
|
||||
status_command,
|
||||
root.display(),
|
||||
))
|
||||
}
|
||||
}
|
||||
613
epiphany-core/src/bin/epiphany-runtime-spine.rs
Normal file
613
epiphany-core/src/bin/epiphany-runtime-spine.rs
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use chrono::SecondsFormat;
|
||||
use epiphany_core::EpiphanyRuntimeEvent;
|
||||
use epiphany_core::EpiphanyRuntimeJob;
|
||||
use epiphany_core::EpiphanyRuntimeJobResult;
|
||||
use epiphany_core::RuntimeSpineEventOptions;
|
||||
use epiphany_core::RuntimeSpineInitOptions;
|
||||
use epiphany_core::RuntimeSpineJobOptions;
|
||||
use epiphany_core::RuntimeSpineJobResultOptions;
|
||||
use epiphany_core::RuntimeSpineSessionOptions;
|
||||
use epiphany_core::append_runtime_event;
|
||||
use epiphany_core::complete_runtime_job;
|
||||
use epiphany_core::create_runtime_job;
|
||||
use epiphany_core::create_runtime_session;
|
||||
use epiphany_core::initialize_runtime_spine;
|
||||
use epiphany_core::runtime_spine_cache;
|
||||
use epiphany_core::runtime_spine_status;
|
||||
use epiphany_core::write_runtime_hello_frame;
|
||||
use epiphany_core::write_runtime_schema_catalog_json;
|
||||
use epiphany_model_adapter::EpiphanyModelInputItem;
|
||||
use epiphany_model_adapter::EpiphanyModelRequest;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
const DEFAULT_STORE: &str = "state/runtime-spine.cc";
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
match args.command {
|
||||
Command::Init {
|
||||
runtime_id,
|
||||
display_name,
|
||||
} => {
|
||||
let identity = initialize_runtime_spine(
|
||||
&args.store,
|
||||
RuntimeSpineInitOptions {
|
||||
runtime_id,
|
||||
display_name,
|
||||
created_at: now(),
|
||||
},
|
||||
)?;
|
||||
println!("runtime spine initialized");
|
||||
println!("store: {}", args.store.display());
|
||||
println!("runtime: {}", identity.runtime_id);
|
||||
println!(
|
||||
"documents: {}",
|
||||
identity.supported_document_types.join(", ")
|
||||
);
|
||||
}
|
||||
Command::Status => {
|
||||
let status = runtime_spine_status(&args.store)?;
|
||||
println!("runtime spine status");
|
||||
println!("store: {}", status.store);
|
||||
println!("present: {}", status.present);
|
||||
println!(
|
||||
"runtime: {}",
|
||||
status.runtime_id.unwrap_or_else(|| "missing".to_string())
|
||||
);
|
||||
println!(
|
||||
"display: {}",
|
||||
status.display_name.unwrap_or_else(|| "missing".to_string())
|
||||
);
|
||||
println!(
|
||||
"sessions: {} active: {}",
|
||||
status.sessions, status.active_sessions
|
||||
);
|
||||
println!("jobs: {} open: {}", status.jobs, status.open_jobs);
|
||||
println!("job results: {}", status.job_results);
|
||||
println!("events: {}", status.events);
|
||||
println!(
|
||||
"tool invocations: {} pending: {} receipts: {}",
|
||||
status.tool_invocation_intents,
|
||||
status.pending_tool_invocations,
|
||||
status.tool_invocation_receipts
|
||||
);
|
||||
if !status.supported_document_types.is_empty() {
|
||||
println!("documents: {}", status.supported_document_types.join(", "));
|
||||
}
|
||||
}
|
||||
Command::ListJobs => {
|
||||
let mut cache = runtime_spine_cache(&args.store)?;
|
||||
cache.pull_all_backing_stores()?;
|
||||
let mut jobs = cache.get_all::<EpiphanyRuntimeJob>()?;
|
||||
let results = cache.get_all::<EpiphanyRuntimeJobResult>()?;
|
||||
jobs.sort_by(|left, right| left.created_at.cmp(&right.created_at));
|
||||
println!("runtime jobs");
|
||||
for job in jobs {
|
||||
let result = results.iter().find(|result| result.job_id == job.job_id);
|
||||
println!(
|
||||
"{}\t{:?}\t{}\t{}\t{}",
|
||||
job.job_id,
|
||||
job.status,
|
||||
job.role,
|
||||
result
|
||||
.map(|result| result.result_id.as_str())
|
||||
.unwrap_or("no-result"),
|
||||
job.summary.replace(['\r', '\n', '\t'], " ")
|
||||
);
|
||||
}
|
||||
}
|
||||
Command::ListModelRequests => {
|
||||
let mut cache = runtime_spine_cache(&args.store)?;
|
||||
cache.pull_all_backing_stores()?;
|
||||
let mut requests = cache.get_all::<EpiphanyModelRequest>()?;
|
||||
requests.sort_by(|left, right| left.request_id.cmp(&right.request_id));
|
||||
println!("model requests");
|
||||
for request in requests {
|
||||
let input_chars = request
|
||||
.input
|
||||
.iter()
|
||||
.map(model_input_item_chars)
|
||||
.sum::<usize>();
|
||||
println!(
|
||||
"{}\t{}\t{}\tinstructions={}\tinputItems={}\tinputChars={}\tcontract={}",
|
||||
request.request_id,
|
||||
request.provider,
|
||||
request.model,
|
||||
request.instructions.chars().count(),
|
||||
request.input.len(),
|
||||
input_chars,
|
||||
request.output_contract_id.as_deref().unwrap_or("none")
|
||||
);
|
||||
}
|
||||
}
|
||||
Command::ListEvents => {
|
||||
let mut cache = runtime_spine_cache(&args.store)?;
|
||||
cache.pull_all_backing_stores()?;
|
||||
let mut events = cache.get_all::<EpiphanyRuntimeEvent>()?;
|
||||
events.sort_by(|left, right| {
|
||||
left.occurred_at
|
||||
.cmp(&right.occurred_at)
|
||||
.then_with(|| left.event_id.cmp(&right.event_id))
|
||||
});
|
||||
println!("runtime events");
|
||||
for event in events {
|
||||
println!(
|
||||
"{}\t{}\t{}\t{}\t{}",
|
||||
event.occurred_at,
|
||||
event.event_type,
|
||||
event.job_id.as_deref().unwrap_or("no-job"),
|
||||
event.source,
|
||||
event.summary.replace(['\r', '\n', '\t'], " ")
|
||||
);
|
||||
}
|
||||
}
|
||||
Command::OpenSession {
|
||||
session_id,
|
||||
objective,
|
||||
coordinator_note,
|
||||
} => {
|
||||
let session = create_runtime_session(
|
||||
&args.store,
|
||||
RuntimeSpineSessionOptions {
|
||||
session_id,
|
||||
objective,
|
||||
created_at: now(),
|
||||
coordinator_note,
|
||||
},
|
||||
)?;
|
||||
println!("runtime session opened");
|
||||
println!("session: {}", session.session_id);
|
||||
println!("objective: {}", session.objective);
|
||||
}
|
||||
Command::RecordEvent {
|
||||
event_id,
|
||||
event_type,
|
||||
source,
|
||||
session_id,
|
||||
job_id,
|
||||
summary,
|
||||
} => {
|
||||
let event = append_runtime_event(
|
||||
&args.store,
|
||||
RuntimeSpineEventOptions {
|
||||
event_id,
|
||||
occurred_at: now(),
|
||||
event_type,
|
||||
source,
|
||||
session_id,
|
||||
job_id,
|
||||
summary,
|
||||
},
|
||||
)?;
|
||||
println!("runtime event recorded");
|
||||
println!("event: {}", event.event_id);
|
||||
println!("type: {}", event.event_type);
|
||||
}
|
||||
Command::OpenJob {
|
||||
job_id,
|
||||
session_id,
|
||||
role,
|
||||
summary,
|
||||
artifact_refs,
|
||||
} => {
|
||||
let job = create_runtime_job(
|
||||
&args.store,
|
||||
RuntimeSpineJobOptions {
|
||||
job_id,
|
||||
session_id,
|
||||
role,
|
||||
created_at: now(),
|
||||
summary,
|
||||
artifact_refs,
|
||||
},
|
||||
)?;
|
||||
println!("runtime job opened");
|
||||
println!("job: {}", job.job_id);
|
||||
println!("session: {}", job.session_id);
|
||||
println!("role: {}", job.role);
|
||||
}
|
||||
Command::CompleteJob {
|
||||
result_id,
|
||||
job_id,
|
||||
verdict,
|
||||
summary,
|
||||
next_safe_move,
|
||||
evidence_refs,
|
||||
artifact_refs,
|
||||
} => {
|
||||
let result = complete_runtime_job(
|
||||
&args.store,
|
||||
RuntimeSpineJobResultOptions {
|
||||
result_id,
|
||||
job_id,
|
||||
completed_at: now(),
|
||||
verdict,
|
||||
summary,
|
||||
next_safe_move,
|
||||
evidence_refs,
|
||||
artifact_refs,
|
||||
},
|
||||
)?;
|
||||
println!("runtime job completed");
|
||||
println!("result: {}", result.result_id);
|
||||
println!("job: {}", result.job_id);
|
||||
println!("verdict: {}", result.verdict);
|
||||
}
|
||||
Command::HelloFrame { output } => {
|
||||
let bytes = write_runtime_hello_frame(&args.store, &output)?;
|
||||
println!("cultnet hello frame written");
|
||||
println!("path: {}", output.display());
|
||||
println!("bytes: {bytes}");
|
||||
}
|
||||
Command::SchemaCatalog {
|
||||
output,
|
||||
include_schema_json,
|
||||
} => {
|
||||
let bytes = write_runtime_schema_catalog_json(&output, include_schema_json)?;
|
||||
println!("cultnet schema catalog written");
|
||||
println!("path: {}", output.display());
|
||||
println!("bytes: {bytes}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
store: PathBuf,
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Command {
|
||||
Init {
|
||||
runtime_id: String,
|
||||
display_name: String,
|
||||
},
|
||||
Status,
|
||||
ListJobs,
|
||||
ListModelRequests,
|
||||
ListEvents,
|
||||
OpenSession {
|
||||
session_id: String,
|
||||
objective: String,
|
||||
coordinator_note: String,
|
||||
},
|
||||
RecordEvent {
|
||||
event_id: String,
|
||||
event_type: String,
|
||||
source: String,
|
||||
session_id: Option<String>,
|
||||
job_id: Option<String>,
|
||||
summary: String,
|
||||
},
|
||||
OpenJob {
|
||||
job_id: String,
|
||||
session_id: String,
|
||||
role: String,
|
||||
summary: String,
|
||||
artifact_refs: Vec<String>,
|
||||
},
|
||||
CompleteJob {
|
||||
result_id: String,
|
||||
job_id: String,
|
||||
verdict: String,
|
||||
summary: String,
|
||||
next_safe_move: String,
|
||||
evidence_refs: Vec<String>,
|
||||
artifact_refs: Vec<String>,
|
||||
},
|
||||
HelloFrame {
|
||||
output: PathBuf,
|
||||
},
|
||||
SchemaCatalog {
|
||||
output: PathBuf,
|
||||
include_schema_json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let mut store = PathBuf::from(DEFAULT_STORE);
|
||||
let mut positional = Vec::new();
|
||||
let mut args = env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--store" => store = take_path(&mut args, "--store")?,
|
||||
_ => positional.push(arg),
|
||||
}
|
||||
}
|
||||
let command = parse_command(positional)?;
|
||||
Ok(Self { store, command })
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_command(mut args: Vec<String>) -> Result<Command> {
|
||||
if args.is_empty() {
|
||||
return Err(anyhow!(usage()));
|
||||
}
|
||||
let command = args.remove(0);
|
||||
match command.as_str() {
|
||||
"init" => {
|
||||
let mut runtime_id = "epiphany-local".to_string();
|
||||
let mut display_name = "Epiphany Local".to_string();
|
||||
parse_options(args, |name, value| match name {
|
||||
"--runtime-id" => {
|
||||
runtime_id = value;
|
||||
Ok(())
|
||||
}
|
||||
"--display-name" => {
|
||||
display_name = value;
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(anyhow!("unknown init argument: {name}")),
|
||||
})?;
|
||||
Ok(Command::Init {
|
||||
runtime_id,
|
||||
display_name,
|
||||
})
|
||||
}
|
||||
"status" => Ok(Command::Status),
|
||||
"list-jobs" => Ok(Command::ListJobs),
|
||||
"list-model-requests" => Ok(Command::ListModelRequests),
|
||||
"list-events" => Ok(Command::ListEvents),
|
||||
"open-session" => {
|
||||
let mut session_id = format!("session-{}", Uuid::new_v4());
|
||||
let mut objective = String::new();
|
||||
let mut coordinator_note = String::new();
|
||||
parse_options(args, |name, value| match name {
|
||||
"--session-id" => {
|
||||
session_id = value;
|
||||
Ok(())
|
||||
}
|
||||
"--objective" => {
|
||||
objective = value;
|
||||
Ok(())
|
||||
}
|
||||
"--coordinator-note" => {
|
||||
coordinator_note = value;
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(anyhow!("unknown open-session argument: {name}")),
|
||||
})?;
|
||||
if objective.trim().is_empty() {
|
||||
return Err(anyhow!("open-session requires --objective"));
|
||||
}
|
||||
Ok(Command::OpenSession {
|
||||
session_id,
|
||||
objective,
|
||||
coordinator_note,
|
||||
})
|
||||
}
|
||||
"record-event" => {
|
||||
let mut event_id = format!("event-{}", Uuid::new_v4());
|
||||
let mut event_type = String::new();
|
||||
let mut source = "operator".to_string();
|
||||
let mut session_id = None;
|
||||
let mut job_id = None;
|
||||
let mut summary = String::new();
|
||||
parse_options(args, |name, value| match name {
|
||||
"--event-id" => {
|
||||
event_id = value;
|
||||
Ok(())
|
||||
}
|
||||
"--type" => {
|
||||
event_type = value;
|
||||
Ok(())
|
||||
}
|
||||
"--source" => {
|
||||
source = value;
|
||||
Ok(())
|
||||
}
|
||||
"--session-id" => {
|
||||
session_id = Some(value);
|
||||
Ok(())
|
||||
}
|
||||
"--job-id" => {
|
||||
job_id = Some(value);
|
||||
Ok(())
|
||||
}
|
||||
"--summary" => {
|
||||
summary = value;
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(anyhow!("unknown record-event argument: {name}")),
|
||||
})?;
|
||||
if event_type.trim().is_empty() {
|
||||
return Err(anyhow!("record-event requires --type"));
|
||||
}
|
||||
Ok(Command::RecordEvent {
|
||||
event_id,
|
||||
event_type,
|
||||
source,
|
||||
session_id,
|
||||
job_id,
|
||||
summary,
|
||||
})
|
||||
}
|
||||
"open-job" => {
|
||||
let mut job_id = format!("job-{}", Uuid::new_v4());
|
||||
let mut session_id = String::new();
|
||||
let mut role = String::new();
|
||||
let mut summary = String::new();
|
||||
let mut artifact_refs = Vec::new();
|
||||
parse_options(args, |name, value| match name {
|
||||
"--job-id" => {
|
||||
job_id = value;
|
||||
Ok(())
|
||||
}
|
||||
"--session-id" => {
|
||||
session_id = value;
|
||||
Ok(())
|
||||
}
|
||||
"--role" => {
|
||||
role = value;
|
||||
Ok(())
|
||||
}
|
||||
"--summary" => {
|
||||
summary = value;
|
||||
Ok(())
|
||||
}
|
||||
"--artifact-ref" => {
|
||||
artifact_refs.push(value);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(anyhow!("unknown open-job argument: {name}")),
|
||||
})?;
|
||||
if session_id.trim().is_empty() {
|
||||
return Err(anyhow!("open-job requires --session-id"));
|
||||
}
|
||||
if role.trim().is_empty() {
|
||||
return Err(anyhow!("open-job requires --role"));
|
||||
}
|
||||
Ok(Command::OpenJob {
|
||||
job_id,
|
||||
session_id,
|
||||
role,
|
||||
summary,
|
||||
artifact_refs,
|
||||
})
|
||||
}
|
||||
"complete-job" => {
|
||||
let mut result_id = format!("result-{}", Uuid::new_v4());
|
||||
let mut job_id = String::new();
|
||||
let mut verdict = String::new();
|
||||
let mut summary = String::new();
|
||||
let mut next_safe_move = String::new();
|
||||
let mut evidence_refs = Vec::new();
|
||||
let mut artifact_refs = Vec::new();
|
||||
parse_options(args, |name, value| match name {
|
||||
"--result-id" => {
|
||||
result_id = value;
|
||||
Ok(())
|
||||
}
|
||||
"--job-id" => {
|
||||
job_id = value;
|
||||
Ok(())
|
||||
}
|
||||
"--verdict" => {
|
||||
verdict = value;
|
||||
Ok(())
|
||||
}
|
||||
"--summary" => {
|
||||
summary = value;
|
||||
Ok(())
|
||||
}
|
||||
"--next-safe-move" => {
|
||||
next_safe_move = value;
|
||||
Ok(())
|
||||
}
|
||||
"--evidence-ref" => {
|
||||
evidence_refs.push(value);
|
||||
Ok(())
|
||||
}
|
||||
"--artifact-ref" => {
|
||||
artifact_refs.push(value);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(anyhow!("unknown complete-job argument: {name}")),
|
||||
})?;
|
||||
if job_id.trim().is_empty() {
|
||||
return Err(anyhow!("complete-job requires --job-id"));
|
||||
}
|
||||
if verdict.trim().is_empty() {
|
||||
return Err(anyhow!("complete-job requires --verdict"));
|
||||
}
|
||||
if summary.trim().is_empty() {
|
||||
return Err(anyhow!("complete-job requires --summary"));
|
||||
}
|
||||
Ok(Command::CompleteJob {
|
||||
result_id,
|
||||
job_id,
|
||||
verdict,
|
||||
summary,
|
||||
next_safe_move,
|
||||
evidence_refs,
|
||||
artifact_refs,
|
||||
})
|
||||
}
|
||||
"hello-frame" => {
|
||||
let mut output = PathBuf::from(".epiphany-dogfood/runtime-spine/hello.cultnet");
|
||||
parse_options(args, |name, value| match name {
|
||||
"--output" => {
|
||||
output = PathBuf::from(value);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(anyhow!("unknown hello-frame argument: {name}")),
|
||||
})?;
|
||||
Ok(Command::HelloFrame { output })
|
||||
}
|
||||
"schema-catalog" => {
|
||||
let mut output = PathBuf::from(".epiphany-dogfood/runtime-spine/schema-catalog.json");
|
||||
let mut include_schema_json = false;
|
||||
parse_options(args, |name, value| match name {
|
||||
"--output" => {
|
||||
output = PathBuf::from(value);
|
||||
Ok(())
|
||||
}
|
||||
"--include-schema-json" => {
|
||||
include_schema_json = parse_bool_flag(&value, "--include-schema-json")?;
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(anyhow!("unknown schema-catalog argument: {name}")),
|
||||
})?;
|
||||
Ok(Command::SchemaCatalog {
|
||||
output,
|
||||
include_schema_json,
|
||||
})
|
||||
}
|
||||
_ => Err(anyhow!(usage())),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_options(
|
||||
args: Vec<String>,
|
||||
mut on_option: impl FnMut(&str, String) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let mut args = args.into_iter();
|
||||
while let Some(name) = args.next() {
|
||||
if !name.starts_with("--") {
|
||||
return Err(anyhow!("unexpected positional argument: {name}"));
|
||||
}
|
||||
let value = args
|
||||
.next()
|
||||
.with_context(|| format!("{name} requires a value"))?;
|
||||
on_option(&name, value)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn take_path(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
args.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
|
||||
fn parse_bool_flag(value: &str, name: &str) -> Result<bool> {
|
||||
match value {
|
||||
"true" | "1" | "yes" => Ok(true),
|
||||
"false" | "0" | "no" => Ok(false),
|
||||
_ => Err(anyhow!("{name} must be true/false")),
|
||||
}
|
||||
}
|
||||
|
||||
fn now() -> String {
|
||||
chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
fn usage() -> &'static str {
|
||||
"usage: epiphany-runtime-spine [--store path] <init|status|list-jobs|list-model-requests|list-events|open-session|open-job|complete-job|record-event|hello-frame|schema-catalog>"
|
||||
}
|
||||
|
||||
fn model_input_item_chars(item: &EpiphanyModelInputItem) -> usize {
|
||||
match item {
|
||||
EpiphanyModelInputItem::UserText { text }
|
||||
| EpiphanyModelInputItem::AssistantText { text } => text.chars().count(),
|
||||
EpiphanyModelInputItem::ToolResult { output, .. } => output.chars().count(),
|
||||
}
|
||||
}
|
||||
136
epiphany-core/src/bin/epiphany-state-ledger-store.rs
Normal file
136
epiphany-core/src/bin/epiphany-state-ledger-store.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use epiphany_core::EpiphanyBranchRecord;
|
||||
use epiphany_core::EpiphanyLedgerEvidenceRecord;
|
||||
use epiphany_core::add_state_branch;
|
||||
use epiphany_core::append_state_evidence;
|
||||
use epiphany_core::close_state_branch;
|
||||
use epiphany_core::state_ledger_status;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = env::args().skip(1);
|
||||
let Some(command) = args.next() else {
|
||||
print_usage();
|
||||
std::process::exit(2);
|
||||
};
|
||||
match command.as_str() {
|
||||
"status" => {
|
||||
let store = require_path_arg(&mut args, "--store")?;
|
||||
print_json(&state_ledger_status(store)?)?;
|
||||
}
|
||||
"add-evidence" => {
|
||||
let store = require_path_arg(&mut args, "--store")?;
|
||||
let record = EpiphanyLedgerEvidenceRecord {
|
||||
ts: require_string_arg(&mut args, "--ts")?,
|
||||
evidence_type: require_string_arg(&mut args, "--type")?,
|
||||
status: require_string_arg(&mut args, "--status")?,
|
||||
note: require_string_arg(&mut args, "--note")?,
|
||||
branch: optional_string_arg(&mut args, "--branch")?,
|
||||
};
|
||||
print_json(&append_state_evidence(store, record)?)?;
|
||||
}
|
||||
"add-branch" => {
|
||||
let store = require_path_arg(&mut args, "--store")?;
|
||||
let id = require_string_arg(&mut args, "--id")?;
|
||||
let hypothesis = require_string_arg(&mut args, "--hypothesis")?;
|
||||
let (artifacts, note) = collect_branch_tail(&mut args)?;
|
||||
let branch = EpiphanyBranchRecord {
|
||||
id,
|
||||
hypothesis,
|
||||
status: "active".to_string(),
|
||||
artifacts,
|
||||
notes: note.unwrap_or_default(),
|
||||
};
|
||||
print_json(&add_state_branch(store, branch)?)?;
|
||||
}
|
||||
"close-branch" => {
|
||||
let store = require_path_arg(&mut args, "--store")?;
|
||||
let id = require_string_arg(&mut args, "--id")?;
|
||||
let status = require_string_arg(&mut args, "--status")?;
|
||||
let note = optional_string_arg(&mut args, "--note")?;
|
||||
print_json(&close_state_branch(store, &id, &status, note)?)?;
|
||||
}
|
||||
_ => {
|
||||
print_usage();
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_path_arg(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(require_string_arg(args, name)?))
|
||||
}
|
||||
|
||||
fn require_string_arg(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
let Some(flag) = args.next() else {
|
||||
anyhow::bail!("missing {name}");
|
||||
};
|
||||
if flag != name {
|
||||
anyhow::bail!("expected {name}, got {flag}");
|
||||
}
|
||||
args.next()
|
||||
.with_context(|| format!("missing value for {name}"))
|
||||
}
|
||||
|
||||
fn optional_string_arg(
|
||||
args: &mut impl Iterator<Item = String>,
|
||||
name: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let remaining = args.collect::<Vec<_>>();
|
||||
let mut found = None;
|
||||
let mut index = 0;
|
||||
while index < remaining.len() {
|
||||
if remaining[index] == name {
|
||||
found = remaining.get(index + 1).cloned();
|
||||
index += 2;
|
||||
} else {
|
||||
anyhow::bail!("unexpected argument {}", remaining[index]);
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn collect_branch_tail(
|
||||
args: &mut impl Iterator<Item = String>,
|
||||
) -> Result<(Vec<String>, Option<String>)> {
|
||||
let remaining = args.collect::<Vec<_>>();
|
||||
let mut artifacts = Vec::new();
|
||||
let mut note = None;
|
||||
let mut index = 0;
|
||||
while index < remaining.len() {
|
||||
if remaining[index] == "--artifact" {
|
||||
artifacts.push(
|
||||
remaining
|
||||
.get(index + 1)
|
||||
.cloned()
|
||||
.context("missing value for --artifact")?,
|
||||
);
|
||||
index += 2;
|
||||
} else if remaining[index] == "--note" {
|
||||
note = Some(
|
||||
remaining
|
||||
.get(index + 1)
|
||||
.cloned()
|
||||
.context("missing value for --note")?,
|
||||
);
|
||||
index += 2;
|
||||
} else {
|
||||
anyhow::bail!("unexpected argument {}", remaining[index]);
|
||||
}
|
||||
}
|
||||
Ok((artifacts, note))
|
||||
}
|
||||
|
||||
fn print_json<T: serde::Serialize>(value: &T) -> Result<()> {
|
||||
println!("{}", serde_json::to_string_pretty(value)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: epiphany-state-ledger-store <status|add-evidence|add-branch|close-branch> ..."
|
||||
);
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ fn main() -> Result<()> {
|
|||
let root = env::current_dir().context("failed to resolve current directory")?;
|
||||
let state_dir = root.join("state");
|
||||
let map_path = state_dir.join("map.yaml");
|
||||
let ledger_store = state_dir.join("ledgers.msgpack");
|
||||
let ledger_store = state_dir.join("ledgers.cc");
|
||||
let mut args = env::args().skip(1);
|
||||
let Some(command) = args.next() else {
|
||||
print_usage();
|
||||
|
|
|
|||
|
|
@ -1,853 +0,0 @@
|
|||
use anyhow::{Context, Result, anyhow};
|
||||
use chrono::Utc;
|
||||
use epiphany_core::{
|
||||
ChildObservation, CoordinatorLaunch, LaunchedCoordinator, ProcessInstanceIdentity,
|
||||
ProcessInstanceObservation, ResidentProviderReadiness, ResidentReadinessRequest,
|
||||
ResidentSelfOutcome, ResidentSelfPolicy, ResidentSelfPorts, ResidentSelfPressure,
|
||||
ResidentSelfState, acknowledge_resident_self_launch, acquire_resident_process_singleton,
|
||||
admit_persona_feedback_to_social, authenticate_resident_self_policy,
|
||||
bind_runtime_repository_domain, capture_process_instance, coordinator_run_receipts,
|
||||
derive_resident_cognition_readiness, enqueue_resident_self_pressure_idempotent,
|
||||
import_bifrost_persona_feedback_deliveries, ingest_resident_self_current_work_pressure,
|
||||
issue_resident_self_grant, live_resident_self_typed_request_ids,
|
||||
load_epiphany_cultmesh_swarm_brake, load_resident_self_state,
|
||||
materialize_resident_self_domain_obligations, observe_process_instance,
|
||||
prepare_resident_self_launch, publish_resident_provider_readiness, reap_exited_child_process,
|
||||
recover_dead_runtime_worker_attempts, resident_cognitive_runtime_id,
|
||||
resident_prepared_launch_thread_id, resident_self_child_claim,
|
||||
resident_self_local_provider_status, retain_completed_runtime_sessions,
|
||||
retain_failed_runtime_worker_attempts, retain_fulfilled_runtime_worker_attempts,
|
||||
retain_resident_self_lifecycles, runtime_worker_process_claims,
|
||||
settle_resident_self_exited_coordinator, settle_resident_self_receipt_free_dead_coordinator,
|
||||
terminate_process_instance, validate_persona_feedback_store_separation,
|
||||
validate_resident_self_coordinator_receipt_binding, validate_resident_self_store_separation,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const RESIDENT_MAINTENANCE_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
fn main() -> Result<()> {
|
||||
if env::args().nth(1).as_deref() == Some("initialize-and-release-brake") {
|
||||
return initialize_and_release_brake();
|
||||
}
|
||||
let mut args = Args::parse()?;
|
||||
authenticate_resident_self_policy(&mut args.policy)?;
|
||||
args.policy.validate()?;
|
||||
validate_resident_self_store_separation(&args.state_store, &args.policy)?;
|
||||
validate_persona_feedback_store_separation(
|
||||
&args.persona_feedback_source_store,
|
||||
&args.persona_feedback_store,
|
||||
&[
|
||||
&args.bifrost_feedback_trust_anchor,
|
||||
&args.state_store,
|
||||
&args.policy.runtime_store,
|
||||
&args.policy.local_verse_store,
|
||||
&args.policy.release_store,
|
||||
],
|
||||
)?;
|
||||
if matches!(args.command, CommandKind::Status) {
|
||||
let projection = derive_resident_cognition_readiness(ResidentReadinessRequest {
|
||||
release_store: &args.policy.release_store,
|
||||
resident_store: &args.state_store,
|
||||
policy: &args.policy,
|
||||
release_runtime_id: &args.policy.release_runtime_id,
|
||||
release_id: &args.policy.release_id,
|
||||
release_witness_sha256: &args.policy.release_witness_sha256,
|
||||
freshness_millis: args.provider_freshness_seconds.saturating_mul(1000),
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&projection)?);
|
||||
return Ok(());
|
||||
}
|
||||
require_resident_git("git")?;
|
||||
let _singleton = acquire_resident_process_singleton(&args.state_store)?;
|
||||
if let Some(pressure) = args.pressure.as_ref() {
|
||||
enqueue_resident_self_pressure_idempotent(&args.state_store, pressure)?;
|
||||
}
|
||||
let mut state = load_resident_self_state(&args.state_store)?;
|
||||
let mut ports = NativePorts::new(&args.policy)?;
|
||||
match args.command {
|
||||
CommandKind::Once => {
|
||||
let outcome = cycle(&args, &mut state, &mut ports, false)?;
|
||||
maintain_resident_runtime(&args, &state)?;
|
||||
publish_self_readiness(&args)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&summary(&state, &outcome, false))?
|
||||
);
|
||||
}
|
||||
CommandKind::Serve => {
|
||||
let shutdown_requested = install_shutdown_signal_owner()?;
|
||||
let mut last_maintained_revision = None;
|
||||
let mut next_maintenance_at = std::time::Instant::now();
|
||||
loop {
|
||||
let shutting_down = shutdown_requested.load(Ordering::SeqCst);
|
||||
let outcome = cycle(&args, &mut state, &mut ports, shutting_down)?;
|
||||
if last_maintained_revision != Some(state.revision)
|
||||
|| std::time::Instant::now() >= next_maintenance_at
|
||||
{
|
||||
maintain_resident_runtime(&args, &state)?;
|
||||
last_maintained_revision = Some(state.revision);
|
||||
next_maintenance_at = std::time::Instant::now() + RESIDENT_MAINTENANCE_INTERVAL;
|
||||
}
|
||||
publish_self_readiness(&args)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&summary(&state, &outcome, shutting_down))?
|
||||
);
|
||||
if shutting_down && state.active_turn.is_none() {
|
||||
break;
|
||||
}
|
||||
let seconds = match outcome {
|
||||
ResidentSelfOutcome::Failed => args.policy.failure_backoff_seconds,
|
||||
ResidentSelfOutcome::Completed => args.policy.cooldown_seconds,
|
||||
_ => args.policy.idle_sleep_seconds,
|
||||
};
|
||||
wait_for_shutdown(&shutdown_requested, Duration::from_secs(seconds.max(1)));
|
||||
}
|
||||
}
|
||||
CommandKind::Status => unreachable!("status returned before actuation setup"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn initialize_and_release_brake() -> Result<()> {
|
||||
let mut values = BTreeMap::new();
|
||||
let mut args = env::args().skip(2);
|
||||
while let Some(flag) = args.next() {
|
||||
let value = args
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("missing value for {flag}"))?;
|
||||
values.insert(flag, value);
|
||||
}
|
||||
let required = |name: &str| {
|
||||
values
|
||||
.get(name)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("missing {name}"))
|
||||
};
|
||||
let store = PathBuf::from(required("--local-verse-store")?);
|
||||
let runtime_id = required("--runtime-id")?;
|
||||
let actor_id = required("--actor-id")?;
|
||||
let reason = required("--reason")?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
epiphany_core::engage_epiphany_cultmesh_swarm_brake(
|
||||
&store,
|
||||
&runtime_id,
|
||||
"Fresh runtime begins braked before explicit release.",
|
||||
&actor_id,
|
||||
&now,
|
||||
false,
|
||||
)?;
|
||||
let brake = epiphany_core::release_epiphany_cultmesh_swarm_brake(
|
||||
&store,
|
||||
&runtime_id,
|
||||
reason,
|
||||
actor_id,
|
||||
now,
|
||||
)?;
|
||||
println!("{}", serde_json::to_string_pretty(&brake)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_resident_git(executable: &str) -> Result<()> {
|
||||
let output = Command::new(executable)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.with_context(|| format!("resident coordinator body lacks required {executable:?}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"resident coordinator body has unusable {executable:?}: exit {}",
|
||||
output.status
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn maintain_resident_runtime(args: &Args, state: &ResidentSelfState) -> Result<()> {
|
||||
retain_runtime_receipts(args, state)?;
|
||||
retain_resident_self_lifecycles(&args.state_store, args.retained_closed_lifecycles)?;
|
||||
retain_runtime_worker_attempts(args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retain_runtime_receipts(args: &Args, state: &ResidentSelfState) -> Result<()> {
|
||||
if state.active_turn.is_some() || state.prepared_launch.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
retain_completed_runtime_sessions(
|
||||
&args.policy.runtime_store,
|
||||
args.retained_completed_runtime_sessions,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retain_runtime_worker_attempts(args: &Args) -> Result<()> {
|
||||
let live_request_ids = live_resident_self_typed_request_ids(&args.state_store)?;
|
||||
retain_failed_runtime_worker_attempts(
|
||||
&args.policy.runtime_store,
|
||||
args.retained_runtime_worker_attempts,
|
||||
&live_request_ids,
|
||||
)?;
|
||||
retain_fulfilled_runtime_worker_attempts(
|
||||
&args.policy.runtime_store,
|
||||
args.retained_runtime_worker_attempts,
|
||||
&live_request_ids,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn publish_self_readiness(args: &Args) -> Result<()> {
|
||||
let process = capture_process_instance(std::process::id())?;
|
||||
publish_resident_provider_readiness(
|
||||
&args.state_store,
|
||||
ResidentProviderReadiness {
|
||||
schema_version: epiphany_core::RESIDENT_PROVIDER_READINESS_SCHEMA_VERSION.into(),
|
||||
provider: "resident-self".into(),
|
||||
runtime_id: args.policy.release_runtime_id.clone(),
|
||||
release_id: args.policy.release_id.clone(),
|
||||
release_witness_sha256: args.policy.release_witness_sha256.clone(),
|
||||
source_commit: args.policy.release_commit.clone(),
|
||||
publisher_sequence: 0,
|
||||
observed_at_millis: Utc::now().timestamp_millis().max(0) as u64,
|
||||
process_id: process.process_id,
|
||||
process_creation_token: process.creation_token,
|
||||
process_executable_path: process.executable_path.display().to_string(),
|
||||
status: resident_self_local_provider_status(&args.state_store, &args.policy).into(),
|
||||
private_state_exposed: false,
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_shutdown_signal_owner() -> Result<Arc<AtomicBool>> {
|
||||
let requested = Arc::new(AtomicBool::new(false));
|
||||
let signal = Arc::clone(&requested);
|
||||
ctrlc::try_set_handler(move || {
|
||||
signal.store(true, Ordering::SeqCst);
|
||||
})
|
||||
.context("failed to install resident Self shutdown signal owner")?;
|
||||
Ok(requested)
|
||||
}
|
||||
|
||||
fn wait_for_shutdown(requested: &AtomicBool, duration: Duration) {
|
||||
let deadline = std::time::Instant::now() + duration;
|
||||
while !requested.load(Ordering::SeqCst) {
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
thread::sleep(remaining.min(Duration::from_millis(50)));
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_resident_coordinator_receipt(
|
||||
runtime_store: &std::path::Path,
|
||||
lease: &epiphany_core::ResidentSelfTurnLease,
|
||||
) -> Result<Option<epiphany_core::EpiphanyCoordinatorRunReceipt>> {
|
||||
let session_id =
|
||||
epiphany_core::coordinator_run_session_id(&lease.turn_id, Some(&lease.launch_digest))?;
|
||||
let mut receipts = coordinator_run_receipts(runtime_store)?
|
||||
.into_iter()
|
||||
.filter(|receipt| receipt.session_id == session_id)
|
||||
.collect::<Vec<_>>();
|
||||
if receipts.len() > 1 {
|
||||
return Err(anyhow!(
|
||||
"resident coordinator session has multiple terminal receipts"
|
||||
));
|
||||
}
|
||||
let Some(receipt) = receipts.pop() else {
|
||||
return Ok(None);
|
||||
};
|
||||
validate_resident_self_coordinator_receipt_binding(lease, &receipt)?;
|
||||
Ok(Some(receipt))
|
||||
}
|
||||
|
||||
fn cycle(
|
||||
args: &Args,
|
||||
state: &mut ResidentSelfState,
|
||||
ports: &mut NativePorts,
|
||||
shutdown_requested: bool,
|
||||
) -> Result<ResidentSelfOutcome> {
|
||||
ports.reap_adopted_worker_children()?;
|
||||
let now = Utc::now().timestamp_millis().max(0) as u64;
|
||||
recover_dead_runtime_worker_attempts(&args.policy.runtime_store, now)?;
|
||||
let brake_engaged = ports.brake_engaged()?;
|
||||
let cognitive_runtime_id = ports.cognitive_runtime_id().to_string();
|
||||
if !brake_engaged && !shutdown_requested {
|
||||
import_bifrost_persona_feedback_deliveries(
|
||||
&args.persona_feedback_source_store,
|
||||
&args.persona_feedback_store,
|
||||
&args.bifrost_feedback_trust_anchor,
|
||||
&cognitive_runtime_id,
|
||||
&args.feedback_target_repository,
|
||||
&args.feedback_target_persona,
|
||||
)?;
|
||||
admit_persona_feedback_to_social(
|
||||
&args.persona_feedback_store,
|
||||
&args.persona_social_store,
|
||||
&cognitive_runtime_id,
|
||||
&args.policy.model_provider,
|
||||
&args.persona_model_allowed_data_classifications,
|
||||
)?;
|
||||
}
|
||||
if !brake_engaged && !shutdown_requested {
|
||||
bind_runtime_repository_domain(
|
||||
&args.policy.runtime_store,
|
||||
&args.feedback_target_repository,
|
||||
)?;
|
||||
materialize_resident_self_domain_obligations(
|
||||
&args.policy.runtime_store,
|
||||
&args.persona_feedback_store,
|
||||
&cognitive_runtime_id,
|
||||
&args.feedback_target_repository,
|
||||
&args.policy.workspace.display().to_string(),
|
||||
now,
|
||||
)?;
|
||||
ingest_resident_self_current_work_pressure(
|
||||
&args.state_store,
|
||||
&args.policy.runtime_store,
|
||||
now,
|
||||
)?;
|
||||
}
|
||||
*state = load_resident_self_state(&args.state_store)?;
|
||||
if let Some(prepared) = state.prepared_launch.clone() {
|
||||
if let Some(claim) = resident_self_child_claim(&args.state_store, &prepared.preparation_id)?
|
||||
{
|
||||
acknowledge_resident_self_launch(
|
||||
&args.state_store,
|
||||
&prepared.preparation_id,
|
||||
&LaunchedCoordinator {
|
||||
process_id: claim.process_id,
|
||||
process_creation_token: claim.process_creation_token,
|
||||
process_executable_path: claim.executable_path,
|
||||
},
|
||||
claim.claimed_at_millis,
|
||||
)?;
|
||||
*state = load_resident_self_state(&args.state_store)?;
|
||||
return Ok(ResidentSelfOutcome::Running);
|
||||
}
|
||||
if shutdown_requested || ports.brake_engaged()? {
|
||||
return Ok(ResidentSelfOutcome::Braked);
|
||||
}
|
||||
let launch = CoordinatorLaunch {
|
||||
turn_id: resident_prepared_launch_thread_id(&prepared)?,
|
||||
wake: epiphany_core::ResidentSelfWake::Explicit {
|
||||
objective: prepared.grant.objective.clone(),
|
||||
},
|
||||
argv: prepared.argv.clone(),
|
||||
};
|
||||
let process = ports.launch_coordinator(&launch)?;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while resident_self_child_claim(&args.state_store, &prepared.preparation_id)?.is_none() {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(anyhow!(
|
||||
"retried coordinator did not claim prepared authority; preparation remains fail-closed"
|
||||
));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
acknowledge_resident_self_launch(
|
||||
&args.state_store,
|
||||
&prepared.preparation_id,
|
||||
&process,
|
||||
now,
|
||||
)?;
|
||||
*state = load_resident_self_state(&args.state_store)?;
|
||||
return Ok(ResidentSelfOutcome::Launched);
|
||||
}
|
||||
if let Some(lease) = state.active_turn.clone() {
|
||||
let brake_engaged = ports.brake_engaged()?;
|
||||
let timed_out = now.saturating_sub(lease.started_at_millis)
|
||||
> args.policy.turn_timeout_seconds.saturating_mul(1000);
|
||||
if shutdown_requested || brake_engaged || timed_out {
|
||||
if ports.observe_child(&lease)? == ChildObservation::Running {
|
||||
ports.request_child_stop(&lease)?;
|
||||
return Ok(ResidentSelfOutcome::Draining);
|
||||
}
|
||||
}
|
||||
return match ports.observe_child(&lease)? {
|
||||
ChildObservation::Running => Ok(ResidentSelfOutcome::Running),
|
||||
observation => {
|
||||
let receipt =
|
||||
exact_resident_coordinator_receipt(&args.policy.runtime_store, &lease)?;
|
||||
if let Some(receipt) = receipt.as_ref() {
|
||||
let outcome = settle_resident_self_exited_coordinator(
|
||||
&args.state_store,
|
||||
&args.policy.runtime_store,
|
||||
&lease,
|
||||
receipt,
|
||||
shutdown_requested,
|
||||
brake_engaged,
|
||||
timed_out,
|
||||
now,
|
||||
args.policy.cooldown_seconds,
|
||||
)?;
|
||||
*state = load_resident_self_state(&args.state_store)?;
|
||||
return Ok(outcome);
|
||||
}
|
||||
let outcome = settle_resident_self_receipt_free_dead_coordinator(
|
||||
&args.state_store,
|
||||
&args.policy.runtime_store,
|
||||
&lease,
|
||||
observation,
|
||||
shutdown_requested,
|
||||
brake_engaged,
|
||||
timed_out,
|
||||
now,
|
||||
args.policy.cooldown_seconds,
|
||||
)?;
|
||||
*state = load_resident_self_state(&args.state_store)?;
|
||||
Ok(outcome)
|
||||
}
|
||||
};
|
||||
}
|
||||
if shutdown_requested || ports.brake_engaged()? {
|
||||
return Ok(ResidentSelfOutcome::Braked);
|
||||
}
|
||||
issue_resident_self_grant(&args.state_store, now)?;
|
||||
let Some(prepared) = prepare_resident_self_launch(&args.state_store, &args.policy, now)? else {
|
||||
return Ok(ResidentSelfOutcome::Sleeping);
|
||||
};
|
||||
let launch = CoordinatorLaunch {
|
||||
turn_id: resident_prepared_launch_thread_id(&prepared)?,
|
||||
wake: epiphany_core::ResidentSelfWake::Explicit {
|
||||
objective: prepared.grant.objective.clone(),
|
||||
},
|
||||
argv: prepared.argv.clone(),
|
||||
};
|
||||
let process = ports.launch_coordinator(&launch)?;
|
||||
let claim_deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while resident_self_child_claim(&args.state_store, &prepared.preparation_id)?.is_none() {
|
||||
if std::time::Instant::now() >= claim_deadline {
|
||||
return Err(anyhow!(
|
||||
"coordinator did not claim prepared authority before bootstrap deadline; preparation remains fail-closed"
|
||||
));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
acknowledge_resident_self_launch(&args.state_store, &prepared.preparation_id, &process, now)?;
|
||||
*state = load_resident_self_state(&args.state_store)?;
|
||||
Ok(ResidentSelfOutcome::Launched)
|
||||
}
|
||||
|
||||
fn summary(
|
||||
state: &ResidentSelfState,
|
||||
outcome: &ResidentSelfOutcome,
|
||||
shutdown_requested: bool,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"schemaVersion": "epiphany.resident_self.operator_projection.v0",
|
||||
"status": outcome.operator_status(),
|
||||
"revision": state.revision,
|
||||
"activeTurnId": state.active_turn.as_ref().map(|turn| &turn.turn_id),
|
||||
"shutdownRequested": shutdown_requested,
|
||||
"nextEligibleAtMillis": state.next_eligible_at_millis,
|
||||
"wakeAuthority": "Resident Self claims one typed pending pressure and emits one single-consumption grant",
|
||||
"preparedRecovery": if state.prepared_launch.is_some() { "fail-closed-awaiting-exact-child-claim-or-witnessed-recovery" } else { "not-required" },
|
||||
"authority": "Self may launch one bounded coordinator turn; it cannot directly invoke model/tools, mutate Mind/Hands, review, release, or deploy",
|
||||
"privateStateExposed": false
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum CommandKind {
|
||||
Once,
|
||||
Serve,
|
||||
Status,
|
||||
}
|
||||
|
||||
fn validate_command_turn_bound(command: CommandKind, max_steps: u64) -> Result<()> {
|
||||
if matches!(command, CommandKind::Serve) && max_steps != 1 {
|
||||
return Err(anyhow!(
|
||||
"resident serve requires --max-steps 1 because each state-driven continuation owns exactly one coordinator step"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Args {
|
||||
command: CommandKind,
|
||||
state_store: PathBuf,
|
||||
persona_social_store: PathBuf,
|
||||
provider_freshness_seconds: u64,
|
||||
retained_closed_lifecycles: usize,
|
||||
retained_completed_runtime_sessions: usize,
|
||||
retained_runtime_worker_attempts: usize,
|
||||
persona_feedback_source_store: PathBuf,
|
||||
persona_feedback_store: PathBuf,
|
||||
bifrost_feedback_trust_anchor: PathBuf,
|
||||
feedback_target_repository: String,
|
||||
feedback_target_persona: String,
|
||||
persona_model_allowed_data_classifications: Vec<String>,
|
||||
policy: ResidentSelfPolicy,
|
||||
pressure: Option<ResidentSelfPressure>,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let mut it = env::args().skip(1);
|
||||
let command = match it.next().as_deref() {
|
||||
Some("once") => CommandKind::Once,
|
||||
Some("serve") => CommandKind::Serve,
|
||||
Some("status") => CommandKind::Status,
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"usage: epiphany-swarm <once|serve|status> with exact absolute packaged paths"
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut value = BTreeMap::new();
|
||||
let mut objective = None;
|
||||
while let Some(flag) = it.next() {
|
||||
let raw = it
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("missing value for {flag}"))?;
|
||||
if flag == "--objective" {
|
||||
objective = Some(raw);
|
||||
} else {
|
||||
value.insert(flag, raw);
|
||||
}
|
||||
}
|
||||
let path = |name: &str| -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(
|
||||
value.get(name).ok_or_else(|| anyhow!("missing {name}"))?,
|
||||
))
|
||||
};
|
||||
let u64v = |name: &str, default: u64| -> Result<u64> {
|
||||
value.get(name).map_or(Ok(default), |v| {
|
||||
v.parse().with_context(|| format!("invalid {name}"))
|
||||
})
|
||||
};
|
||||
let release_store = path("--release-store")?;
|
||||
let policy = ResidentSelfPolicy {
|
||||
workspace: path("--workspace")?,
|
||||
coordinator_bin: release_store.clone(),
|
||||
model_runtime_bin: release_store.clone(),
|
||||
tool_adapter_bin: release_store.clone(),
|
||||
runtime_store: path("--runtime-store")?,
|
||||
local_verse_store: path("--local-verse-store")?,
|
||||
artifact_root: path("--artifact-root")?,
|
||||
model_connector_endpoint: value
|
||||
.get("--connector-endpoint")
|
||||
.ok_or_else(|| anyhow!("missing --connector-endpoint"))?
|
||||
.parse()
|
||||
.context("invalid --connector-endpoint")?,
|
||||
mcp_config: path("--mcp-config")?,
|
||||
model_provider: value
|
||||
.get("--model-provider")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("missing --model-provider"))?,
|
||||
model: value
|
||||
.get("--model")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("missing --model"))?,
|
||||
provider_credential_path: value.get("--provider-credential").map(PathBuf::from),
|
||||
max_steps: u64v("--max-steps", 4)?,
|
||||
turn_timeout_seconds: u64v("--turn-timeout-seconds", 600)?,
|
||||
cooldown_seconds: u64v("--cooldown-seconds", 60)?,
|
||||
idle_sleep_seconds: u64v("--idle-sleep-seconds", 15)?,
|
||||
failure_backoff_seconds: u64v("--failure-backoff-seconds", 60)?,
|
||||
release_commit: String::new(),
|
||||
release_manifest_digest: String::new(),
|
||||
release_store,
|
||||
release_runtime_id: value
|
||||
.get("--release-runtime-id")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("missing --release-runtime-id"))?,
|
||||
release_id: value
|
||||
.get("--release-id")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("missing --release-id"))?,
|
||||
release_witness_sha256: value
|
||||
.get("--release-witness-sha256")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("missing --release-witness-sha256"))?,
|
||||
};
|
||||
validate_command_turn_bound(command, policy.max_steps)?;
|
||||
let pressure = objective.map(|objective| ResidentSelfPressure {
|
||||
schema_version: epiphany_core::RESIDENT_SELF_PRESSURE_SCHEMA_VERSION.into(),
|
||||
pressure_id: value
|
||||
.get("--pressure-id")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("operator-pressure-{}", Utc::now().timestamp_millis())),
|
||||
kind: "operator-objective".into(),
|
||||
provenance_ref: "cli://epiphany-swarm/operator-objective".into(),
|
||||
objective,
|
||||
created_at_millis: Utc::now().timestamp_millis().max(0) as u64,
|
||||
status: "pending".into(),
|
||||
consumed_by_grant_id: None,
|
||||
private_state_exposed: false,
|
||||
});
|
||||
Ok(Self {
|
||||
command,
|
||||
state_store: path("--state-store")?,
|
||||
persona_social_store: path("--persona-social-store")?,
|
||||
provider_freshness_seconds: u64v("--provider-freshness-seconds", 180)?,
|
||||
retained_closed_lifecycles: u64v("--retained-closed-lifecycles", 256)?
|
||||
.try_into()
|
||||
.context("--retained-closed-lifecycles exceeds platform size")?,
|
||||
retained_completed_runtime_sessions: u64v(
|
||||
"--retained-completed-runtime-sessions",
|
||||
256,
|
||||
)?
|
||||
.try_into()
|
||||
.context("--retained-completed-runtime-sessions exceeds platform size")?,
|
||||
retained_runtime_worker_attempts: u64v("--retained-runtime-worker-attempts", 256)?
|
||||
.try_into()
|
||||
.context("--retained-runtime-worker-attempts exceeds platform size")?,
|
||||
persona_feedback_source_store: path("--persona-feedback-source-store")?,
|
||||
persona_feedback_store: path("--persona-feedback-store")?,
|
||||
bifrost_feedback_trust_anchor: path("--bifrost-feedback-trust-anchor")?,
|
||||
feedback_target_repository: value
|
||||
.get("--feedback-target-repository")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("missing --feedback-target-repository"))?,
|
||||
feedback_target_persona: value
|
||||
.get("--feedback-target-persona")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("missing --feedback-target-persona"))?,
|
||||
persona_model_allowed_data_classifications: value
|
||||
.get("--persona-model-allowed-data-classifications")
|
||||
.ok_or_else(|| anyhow!("missing --persona-model-allowed-data-classifications"))?
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
policy,
|
||||
pressure,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct NativePorts<'a> {
|
||||
policy: &'a ResidentSelfPolicy,
|
||||
cognitive_runtime_id: String,
|
||||
children: BTreeMap<u32, Child>,
|
||||
}
|
||||
|
||||
impl<'a> NativePorts<'a> {
|
||||
fn new(policy: &'a ResidentSelfPolicy) -> Result<Self> {
|
||||
Ok(Self {
|
||||
policy,
|
||||
cognitive_runtime_id: resident_cognitive_runtime_id(&policy.runtime_store)?,
|
||||
children: BTreeMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn cognitive_runtime_id(&self) -> &str {
|
||||
&self.cognitive_runtime_id
|
||||
}
|
||||
|
||||
fn reap_adopted_worker_children(&mut self) -> Result<usize> {
|
||||
let mut reaped = 0;
|
||||
for claim in runtime_worker_process_claims(&self.policy.runtime_store)? {
|
||||
let expected = ProcessInstanceIdentity {
|
||||
process_id: claim.process_id,
|
||||
creation_token: claim.process_creation_token,
|
||||
created_at_rfc3339: None,
|
||||
executable_path: claim.process_executable_path.into(),
|
||||
};
|
||||
if reap_exited_child_process(&expected)? {
|
||||
reaped += 1;
|
||||
}
|
||||
}
|
||||
Ok(reaped)
|
||||
}
|
||||
}
|
||||
|
||||
impl ResidentSelfPorts for NativePorts<'_> {
|
||||
fn brake_engaged(&mut self) -> Result<bool> {
|
||||
resident_self_brake_engaged(&self.policy.local_verse_store, &self.cognitive_runtime_id)
|
||||
}
|
||||
|
||||
fn observe_child(
|
||||
&mut self,
|
||||
lease: &epiphany_core::ResidentSelfTurnLease,
|
||||
) -> Result<ChildObservation> {
|
||||
if let Some(child) = self.children.get_mut(&lease.process_id) {
|
||||
return Ok(match child.try_wait()? {
|
||||
Some(status) => ChildObservation::Exited(status.code().unwrap_or(-1)),
|
||||
None => ChildObservation::Running,
|
||||
});
|
||||
}
|
||||
let expected = ProcessInstanceIdentity {
|
||||
process_id: lease.process_id,
|
||||
creation_token: lease.process_creation_token,
|
||||
created_at_rfc3339: None,
|
||||
executable_path: lease.process_executable_path.clone(),
|
||||
};
|
||||
Ok(match observe_process_instance(&expected) {
|
||||
ProcessInstanceObservation::ExactAlive => ChildObservation::Running,
|
||||
ProcessInstanceObservation::ExactExited { exit_code } => {
|
||||
ChildObservation::Exited(exit_code.map(|v| v as i32).unwrap_or(-1))
|
||||
}
|
||||
ProcessInstanceObservation::Missing | ProcessInstanceObservation::Replaced { .. } => {
|
||||
ChildObservation::Missing
|
||||
}
|
||||
// Uncertainty cannot prove the exact incarnation dead, so it cannot admit a replacement.
|
||||
ProcessInstanceObservation::Inaccessible
|
||||
| ProcessInstanceObservation::Indeterminate { .. } => ChildObservation::Running,
|
||||
})
|
||||
}
|
||||
|
||||
fn request_child_stop(&mut self, lease: &epiphany_core::ResidentSelfTurnLease) -> Result<()> {
|
||||
terminate_process_instance(&ProcessInstanceIdentity {
|
||||
process_id: lease.process_id,
|
||||
creation_token: lease.process_creation_token,
|
||||
created_at_rfc3339: None,
|
||||
executable_path: lease.process_executable_path.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn launch_coordinator(&mut self, launch: &CoordinatorLaunch) -> Result<LaunchedCoordinator> {
|
||||
let mut command = Command::new(&self.policy.coordinator_bin);
|
||||
command
|
||||
.args(&launch.argv)
|
||||
.current_dir(&self.policy.workspace);
|
||||
let child = command.spawn().with_context(|| {
|
||||
format!("failed to launch {}", self.policy.coordinator_bin.display())
|
||||
})?;
|
||||
let process_id = child.id();
|
||||
let identity = capture_process_instance(process_id)?;
|
||||
self.children.insert(process_id, child);
|
||||
Ok(LaunchedCoordinator {
|
||||
process_id,
|
||||
process_creation_token: identity.creation_token,
|
||||
process_executable_path: identity.executable_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn coordinator_receipt_since(
|
||||
&mut self,
|
||||
turn_id: &str,
|
||||
started_at_millis: u64,
|
||||
) -> Result<Option<String>> {
|
||||
let mut receipts = coordinator_run_receipts(&self.policy.runtime_store)?;
|
||||
receipts.retain(|receipt| {
|
||||
receipt.thread_id == turn_id
|
||||
&& chrono::DateTime::parse_from_rfc3339(&receipt.created_at)
|
||||
.map(|at| at.timestamp_millis().max(0) as u64 >= started_at_millis)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
receipts.sort_by(|a, b| {
|
||||
a.created_at
|
||||
.cmp(&b.created_at)
|
||||
.then(a.receipt_id.cmp(&b.receipt_id))
|
||||
});
|
||||
Ok(receipts.last().map(|receipt| receipt.receipt_id.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn resident_self_brake_engaged(
|
||||
local_verse_store: &std::path::Path,
|
||||
runtime_id: &str,
|
||||
) -> Result<bool> {
|
||||
Ok(
|
||||
load_epiphany_cultmesh_swarm_brake(local_verse_store, runtime_id)?
|
||||
.is_some_and(|brake| brake.status == "engaged"),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod brake_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resident_serve_refuses_a_multi_step_coordinator_policy_at_startup() {
|
||||
assert!(validate_command_turn_bound(CommandKind::Serve, 4).is_err());
|
||||
assert!(validate_command_turn_bound(CommandKind::Serve, 1).is_ok());
|
||||
assert!(validate_command_turn_bound(CommandKind::Once, 4).is_ok());
|
||||
assert!(validate_command_turn_bound(CommandKind::Status, 4).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resident_body_preflight_refuses_missing_git_before_serve() {
|
||||
assert!(require_resident_git("epiphany-deliberately-missing-git").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brake_lookup_uses_the_exact_requested_runtime_namespace() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store = temp.path().join("verse.cc");
|
||||
let mut brake =
|
||||
epiphany_core::default_epiphany_cultmesh_swarm_brake("2026-07-18T00:00:00Z");
|
||||
brake.status = "engaged".into();
|
||||
brake.reason = "test".into();
|
||||
epiphany_core::write_epiphany_cultmesh_swarm_brake(&store, "wrong-runtime", brake.clone())?;
|
||||
assert!(!resident_self_brake_engaged(&store, "epiphany-yggdrasil")?);
|
||||
epiphany_core::write_epiphany_cultmesh_swarm_brake(&store, "epiphany-yggdrasil", brake)?;
|
||||
assert!(resident_self_brake_engaged(&store, "epiphany-yggdrasil")?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_resident_uses_mounted_cognitive_runtime_for_braking() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let runtime_store = temp.path().join("runtime.cc");
|
||||
let verse_store = temp.path().join("verse.cc");
|
||||
epiphany_core::initialize_runtime_spine(
|
||||
&runtime_store,
|
||||
epiphany_core::RuntimeSpineInitOptions {
|
||||
runtime_id: "cognitive-runtime".into(),
|
||||
display_name: "Cognitive runtime".into(),
|
||||
created_at: "2026-08-10T00:00:00Z".into(),
|
||||
},
|
||||
)?;
|
||||
let mut cognitive_brake =
|
||||
epiphany_core::default_epiphany_cultmesh_swarm_brake("2026-08-10T00:00:00Z");
|
||||
cognitive_brake.status = "engaged".into();
|
||||
cognitive_brake.reason = "cognitive brake".into();
|
||||
epiphany_core::write_epiphany_cultmesh_swarm_brake(
|
||||
&verse_store,
|
||||
"cognitive-runtime",
|
||||
cognitive_brake,
|
||||
)?;
|
||||
let shared = temp.path().to_path_buf();
|
||||
let policy = ResidentSelfPolicy {
|
||||
workspace: shared.clone(),
|
||||
coordinator_bin: shared.join("coordinator"),
|
||||
model_runtime_bin: shared.join("model"),
|
||||
tool_adapter_bin: shared.join("tool"),
|
||||
runtime_store,
|
||||
local_verse_store: verse_store,
|
||||
artifact_root: shared.join("artifacts"),
|
||||
model_connector_endpoint: "127.0.0.1:17891".parse().unwrap(),
|
||||
mcp_config: shared.join("mcp.toml"),
|
||||
model_provider: "test".into(),
|
||||
model: "test-model".into(),
|
||||
provider_credential_path: None,
|
||||
max_steps: 1,
|
||||
turn_timeout_seconds: 1,
|
||||
cooldown_seconds: 1,
|
||||
idle_sleep_seconds: 1,
|
||||
failure_backoff_seconds: 1,
|
||||
release_commit: "release-commit".into(),
|
||||
release_manifest_digest: "sha256:release".into(),
|
||||
release_store: shared.join("release.cc"),
|
||||
release_runtime_id: "deployment-runtime".into(),
|
||||
release_id: "sha256:release".into(),
|
||||
release_witness_sha256: "sha256:witness".into(),
|
||||
};
|
||||
|
||||
let mut ports = NativePorts::new(&policy)?;
|
||||
assert_eq!(ports.cognitive_runtime_id(), "cognitive-runtime");
|
||||
assert!(ports.brake_engaged()?);
|
||||
assert!(!resident_self_brake_engaged(
|
||||
&policy.local_verse_store,
|
||||
&policy.release_runtime_id,
|
||||
)?);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
478
epiphany-core/src/bin/epiphany-unity-bridge-smoke.rs
Normal file
478
epiphany-core/src/bin/epiphany-unity-bridge-smoke.rs
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
const PROJECT_VERSION: &str = "6000.1.10f1";
|
||||
const WRONG_VERSION: &str = "6000.4.2f1";
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let result = run_smoke()?;
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_smoke() -> Result<Value> {
|
||||
let root = env::current_dir().context("failed to resolve current dir")?;
|
||||
let workspace = root.join(".epiphany-smoke").join("unity-bridge-workspace");
|
||||
let artifact_root = root.join(".epiphany-smoke").join("unity-bridge-artifacts");
|
||||
let fake_roots = root.join(".epiphany-smoke").join("unity-bridge-editors");
|
||||
let result_path = root
|
||||
.join(".epiphany-smoke")
|
||||
.join("unity-bridge-smoke-result.json");
|
||||
prepare_project(&workspace, false)?;
|
||||
reset_path(&artifact_root)?;
|
||||
reset_path(&fake_roots)?;
|
||||
|
||||
let wrong_editor = create_fake_editor(&fake_roots, WRONG_VERSION)?;
|
||||
let missing = run_bridge(
|
||||
&root,
|
||||
&workspace,
|
||||
&artifact_root,
|
||||
&fake_roots,
|
||||
&["inspect"],
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
missing["status"] == "missingEditor",
|
||||
"wrong-only Hub root should not satisfy pin",
|
||||
)?;
|
||||
require(
|
||||
missing["projectVersion"] == PROJECT_VERSION,
|
||||
"inspection should report the project-pinned version",
|
||||
)?;
|
||||
require_artifact(&missing, "unity-bridge-summary.json")?;
|
||||
require_artifact(&missing, "unity-bridge-inspection.md")?;
|
||||
|
||||
let exact_editor = create_fake_editor(&fake_roots, PROJECT_VERSION)?;
|
||||
let ready = run_bridge(
|
||||
&root,
|
||||
&workspace,
|
||||
&artifact_root,
|
||||
&fake_roots,
|
||||
&["inspect"],
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
ready["status"] == "ready",
|
||||
"exact editor should satisfy pin",
|
||||
)?;
|
||||
require(
|
||||
ready["editorPath"] == exact_editor.to_string_lossy().as_ref(),
|
||||
"inspection should resolve exact editor",
|
||||
)?;
|
||||
require(
|
||||
ready
|
||||
.pointer("/editorBridge/exists")
|
||||
.and_then(Value::as_bool)
|
||||
== Some(false),
|
||||
"fresh smoke project should not have bridge package yet",
|
||||
)?;
|
||||
|
||||
let planned = run_bridge(
|
||||
&root,
|
||||
&workspace,
|
||||
&artifact_root,
|
||||
&fake_roots,
|
||||
&[
|
||||
"run",
|
||||
"--dry-run",
|
||||
"--",
|
||||
"-executeMethod",
|
||||
"Epiphany.SmokeProbe",
|
||||
],
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
planned["status"] == "ready",
|
||||
"dry run should still require exact editor",
|
||||
)?;
|
||||
require(
|
||||
planned["runStatus"] == "planned",
|
||||
"dry run should plan instead of executing",
|
||||
)?;
|
||||
let command = value_array(&planned, "command")?;
|
||||
require(
|
||||
command.first().and_then(Value::as_str) == Some(exact_editor.to_str().unwrap_or_default()),
|
||||
"command should use exact editor path",
|
||||
)?;
|
||||
require_command_contains(command, "-batchmode")?;
|
||||
require_command_contains(command, "-quit")?;
|
||||
require_command_contains(command, "-projectPath")?;
|
||||
require_command_contains(command, "-logFile")?;
|
||||
require_command_contains(command, &workspace.to_string_lossy())?;
|
||||
require(
|
||||
!command
|
||||
.iter()
|
||||
.any(|item| item.as_str() == Some(wrong_editor.to_str().unwrap_or_default())),
|
||||
"command must not use wrong editor",
|
||||
)?;
|
||||
require_artifact(&planned, "unity-command.json")?;
|
||||
|
||||
let missing_package = run_bridge(
|
||||
&root,
|
||||
&workspace,
|
||||
&artifact_root,
|
||||
&fake_roots,
|
||||
&[
|
||||
"probe",
|
||||
"--dry-run",
|
||||
"--operation",
|
||||
"scene-facts",
|
||||
"--scene",
|
||||
"Assets/Scenes/Main.unity",
|
||||
],
|
||||
false,
|
||||
)?;
|
||||
require(
|
||||
missing_package["status"] == "missingEditorBridgePackage",
|
||||
"named probes should require the resident editor package",
|
||||
)?;
|
||||
require(
|
||||
missing_package["runStatus"] == "blocked",
|
||||
"missing package probe should be blocked",
|
||||
)?;
|
||||
|
||||
prepare_project(&workspace, true)?;
|
||||
let bridged = run_bridge(
|
||||
&root,
|
||||
&workspace,
|
||||
&artifact_root,
|
||||
&fake_roots,
|
||||
&["inspect"],
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
bridged["status"] == "ready",
|
||||
"bridge project should still resolve exact editor",
|
||||
)?;
|
||||
require(
|
||||
bridged
|
||||
.pointer("/editorBridge/exists")
|
||||
.and_then(Value::as_bool)
|
||||
== Some(true),
|
||||
"resident editor bridge should be detected",
|
||||
)?;
|
||||
|
||||
let scene_probe = run_bridge(
|
||||
&root,
|
||||
&workspace,
|
||||
&artifact_root,
|
||||
&fake_roots,
|
||||
&[
|
||||
"probe",
|
||||
"--dry-run",
|
||||
"--operation",
|
||||
"scene-facts",
|
||||
"--scene",
|
||||
"Assets/Scenes/Main.unity",
|
||||
"--max-objects",
|
||||
"25",
|
||||
"--max-properties",
|
||||
"12",
|
||||
],
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
scene_probe["runStatus"] == "planned",
|
||||
"scene probe should plan under dry run",
|
||||
)?;
|
||||
let scene_command = value_array(&scene_probe, "command")?;
|
||||
require(
|
||||
scene_command.first().and_then(Value::as_str)
|
||||
== Some(exact_editor.to_str().unwrap_or_default()),
|
||||
"scene probe should use exact editor path",
|
||||
)?;
|
||||
require_command_contains(scene_command, "-executeMethod")?;
|
||||
require_command_contains(
|
||||
scene_command,
|
||||
"GameCult.Epiphany.Unity.EpiphanyEditorBridge.RunProbe",
|
||||
)?;
|
||||
require_command_contains(scene_command, "-epiphanyArtifactDir")?;
|
||||
let scene_artifact_path = path_value(&scene_probe, "artifactPath")?;
|
||||
require_command_contains(scene_command, &scene_artifact_path.to_string_lossy())?;
|
||||
require_command_contains(scene_command, "-epiphanyOperation")?;
|
||||
require_command_contains(scene_command, "scene-facts")?;
|
||||
require_command_contains(scene_command, "-epiphanyScene")?;
|
||||
require_command_contains(scene_command, "Assets/Scenes/Main.unity")?;
|
||||
require_command_contains(scene_command, "-epiphanyMaxObjects")?;
|
||||
require_command_contains(scene_command, "25")?;
|
||||
let scene_artifact = read_command_artifact(&scene_probe)?;
|
||||
require(
|
||||
scene_artifact["operation"] == "scene-facts",
|
||||
"scene command artifact should name operation",
|
||||
)?;
|
||||
require(
|
||||
array_contains(scene_artifact.get("expectedArtifacts"), "scene-facts.json"),
|
||||
"scene facts artifact should be expected",
|
||||
)?;
|
||||
|
||||
let compilation = run_bridge(
|
||||
&root,
|
||||
&workspace,
|
||||
&artifact_root,
|
||||
&fake_roots,
|
||||
&["check-compilation", "--dry-run"],
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
compilation["runStatus"] == "planned",
|
||||
"compilation probe should plan under dry run",
|
||||
)?;
|
||||
let compilation_artifact = read_command_artifact(&compilation)?;
|
||||
require(
|
||||
array_contains(
|
||||
compilation_artifact.get("expectedArtifacts"),
|
||||
"compilation.json",
|
||||
),
|
||||
"compilation artifact should be expected",
|
||||
)?;
|
||||
|
||||
let tests = run_bridge(
|
||||
&root,
|
||||
&workspace,
|
||||
&artifact_root,
|
||||
&fake_roots,
|
||||
&[
|
||||
"run-tests",
|
||||
"--dry-run",
|
||||
"--platform",
|
||||
"editmode",
|
||||
"--filter",
|
||||
"SmokeSuite",
|
||||
],
|
||||
true,
|
||||
)?;
|
||||
require(
|
||||
tests["runStatus"] == "planned",
|
||||
"Unity test run should plan under dry run",
|
||||
)?;
|
||||
let test_command = value_array(&tests, "command")?;
|
||||
require_command_contains(test_command, "-runTests")?;
|
||||
require_command_contains(test_command, "-testPlatform")?;
|
||||
require_command_contains(test_command, "editmode")?;
|
||||
require_command_contains(test_command, "-testFilter")?;
|
||||
require_command_contains(test_command, "SmokeSuite")?;
|
||||
let tests_artifact = read_command_artifact(&tests)?;
|
||||
require(
|
||||
array_contains(tests_artifact.get("expectedArtifacts"), "test-results.xml"),
|
||||
"test results should be expected",
|
||||
)?;
|
||||
|
||||
let blocked = run_bridge(
|
||||
&root,
|
||||
&workspace,
|
||||
&artifact_root,
|
||||
&fake_roots.join("missing"),
|
||||
&[
|
||||
"run",
|
||||
"--dry-run",
|
||||
"--",
|
||||
"-executeMethod",
|
||||
"Epiphany.SmokeProbe",
|
||||
],
|
||||
false,
|
||||
)?;
|
||||
require(
|
||||
blocked["runStatus"] == "blocked",
|
||||
"missing editor run should be blocked",
|
||||
)?;
|
||||
|
||||
let result = json!({
|
||||
"workspace": workspace,
|
||||
"artifactRoot": artifact_root,
|
||||
"missingStatus": missing["status"],
|
||||
"readyStatus": ready["status"],
|
||||
"plannedStatus": planned["runStatus"],
|
||||
"missingPackageStatus": missing_package["status"],
|
||||
"sceneProbeStatus": scene_probe["runStatus"],
|
||||
"testStatus": tests["runStatus"],
|
||||
"blockedStatus": blocked["runStatus"],
|
||||
"resolvedEditor": ready["editorPath"],
|
||||
});
|
||||
write_json(&result_path, &result)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn run_bridge(
|
||||
root: &Path,
|
||||
workspace: &Path,
|
||||
artifact_root: &Path,
|
||||
editor_roots: &Path,
|
||||
args: &[&str],
|
||||
expect_success: bool,
|
||||
) -> Result<Value> {
|
||||
let bridge = native_bridge_exe()?;
|
||||
ensure_bridge_built(root, "epiphany-unity-bridge", &bridge)?;
|
||||
let output = Command::new(bridge)
|
||||
.current_dir(root)
|
||||
.env("EPIPHANY_UNITY_EDITOR_ROOTS", editor_roots)
|
||||
.arg(args[0])
|
||||
.arg("--project-path")
|
||||
.arg(workspace)
|
||||
.arg("--artifact-root")
|
||||
.arg(artifact_root)
|
||||
.args(&args[1..])
|
||||
.output()
|
||||
.context("failed to run native unity bridge")?;
|
||||
if expect_success {
|
||||
require(
|
||||
output.status.success(),
|
||||
&format!(
|
||||
"bridge should succeed, got {:?}: {}",
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
),
|
||||
)?;
|
||||
} else {
|
||||
require(
|
||||
!output.status.success(),
|
||||
"bridge should fail for blocked runtime execution",
|
||||
)?;
|
||||
}
|
||||
serde_json::from_slice(&output.stdout).with_context(|| {
|
||||
format!(
|
||||
"bridge did not return JSON: {}",
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_project(workspace: &Path, include_bridge: bool) -> Result<()> {
|
||||
reset_path(workspace)?;
|
||||
let settings = workspace.join("ProjectSettings");
|
||||
fs::create_dir_all(&settings)?;
|
||||
fs::write(
|
||||
settings.join("ProjectVersion.txt"),
|
||||
format!(
|
||||
"m_EditorVersion: {PROJECT_VERSION}\nm_EditorVersionWithRevision: {PROJECT_VERSION} (3c681a6c22ff)\n"
|
||||
),
|
||||
)?;
|
||||
if include_bridge {
|
||||
let bridge = workspace
|
||||
.join("Assets")
|
||||
.join("Editor")
|
||||
.join("Epiphany")
|
||||
.join("EpiphanyEditorBridge.cs");
|
||||
fs::create_dir_all(bridge.parent().expect("bridge path has parent"))?;
|
||||
fs::write(
|
||||
bridge,
|
||||
"// smoke marker for resident Epiphany editor bridge\n",
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_fake_editor(root: &Path, version: &str) -> Result<PathBuf> {
|
||||
let editor = root.join(version).join("Editor").join("Unity.exe");
|
||||
fs::create_dir_all(editor.parent().expect("editor path has parent"))?;
|
||||
fs::write(&editor, "fake unity binary for dry-run smoke only\n")?;
|
||||
Ok(editor)
|
||||
}
|
||||
|
||||
fn read_command_artifact(summary: &Value) -> Result<Value> {
|
||||
let command_path = path_value(summary, "artifactPath")?.join("unity-command.json");
|
||||
require(command_path.exists(), "missing unity-command.json")?;
|
||||
let bytes = fs::read(&command_path)?;
|
||||
serde_json::from_slice(&bytes).context("failed to parse unity-command.json")
|
||||
}
|
||||
|
||||
fn require_artifact(summary: &Value, name: &str) -> Result<()> {
|
||||
let artifact_path = path_value(summary, "artifactPath")?;
|
||||
require(
|
||||
artifact_path.join(name).exists(),
|
||||
&format!("missing bridge artifact: {name}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn require_command_contains(command: &[Value], value: &str) -> Result<()> {
|
||||
require(
|
||||
command.iter().any(|item| item.as_str() == Some(value)),
|
||||
&format!("command should contain {value:?}: {command:?}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn native_bridge_exe() -> Result<PathBuf> {
|
||||
let target_dir = env::var_os("CARGO_TARGET_DIR")
|
||||
.unwrap_or_else(|| r"C:\Users\Meta\.cargo-target-codex".into());
|
||||
Ok(PathBuf::from(target_dir)
|
||||
.join("debug")
|
||||
.join(format!("epiphany-unity-bridge{}", env::consts::EXE_SUFFIX)))
|
||||
}
|
||||
|
||||
fn ensure_bridge_built(root: &Path, bin: &str, exe: &Path) -> Result<()> {
|
||||
if exe.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let status = Command::new("cargo")
|
||||
.current_dir(root)
|
||||
.arg("build")
|
||||
.arg("--manifest-path")
|
||||
.arg(root.join("epiphany-core").join("Cargo.toml"))
|
||||
.arg("--bin")
|
||||
.arg(bin)
|
||||
.status()
|
||||
.with_context(|| format!("failed to build native {bin}"))?;
|
||||
require(
|
||||
status.success() && exe.exists(),
|
||||
&format!("native {bin} executable was not built at {}", exe.display()),
|
||||
)
|
||||
}
|
||||
|
||||
fn value_array<'a>(value: &'a Value, key: &str) -> Result<&'a Vec<Value>> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| anyhow!("missing array field {key}"))
|
||||
}
|
||||
|
||||
fn path_value(value: &Value, key: &str) -> Result<PathBuf> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("missing path field {key}"))
|
||||
}
|
||||
|
||||
fn array_contains(value: Option<&Value>, needle: &str) -> bool {
|
||||
value
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|items| items.iter().any(|item| item.as_str() == Some(needle)))
|
||||
}
|
||||
|
||||
fn reset_path(path: &Path) -> Result<()> {
|
||||
if path.exists() {
|
||||
let root = env::current_dir()?.join(".epiphany-smoke").canonicalize()?;
|
||||
let resolved = path.canonicalize()?;
|
||||
if resolved == root || !resolved.starts_with(&root) {
|
||||
return Err(anyhow!(
|
||||
"refusing to reset non-smoke path: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
fs::remove_dir_all(path)?;
|
||||
}
|
||||
fs::create_dir_all(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require(condition: bool, message: &str) -> Result<()> {
|
||||
if condition {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("{message}"))
|
||||
}
|
||||
}
|
||||
964
epiphany-core/src/bin/epiphany-unity-bridge.rs
Normal file
964
epiphany-core/src/bin/epiphany-unity-bridge.rs
Normal file
|
|
@ -0,0 +1,964 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
const PROJECT_VERSION_RELATIVE: &str = "ProjectSettings/ProjectVersion.txt";
|
||||
const UNITY_EDITOR_RELATIVE: &[&str] = &["Editor", "Unity.exe"];
|
||||
const EDITOR_BRIDGE_RELATIVE: &str = "Assets/Editor/Epiphany/EpiphanyEditorBridge.cs";
|
||||
const EDITOR_BRIDGE_EXECUTE_METHOD: &str = "GameCult.Epiphany.Unity.EpiphanyEditorBridge.RunProbe";
|
||||
const FORBIDDEN_EXTRA_ARGS: &[&str] = &["-batchmode", "-quit", "-projectpath", "-logfile"];
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = env::args().skip(1);
|
||||
let Some(command) = args.next() else {
|
||||
return Err(anyhow!(
|
||||
"usage: epiphany-unity-bridge <inspect|run|check-compilation|probe|run-tests|guidance>"
|
||||
));
|
||||
};
|
||||
match run_command(&command, args.collect()) {
|
||||
Ok((code, value)) => {
|
||||
println!("{}", serde_json::to_string_pretty(&value)?);
|
||||
std::process::exit(code);
|
||||
}
|
||||
Err(error) => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"status": "error",
|
||||
"error": error.to_string(),
|
||||
}))?
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_command(command: &str, args: Vec<String>) -> Result<(i32, serde_json::Value)> {
|
||||
let options = UnityOptions::parse(args)?;
|
||||
let result = match command {
|
||||
"inspect" => inspect_unity(&options)?,
|
||||
"run" => run_unity(&options)?,
|
||||
"check-compilation" => run_named_probe(&options, "check-compilation")?,
|
||||
"probe" => {
|
||||
let operation = options
|
||||
.operation
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("probe requires --operation"))?;
|
||||
run_named_probe(&options, operation)?
|
||||
}
|
||||
"run-tests" => test_unity(&options)?,
|
||||
"guidance" => json!({ "guidance": bridge_guidance(&options.project_path)? }),
|
||||
_ => return Err(anyhow!("unsupported command: {command}")),
|
||||
};
|
||||
let blocked = result["runStatus"].as_str() == Some("blocked");
|
||||
Ok((if blocked { 2 } else { 0 }, result))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct UnityOptions {
|
||||
project_path: PathBuf,
|
||||
artifact_root: PathBuf,
|
||||
artifact_dir: Option<PathBuf>,
|
||||
label: String,
|
||||
timeout_seconds: u64,
|
||||
dry_run: bool,
|
||||
unity_args: Vec<String>,
|
||||
operation: Option<String>,
|
||||
scene: Option<String>,
|
||||
asset: Option<String>,
|
||||
guid: Option<String>,
|
||||
max_objects: Option<u32>,
|
||||
max_properties: Option<u32>,
|
||||
max_dependencies: Option<u32>,
|
||||
platform: String,
|
||||
filter: Option<String>,
|
||||
}
|
||||
|
||||
impl UnityOptions {
|
||||
fn parse(args: Vec<String>) -> Result<Self> {
|
||||
let root = repo_root()?;
|
||||
let mut options = Self {
|
||||
project_path: env::current_dir()?,
|
||||
artifact_root: root.join(".epiphany-gui").join("runtime"),
|
||||
artifact_dir: None,
|
||||
label: "unity-bridge-run".to_string(),
|
||||
timeout_seconds: 600,
|
||||
dry_run: false,
|
||||
unity_args: Vec::new(),
|
||||
operation: None,
|
||||
scene: None,
|
||||
asset: None,
|
||||
guid: None,
|
||||
max_objects: None,
|
||||
max_properties: None,
|
||||
max_dependencies: None,
|
||||
platform: "editmode".to_string(),
|
||||
filter: None,
|
||||
};
|
||||
let mut iter = args.into_iter().peekable();
|
||||
while let Some(arg) = iter.next() {
|
||||
if arg == "--" {
|
||||
options.unity_args.extend(iter);
|
||||
break;
|
||||
}
|
||||
match arg.as_str() {
|
||||
"--project-path" => options.project_path = next_path(&mut iter, &arg)?,
|
||||
"--artifact-root" => options.artifact_root = next_path(&mut iter, &arg)?,
|
||||
"--artifact-dir" => options.artifact_dir = Some(next_path(&mut iter, &arg)?),
|
||||
"--label" => options.label = next_value(&mut iter, &arg)?,
|
||||
"--timeout-seconds" => {
|
||||
options.timeout_seconds = next_value(&mut iter, &arg)?.parse()?
|
||||
}
|
||||
"--dry-run" => options.dry_run = true,
|
||||
"--operation" => options.operation = Some(next_value(&mut iter, &arg)?),
|
||||
"--scene" => options.scene = Some(next_value(&mut iter, &arg)?),
|
||||
"--asset" => options.asset = Some(next_value(&mut iter, &arg)?),
|
||||
"--guid" => options.guid = Some(next_value(&mut iter, &arg)?),
|
||||
"--max-objects" => {
|
||||
options.max_objects = Some(next_value(&mut iter, &arg)?.parse()?)
|
||||
}
|
||||
"--max-properties" => {
|
||||
options.max_properties = Some(next_value(&mut iter, &arg)?.parse()?)
|
||||
}
|
||||
"--max-dependencies" => {
|
||||
options.max_dependencies = Some(next_value(&mut iter, &arg)?.parse()?)
|
||||
}
|
||||
"--platform" => options.platform = next_value(&mut iter, &arg)?,
|
||||
"--filter" => options.filter = Some(next_value(&mut iter, &arg)?),
|
||||
_ => options.unity_args.push(arg),
|
||||
}
|
||||
}
|
||||
Ok(options)
|
||||
}
|
||||
}
|
||||
|
||||
fn next_value(iter: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
iter.next()
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
|
||||
fn next_path(iter: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(next_value(iter, name)?))
|
||||
}
|
||||
|
||||
fn repo_root() -> Result<PathBuf> {
|
||||
Ok(Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("epiphany-core has no parent repo root"))?
|
||||
.to_path_buf())
|
||||
}
|
||||
|
||||
fn now_iso() -> String {
|
||||
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
fn clean_windows_path(value: &str) -> String {
|
||||
value.strip_prefix(r"\\?\").unwrap_or(value).to_string()
|
||||
}
|
||||
|
||||
fn path_string(path: &Path) -> String {
|
||||
clean_windows_path(&path.display().to_string())
|
||||
}
|
||||
|
||||
fn maybe_resolve(path: &Path) -> String {
|
||||
path_string(&path.canonicalize().unwrap_or_else(|_| path.to_path_buf()))
|
||||
}
|
||||
|
||||
fn artifact_dir(root: &Path, action: &str) -> Result<PathBuf> {
|
||||
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
|
||||
Ok(root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| root.to_path_buf())
|
||||
.join(format!("unity-{action}-{nanos}-{}", std::process::id())))
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &serde_json::Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))
|
||||
.with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn write_text(path: &Path, value: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, value).with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn read_unity_project_version(project_path: &Path) -> serde_json::Value {
|
||||
let version_path = project_path.join(PROJECT_VERSION_RELATIVE);
|
||||
let mut result = json!({
|
||||
"path": path_string(&version_path),
|
||||
"exists": version_path.exists(),
|
||||
"editorVersion": null,
|
||||
"editorVersionWithRevision": null,
|
||||
});
|
||||
if !version_path.exists() {
|
||||
result["status"] = json!("missingProjectVersion");
|
||||
result["note"] = json!("ProjectSettings/ProjectVersion.txt was not found.");
|
||||
return result;
|
||||
}
|
||||
let Ok(raw) = fs::read_to_string(&version_path) else {
|
||||
result["status"] = json!("missingProjectVersion");
|
||||
result["note"] = json!("ProjectVersion.txt could not be read.");
|
||||
return result;
|
||||
};
|
||||
for line in raw.lines() {
|
||||
if let Some(value) = line.strip_prefix("m_EditorVersion:") {
|
||||
result["editorVersion"] = json!(value.trim());
|
||||
} else if let Some(value) = line.strip_prefix("m_EditorVersionWithRevision:") {
|
||||
result["editorVersionWithRevision"] = json!(value.trim());
|
||||
}
|
||||
}
|
||||
if result["editorVersion"]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
result["status"] = json!("ready");
|
||||
} else {
|
||||
result["status"] = json!("missingProjectVersion");
|
||||
result["note"] = json!("ProjectVersion.txt did not contain m_EditorVersion.");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn default_unity_editor_roots() -> Vec<PathBuf> {
|
||||
let mut roots = Vec::new();
|
||||
if let Ok(value) = env::var("EPIPHANY_UNITY_EDITOR_ROOTS") {
|
||||
roots.extend(env::split_paths(&value));
|
||||
}
|
||||
roots.push(
|
||||
PathBuf::from(env::var("ProgramFiles").unwrap_or_else(|_| r"C:\Program Files".to_string()))
|
||||
.join("Unity")
|
||||
.join("Hub")
|
||||
.join("Editor"),
|
||||
);
|
||||
roots.push(
|
||||
PathBuf::from(
|
||||
env::var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".to_string()),
|
||||
)
|
||||
.join("Unity")
|
||||
.join("Hub")
|
||||
.join("Editor"),
|
||||
);
|
||||
dedupe_paths(roots)
|
||||
}
|
||||
|
||||
fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut result = Vec::new();
|
||||
for path in paths {
|
||||
let key = maybe_resolve(&path).to_lowercase();
|
||||
if seen.insert(key) {
|
||||
result.push(path);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn unity_editor_relative() -> PathBuf {
|
||||
UNITY_EDITOR_RELATIVE.iter().collect()
|
||||
}
|
||||
|
||||
fn editor_path_for_version(root: &Path, version: &str) -> PathBuf {
|
||||
if root.file_name().and_then(|value| value.to_str()) == Some(version) {
|
||||
root.join(unity_editor_relative())
|
||||
} else {
|
||||
root.join(version).join(unity_editor_relative())
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_unity_editors(roots: &[PathBuf]) -> Result<Vec<serde_json::Value>> {
|
||||
let mut editors = Vec::new();
|
||||
let rel = unity_editor_relative();
|
||||
for root in roots {
|
||||
if !root.exists() {
|
||||
continue;
|
||||
}
|
||||
let direct = root.join(&rel);
|
||||
if direct.exists() {
|
||||
editors.push(json!({
|
||||
"version": root.file_name().and_then(|value| value.to_str()),
|
||||
"root": path_string(root),
|
||||
"editorPath": path_string(&direct),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
for entry in fs::read_dir(root)? {
|
||||
let child = entry?.path();
|
||||
if !child.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let editor = child.join(&rel);
|
||||
if editor.exists() {
|
||||
editors.push(json!({
|
||||
"version": child.file_name().and_then(|value| value.to_str()),
|
||||
"root": path_string(&child),
|
||||
"editorPath": path_string(&editor),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(editors)
|
||||
}
|
||||
|
||||
fn detect_editor_bridge(project_path: &Path) -> serde_json::Value {
|
||||
let package_path = project_path.join(EDITOR_BRIDGE_RELATIVE);
|
||||
json!({
|
||||
"kind": "epiphanyUnityEditorBridge",
|
||||
"path": path_string(&package_path),
|
||||
"relativePath": EDITOR_BRIDGE_RELATIVE,
|
||||
"exists": package_path.exists(),
|
||||
"executeMethod": EDITOR_BRIDGE_EXECUTE_METHOD,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_unity_editor(project_path: &Path) -> Result<serde_json::Value> {
|
||||
let project_path = project_path
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| project_path.to_path_buf());
|
||||
let version = read_unity_project_version(&project_path);
|
||||
let roots = default_unity_editor_roots();
|
||||
let editors = installed_unity_editors(&roots)?;
|
||||
let mut result = json!({
|
||||
"kind": "unity",
|
||||
"projectPath": path_string(&project_path),
|
||||
"generatedAt": now_iso(),
|
||||
"versionFile": version,
|
||||
"searchRoots": roots.iter().map(|path| path_string(path)).collect::<Vec<_>>(),
|
||||
"installedEditors": editors,
|
||||
"projectVersion": null,
|
||||
"projectVersionWithRevision": null,
|
||||
"editorPath": null,
|
||||
"status": "missingProjectVersion",
|
||||
"note": "ProjectSettings/ProjectVersion.txt was not found or did not pin a Unity editor.",
|
||||
"editorBridge": detect_editor_bridge(&project_path),
|
||||
});
|
||||
result["projectVersion"] = result["versionFile"]["editorVersion"].clone();
|
||||
result["projectVersionWithRevision"] =
|
||||
result["versionFile"]["editorVersionWithRevision"].clone();
|
||||
let Some(project_version) = result["projectVersion"].as_str().map(ToOwned::to_owned) else {
|
||||
return Ok(result);
|
||||
};
|
||||
if project_version.is_empty() {
|
||||
return Ok(result);
|
||||
}
|
||||
let exact_candidates: Vec<PathBuf> = roots
|
||||
.iter()
|
||||
.map(|root| editor_path_for_version(root, &project_version))
|
||||
.collect();
|
||||
let exact = exact_candidates.iter().find(|path| path.exists());
|
||||
result["candidatePaths"] = json!(
|
||||
exact_candidates
|
||||
.iter()
|
||||
.map(|path| path_string(path))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
if let Some(exact) = exact {
|
||||
result["status"] = json!("ready");
|
||||
result["editorPath"] = json!(path_string(exact));
|
||||
result["note"] = json!(format!(
|
||||
"Project pins Unity {project_version}; exact editor resolved at {}.",
|
||||
path_string(exact)
|
||||
));
|
||||
} else {
|
||||
let installed_versions = result["installedEditors"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|item| item["version"].as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
result["status"] = json!("missingEditor");
|
||||
result["note"] = json!(format!(
|
||||
"Project pins Unity {project_version}, but no exact editor was found. Installed Hub versions: {}.",
|
||||
if installed_versions.is_empty() {
|
||||
"none detected"
|
||||
} else {
|
||||
&installed_versions
|
||||
}
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn render_inspection(summary: &serde_json::Value) -> String {
|
||||
let installed_lines = summary["installedEditors"]
|
||||
.as_array()
|
||||
.filter(|items| !items.is_empty())
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
format!(
|
||||
"- {}: {}",
|
||||
item["version"].as_str().unwrap_or("unknown"),
|
||||
item["editorPath"].as_str().unwrap_or("unknown")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_else(|| vec!["- none detected".to_string()]);
|
||||
let candidate_lines = summary["candidatePaths"]
|
||||
.as_array()
|
||||
.filter(|items| !items.is_empty())
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str())
|
||||
.map(|item| format!("- {item}"))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_else(|| vec!["- none".to_string()]);
|
||||
format!(
|
||||
"# Unity Runtime Bridge\n\nStatus: {}\nProject: {}\nProject version: {}\nEditor path: {}\n\nInstalled Hub editors:\n{}\n\nExact candidate paths:\n{}\n\nNote: {}\n",
|
||||
summary["status"].as_str().unwrap_or("unknown"),
|
||||
summary["projectPath"].as_str().unwrap_or("unknown"),
|
||||
summary["projectVersion"].as_str().unwrap_or("none"),
|
||||
summary["editorPath"].as_str().unwrap_or("none"),
|
||||
installed_lines.join("\n"),
|
||||
candidate_lines.join("\n"),
|
||||
summary["note"].as_str().unwrap_or(""),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_inspection_artifacts(directory: &Path, summary: &serde_json::Value) -> Result<()> {
|
||||
write_json(&directory.join("unity-bridge-summary.json"), summary)?;
|
||||
write_text(
|
||||
&directory.join("unity-bridge-inspection.md"),
|
||||
&render_inspection(summary),
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_unity_args(values: &[String]) -> Result<()> {
|
||||
let lowered: HashSet<String> = values.iter().map(|value| value.to_lowercase()).collect();
|
||||
let forbidden = FORBIDDEN_EXTRA_ARGS
|
||||
.iter()
|
||||
.filter(|value| lowered.contains(**value))
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
if !forbidden.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Unity bridge owns batchmode, quit, projectPath, and logFile; remove extra args: {}",
|
||||
forbidden.join(", ")
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_unity_command(
|
||||
summary: &serde_json::Value,
|
||||
extra_args: &[String],
|
||||
log_path: Option<&Path>,
|
||||
) -> Result<Vec<String>> {
|
||||
let editor_path = summary["editorPath"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("Unity editor path is unavailable."))?;
|
||||
let project_path = summary["projectPath"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("Unity project path is unavailable."))?;
|
||||
validate_unity_args(extra_args)?;
|
||||
let mut command = vec![
|
||||
editor_path.to_string(),
|
||||
"-batchmode".to_string(),
|
||||
"-quit".to_string(),
|
||||
"-projectPath".to_string(),
|
||||
project_path.to_string(),
|
||||
];
|
||||
if let Some(log_path) = log_path {
|
||||
command.extend(["-logFile".to_string(), path_string(log_path)]);
|
||||
}
|
||||
command.extend(extra_args.iter().cloned());
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn editor_bridge_ready(summary: &serde_json::Value) -> bool {
|
||||
summary["editorBridge"]["exists"].as_bool() == Some(true)
|
||||
}
|
||||
|
||||
fn named_probe_extra_args(
|
||||
options: &UnityOptions,
|
||||
operation: &str,
|
||||
directory: &Path,
|
||||
) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"-executeMethod".to_string(),
|
||||
EDITOR_BRIDGE_EXECUTE_METHOD.to_string(),
|
||||
"-epiphanyArtifactDir".to_string(),
|
||||
path_string(directory),
|
||||
"-epiphanyOperation".to_string(),
|
||||
operation.to_string(),
|
||||
];
|
||||
for (value, flag) in [
|
||||
(
|
||||
options.scene.as_ref().map(ToString::to_string),
|
||||
"-epiphanyScene",
|
||||
),
|
||||
(
|
||||
options.asset.as_ref().map(ToString::to_string),
|
||||
"-epiphanyAsset",
|
||||
),
|
||||
(
|
||||
options.guid.as_ref().map(ToString::to_string),
|
||||
"-epiphanyGuid",
|
||||
),
|
||||
(
|
||||
options.max_objects.map(|value| value.to_string()),
|
||||
"-epiphanyMaxObjects",
|
||||
),
|
||||
(
|
||||
options.max_properties.map(|value| value.to_string()),
|
||||
"-epiphanyMaxProperties",
|
||||
),
|
||||
(
|
||||
options.max_dependencies.map(|value| value.to_string()),
|
||||
"-epiphanyMaxDependencies",
|
||||
),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
args.extend([flag.to_string(), value]);
|
||||
}
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
fn expected_artifacts(operation: &str) -> Vec<&'static str> {
|
||||
match operation {
|
||||
"inspect-project" => vec![
|
||||
"project-facts.json",
|
||||
"unity-probe-result.json",
|
||||
"unity-probe-result.md",
|
||||
],
|
||||
"check-compilation" => vec![
|
||||
"compilation.json",
|
||||
"unity-probe-result.json",
|
||||
"unity-probe-result.md",
|
||||
],
|
||||
"scene-facts" => vec![
|
||||
"scene-facts.json",
|
||||
"unity-probe-result.json",
|
||||
"unity-probe-result.md",
|
||||
],
|
||||
"prefab-facts" => vec![
|
||||
"prefab-facts.json",
|
||||
"unity-probe-result.json",
|
||||
"unity-probe-result.md",
|
||||
],
|
||||
"serialized-object" => vec![
|
||||
"serialized-object-facts.json",
|
||||
"unity-probe-result.json",
|
||||
"unity-probe-result.md",
|
||||
],
|
||||
"reference-search" => vec![
|
||||
"reference-search.json",
|
||||
"unity-probe-result.json",
|
||||
"unity-probe-result.md",
|
||||
],
|
||||
"run-tests" => vec!["test-results.xml", "unity.log"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_description(operation: &str) -> &'static str {
|
||||
match operation {
|
||||
"inspect-project" => "Capture Unity project/editor/build/render-pipeline facts.",
|
||||
"check-compilation" => {
|
||||
"Check whether the editor bridge can run after Unity script compilation."
|
||||
}
|
||||
"scene-facts" => {
|
||||
"Capture scene hierarchy, components, prefab links, and serialized fields."
|
||||
}
|
||||
"prefab-facts" => {
|
||||
"Capture prefab hierarchy, nested instances, overrides, and serialized fields."
|
||||
}
|
||||
"serialized-object" => "Capture SerializedObject properties for one asset.",
|
||||
"reference-search" => "Search Unity asset dependencies for references to a GUID or asset.",
|
||||
_ => "Run a named Unity editor bridge probe.",
|
||||
}
|
||||
}
|
||||
|
||||
fn write_command_artifact(
|
||||
directory: &Path,
|
||||
command: &[String],
|
||||
dry_run: bool,
|
||||
operation: &str,
|
||||
expected_artifacts: Vec<&str>,
|
||||
) -> Result<()> {
|
||||
write_json(
|
||||
&directory.join("unity-command.json"),
|
||||
&json!({
|
||||
"command": command,
|
||||
"dryRun": dry_run,
|
||||
"operation": operation,
|
||||
"expectedArtifacts": expected_artifacts,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_unity(options: &UnityOptions) -> Result<serde_json::Value> {
|
||||
let directory = options
|
||||
.artifact_dir
|
||||
.clone()
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| artifact_dir(&options.artifact_root, "run"))?;
|
||||
let project_path = options
|
||||
.project_path
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| options.project_path.clone());
|
||||
let mut summary = resolve_unity_editor(&project_path)?;
|
||||
summary["artifactPath"] = json!(path_string(&directory));
|
||||
summary["operation"] = json!("run");
|
||||
summary["label"] = json!(options.label);
|
||||
summary["unityArgs"] = json!(options.unity_args);
|
||||
if summary["status"].as_str() != Some("ready") {
|
||||
summary["runStatus"] = json!("blocked");
|
||||
summary["note"] = json!(format!(
|
||||
"{} Runtime execution refused; install the exact pinned editor or use inspect artifacts as evidence of the missing runtime.",
|
||||
summary["note"].as_str().unwrap_or("")
|
||||
));
|
||||
write_inspection_artifacts(&directory, &summary)?;
|
||||
return Ok(summary);
|
||||
}
|
||||
let log_path = directory.join("unity.log");
|
||||
let command = build_unity_command(&summary, &options.unity_args, Some(&log_path))?;
|
||||
summary["command"] = json!(command);
|
||||
summary["logPath"] = json!(path_string(&log_path));
|
||||
if options.dry_run {
|
||||
summary["runStatus"] = json!("planned");
|
||||
summary["returncode"] = serde_json::Value::Null;
|
||||
write_inspection_artifacts(&directory, &summary)?;
|
||||
write_command_artifact(
|
||||
&directory,
|
||||
summary["command"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice(),
|
||||
true,
|
||||
"run",
|
||||
Vec::new(),
|
||||
)?;
|
||||
return Ok(summary);
|
||||
}
|
||||
execute_command(
|
||||
&mut summary,
|
||||
&directory,
|
||||
&project_path,
|
||||
options.timeout_seconds,
|
||||
)?;
|
||||
write_inspection_artifacts(&directory, &summary)?;
|
||||
write_command_artifact(
|
||||
&directory,
|
||||
summary["command"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice(),
|
||||
false,
|
||||
"run",
|
||||
Vec::new(),
|
||||
)?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn run_named_probe(options: &UnityOptions, operation: &str) -> Result<serde_json::Value> {
|
||||
let allowed = [
|
||||
"inspect-project",
|
||||
"check-compilation",
|
||||
"scene-facts",
|
||||
"prefab-facts",
|
||||
"serialized-object",
|
||||
"reference-search",
|
||||
];
|
||||
if !allowed.contains(&operation) {
|
||||
return Err(anyhow!("unsupported probe operation: {operation}"));
|
||||
}
|
||||
let directory =
|
||||
options.artifact_dir.clone().map(Ok).unwrap_or_else(|| {
|
||||
artifact_dir(&options.artifact_root, &operation.replace('-', "_"))
|
||||
})?;
|
||||
let project_path = options
|
||||
.project_path
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| options.project_path.clone());
|
||||
let mut summary = resolve_unity_editor(&project_path)?;
|
||||
summary["artifactPath"] = json!(path_string(&directory));
|
||||
summary["operation"] = json!(operation);
|
||||
summary["probe"] = json!({
|
||||
"description": probe_description(operation),
|
||||
"expectedArtifacts": expected_artifacts(operation),
|
||||
});
|
||||
if summary["status"].as_str() != Some("ready") {
|
||||
summary["runStatus"] = json!("blocked");
|
||||
summary["note"] = json!(format!(
|
||||
"{} Runtime/editor probe refused; install the exact pinned editor or use inspect artifacts as evidence of the missing runtime.",
|
||||
summary["note"].as_str().unwrap_or("")
|
||||
));
|
||||
write_inspection_artifacts(&directory, &summary)?;
|
||||
return Ok(summary);
|
||||
}
|
||||
if !editor_bridge_ready(&summary) {
|
||||
let bridge_path = summary["editorBridge"]["path"]
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| path_string(&project_path.join(EDITOR_BRIDGE_RELATIVE)));
|
||||
summary["status"] = json!("missingEditorBridgePackage");
|
||||
summary["runStatus"] = json!("blocked");
|
||||
summary["note"] = json!(format!(
|
||||
"The Epiphany Unity editor package is missing. Add {bridge_path} before running editor-resident probes."
|
||||
));
|
||||
write_inspection_artifacts(&directory, &summary)?;
|
||||
return Ok(summary);
|
||||
}
|
||||
let extra_args = named_probe_extra_args(options, operation, &directory);
|
||||
let log_path = directory.join("unity.log");
|
||||
let command = build_unity_command(&summary, &extra_args, Some(&log_path))?;
|
||||
summary["command"] = json!(command);
|
||||
summary["logPath"] = json!(path_string(&log_path));
|
||||
if options.dry_run {
|
||||
summary["runStatus"] = json!("planned");
|
||||
summary["returncode"] = serde_json::Value::Null;
|
||||
write_inspection_artifacts(&directory, &summary)?;
|
||||
write_command_artifact(
|
||||
&directory,
|
||||
summary["command"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice(),
|
||||
true,
|
||||
operation,
|
||||
expected_artifacts(operation),
|
||||
)?;
|
||||
return Ok(summary);
|
||||
}
|
||||
execute_command(
|
||||
&mut summary,
|
||||
&directory,
|
||||
&project_path,
|
||||
options.timeout_seconds,
|
||||
)?;
|
||||
write_inspection_artifacts(&directory, &summary)?;
|
||||
write_command_artifact(
|
||||
&directory,
|
||||
summary["command"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice(),
|
||||
false,
|
||||
operation,
|
||||
expected_artifacts(operation),
|
||||
)?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn test_unity(options: &UnityOptions) -> Result<serde_json::Value> {
|
||||
let directory = options
|
||||
.artifact_dir
|
||||
.clone()
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| artifact_dir(&options.artifact_root, "run_tests"))?;
|
||||
let project_path = options
|
||||
.project_path
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| options.project_path.clone());
|
||||
let mut summary = resolve_unity_editor(&project_path)?;
|
||||
summary["artifactPath"] = json!(path_string(&directory));
|
||||
summary["operation"] = json!("run-tests");
|
||||
summary["testPlatform"] = json!(options.platform);
|
||||
summary["testFilter"] = json!(options.filter);
|
||||
if summary["status"].as_str() != Some("ready") {
|
||||
summary["runStatus"] = json!("blocked");
|
||||
summary["note"] = json!(format!(
|
||||
"{} Unity tests refused; install the exact pinned editor or use inspect artifacts as evidence of the missing runtime.",
|
||||
summary["note"].as_str().unwrap_or("")
|
||||
));
|
||||
write_inspection_artifacts(&directory, &summary)?;
|
||||
return Ok(summary);
|
||||
}
|
||||
let log_path = directory.join("unity.log");
|
||||
let test_results = directory.join("test-results.xml");
|
||||
let mut extra_args = vec![
|
||||
"-runTests".to_string(),
|
||||
"-testPlatform".to_string(),
|
||||
options.platform.clone(),
|
||||
"-testResults".to_string(),
|
||||
path_string(&test_results),
|
||||
];
|
||||
if let Some(filter) = &options.filter {
|
||||
extra_args.extend(["-testFilter".to_string(), filter.clone()]);
|
||||
}
|
||||
let command = build_unity_command(&summary, &extra_args, Some(&log_path))?;
|
||||
summary["command"] = json!(command);
|
||||
summary["logPath"] = json!(path_string(&log_path));
|
||||
summary["testResultsPath"] = json!(path_string(&test_results));
|
||||
if options.dry_run {
|
||||
summary["runStatus"] = json!("planned");
|
||||
summary["returncode"] = serde_json::Value::Null;
|
||||
write_inspection_artifacts(&directory, &summary)?;
|
||||
write_command_artifact(
|
||||
&directory,
|
||||
summary["command"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice(),
|
||||
true,
|
||||
"run-tests",
|
||||
expected_artifacts("run-tests"),
|
||||
)?;
|
||||
return Ok(summary);
|
||||
}
|
||||
execute_command(
|
||||
&mut summary,
|
||||
&directory,
|
||||
&project_path,
|
||||
options.timeout_seconds,
|
||||
)?;
|
||||
write_inspection_artifacts(&directory, &summary)?;
|
||||
write_command_artifact(
|
||||
&directory,
|
||||
summary["command"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice(),
|
||||
false,
|
||||
"run-tests",
|
||||
expected_artifacts("run-tests"),
|
||||
)?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn execute_command(
|
||||
summary: &mut serde_json::Value,
|
||||
directory: &Path,
|
||||
project_path: &Path,
|
||||
timeout_seconds: u64,
|
||||
) -> Result<()> {
|
||||
let command = summary["command"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow!("summary command is not an array"))?
|
||||
.iter()
|
||||
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
|
||||
.collect::<Vec<_>>();
|
||||
let (program, args) = command
|
||||
.split_first()
|
||||
.ok_or_else(|| anyhow!("empty Unity command"))?;
|
||||
let stdout_path = directory.join("unity-stdout.log");
|
||||
let stderr_path = directory.join("unity-stderr.log");
|
||||
fs::create_dir_all(directory)?;
|
||||
let started = Instant::now();
|
||||
let mut child = Command::new(program)
|
||||
.args(args)
|
||||
.current_dir(project_path)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()?;
|
||||
let timeout = Duration::from_secs(timeout_seconds);
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
let output = child.wait_with_output()?;
|
||||
fs::write(&stdout_path, output.stdout)?;
|
||||
fs::write(&stderr_path, output.stderr)?;
|
||||
let code = status.code();
|
||||
summary["runStatus"] = json!(if status.success() { "passed" } else { "failed" });
|
||||
summary["returncode"] = json!(code);
|
||||
summary["durationSeconds"] =
|
||||
json!((started.elapsed().as_millis() as f64 / 1000.0).round());
|
||||
summary["stdoutPath"] = json!(path_string(&stdout_path));
|
||||
summary["stderrPath"] = json!(path_string(&stderr_path));
|
||||
return Ok(());
|
||||
}
|
||||
if started.elapsed() > timeout {
|
||||
let _ = child.kill();
|
||||
let output = child.wait_with_output()?;
|
||||
if !output.stdout.is_empty() {
|
||||
fs::write(&stdout_path, output.stdout)?;
|
||||
summary["stdoutPath"] = json!(path_string(&stdout_path));
|
||||
}
|
||||
if !output.stderr.is_empty() {
|
||||
fs::write(&stderr_path, output.stderr)?;
|
||||
summary["stderrPath"] = json!(path_string(&stderr_path));
|
||||
}
|
||||
summary["runStatus"] = json!("timedOut");
|
||||
summary["returncode"] = serde_json::Value::Null;
|
||||
summary["durationSeconds"] =
|
||||
json!((started.elapsed().as_millis() as f64 / 1000.0).round());
|
||||
summary["note"] = json!(format!(
|
||||
"Unity command timed out after {timeout_seconds} seconds."
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_unity(options: &UnityOptions) -> Result<serde_json::Value> {
|
||||
let directory = options
|
||||
.artifact_dir
|
||||
.clone()
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| artifact_dir(&options.artifact_root, "inspect"))?;
|
||||
let mut summary = resolve_unity_editor(&options.project_path)?;
|
||||
summary["artifactPath"] = json!(path_string(&directory));
|
||||
summary["operation"] = json!("inspect");
|
||||
write_inspection_artifacts(&directory, &summary)?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn bridge_guidance(project_path: &Path) -> Result<String> {
|
||||
let summary = resolve_unity_editor(project_path)?;
|
||||
let inspect_command = format!(
|
||||
"`epiphany-unity-bridge inspect --project-path {}`",
|
||||
project_path.display()
|
||||
);
|
||||
if summary["status"].as_str() == Some("ready") {
|
||||
if editor_bridge_ready(&summary) {
|
||||
Ok(format!(
|
||||
"- Unity bridge: project pins {} and the exact editor resolved to `{}`. If Unity execution is needed, use named bridge operations such as `epiphany-unity-bridge probe --project-path {} --operation scene-facts --scene <Assets/...unity>` or `epiphany-unity-bridge check-compilation --project-path {}`. The bridge owns -batchmode, -quit, -projectPath, -logFile, -executeMethod, and artifacts. Do not invoke `Unity`, `Unity.exe`, default installs, or PATH-resolved editors directly, and do not refactor Unity-owned scene/prefab state as raw text when the editor bridge can inspect it.",
|
||||
summary["projectVersion"].as_str().unwrap_or("unknown"),
|
||||
summary["editorPath"].as_str().unwrap_or("unknown"),
|
||||
project_path.display(),
|
||||
project_path.display(),
|
||||
))
|
||||
} else {
|
||||
Ok(format!(
|
||||
"- Unity bridge: project pins {} and the exact editor resolved to `{}`, but the resident editor package is missing. Add `{}` before claiming scene, prefab, or runtime facts. Do not invoke `Unity`, `Unity.exe`, default installs, or PATH-resolved editors directly.",
|
||||
summary["projectVersion"].as_str().unwrap_or("unknown"),
|
||||
summary["editorPath"].as_str().unwrap_or("unknown"),
|
||||
EDITOR_BRIDGE_RELATIVE,
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Ok(format!(
|
||||
"- Unity bridge: {} Run {} to write an auditable runtime evidence artifact. Do not invoke `Unity`, `Unity.exe`, default installs, or PATH-resolved editors directly; if runtime parity is needed, stop with the bridge artifact as the evidence gap.",
|
||||
summary["note"]
|
||||
.as_str()
|
||||
.unwrap_or("Unity bridge is blocked."),
|
||||
inspect_command,
|
||||
))
|
||||
}
|
||||
}
|
||||
132
epiphany-core/src/bin/epiphany-verse-query.rs
Normal file
132
epiphany-core/src/bin/epiphany-verse-query.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use epiphany_core::import_gjallar_daemon_affordances;
|
||||
use epiphany_core::query_epiphany_local_verse_context;
|
||||
use epiphany_core::seed_epiphany_local_verse_context;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
match args.command.as_str() {
|
||||
"seed" => {
|
||||
seed_epiphany_local_verse_context(
|
||||
&args.store,
|
||||
args.runtime_id.clone(),
|
||||
Utc::now().to_rfc3339(),
|
||||
)?;
|
||||
if let Some(gjallar_store) = args.gjallar_affordance_store.as_ref() {
|
||||
import_gjallar_daemon_affordances(
|
||||
&args.store,
|
||||
gjallar_store,
|
||||
args.runtime_id.clone(),
|
||||
)?;
|
||||
}
|
||||
let context = query_epiphany_local_verse_context(&args.store, args.runtime_id)?;
|
||||
println!("{}", serde_json::to_string_pretty(&context)?);
|
||||
}
|
||||
"query" => {
|
||||
if let Some(gjallar_store) = args.gjallar_affordance_store.as_ref() {
|
||||
import_gjallar_daemon_affordances(
|
||||
&args.store,
|
||||
gjallar_store,
|
||||
args.runtime_id.clone(),
|
||||
)?;
|
||||
}
|
||||
let context = query_epiphany_local_verse_context(&args.store, args.runtime_id)?;
|
||||
println!("{}", serde_json::to_string_pretty(&context)?);
|
||||
}
|
||||
"smoke" => {
|
||||
seed_epiphany_local_verse_context(
|
||||
&args.store,
|
||||
args.runtime_id.clone(),
|
||||
"2026-06-02T00:00:00Z",
|
||||
)?;
|
||||
if let Some(gjallar_store) = args.gjallar_affordance_store.as_ref() {
|
||||
import_gjallar_daemon_affordances(
|
||||
&args.store,
|
||||
gjallar_store,
|
||||
args.runtime_id.clone(),
|
||||
)?;
|
||||
}
|
||||
let context = query_epiphany_local_verse_context(&args.store, args.runtime_id)?;
|
||||
if context.verse_policies.len() != 3 {
|
||||
anyhow::bail!("local Verse query smoke expected three Verse policies");
|
||||
}
|
||||
if !context.verse_policies.iter().any(|policy| {
|
||||
policy.verse_id == "gamecult-local" && policy.yggdrasil_tunnel_allowed
|
||||
}) {
|
||||
anyhow::bail!("local Verse query smoke lost Yggdrasil tunnel policy");
|
||||
}
|
||||
if context.contract_summaries.len() < 6 {
|
||||
anyhow::bail!("local Verse query smoke expected organ contract summaries");
|
||||
}
|
||||
if args.gjallar_affordance_store.is_some() && context.daemon_affordances.is_empty() {
|
||||
anyhow::bail!("local Verse query smoke expected imported daemon affordances");
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"status": "ok",
|
||||
"store": args.store,
|
||||
"runtimeId": context.runtime_id,
|
||||
"verses": context.verse_policies.len(),
|
||||
"globalRooms": context.global_room_policies.len(),
|
||||
"contracts": context.contract_summaries.len(),
|
||||
"daemonAffordances": context.daemon_affordances.len(),
|
||||
}))?
|
||||
);
|
||||
}
|
||||
other => anyhow::bail!("unknown command {other:?}; use seed, query, or smoke"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Args {
|
||||
command: String,
|
||||
store: PathBuf,
|
||||
runtime_id: String,
|
||||
gjallar_affordance_store: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let mut values = env::args().skip(1);
|
||||
let command = values.next().unwrap_or_else(|| "query".to_string());
|
||||
let mut store = PathBuf::from(".epiphany-run/cultmesh/epiphany-local.cc");
|
||||
let mut runtime_id = "epiphany-local".to_string();
|
||||
let mut gjallar_affordance_store = None;
|
||||
|
||||
while let Some(arg) = values.next() {
|
||||
match arg.as_str() {
|
||||
"--store" => {
|
||||
store = PathBuf::from(values.next().context("missing --store value")?);
|
||||
}
|
||||
"--runtime-id" => {
|
||||
runtime_id = values.next().context("missing --runtime-id value")?;
|
||||
}
|
||||
"--gjallar-affordance-store" => {
|
||||
gjallar_affordance_store = Some(PathBuf::from(
|
||||
values
|
||||
.next()
|
||||
.context("missing --gjallar-affordance-store value")?,
|
||||
));
|
||||
}
|
||||
_ => anyhow::bail!("unknown argument {arg:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent) = store.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
command,
|
||||
store,
|
||||
runtime_id,
|
||||
gjallar_affordance_store,
|
||||
})
|
||||
}
|
||||
}
|
||||
620
epiphany-core/src/bin/epiphany-void-memory.rs
Normal file
620
epiphany-core/src/bin/epiphany-void-memory.rs
Normal file
|
|
@ -0,0 +1,620 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use postgres::NoTls;
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::blocking::ClientBuilder;
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest::header::HeaderValue;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
const STATUS_SCHEMA_VERSION: &str = "epiphany.void_memory_status.v0";
|
||||
const SEARCH_SCHEMA_VERSION: &str = "epiphany.void_memory_search.v0";
|
||||
const CONTEXT_SCHEMA_VERSION: &str = "epiphany.void_message_context.v0";
|
||||
const DEFAULT_CONFIG: &str = "state/void-memory.toml";
|
||||
const MAX_LIMIT: usize = 20;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = env::args().skip(1);
|
||||
let Some(command) = args.next() else {
|
||||
return usage();
|
||||
};
|
||||
let mut config_path = PathBuf::from(DEFAULT_CONFIG);
|
||||
let mut query: Option<String> = None;
|
||||
let mut message_id: Option<String> = None;
|
||||
let mut limit = 5_usize;
|
||||
let mut before = 4_usize;
|
||||
let mut after = 4_usize;
|
||||
let mut guild_id: Option<String> = None;
|
||||
let mut channel_id: Option<String> = None;
|
||||
let mut author_id: Option<String> = None;
|
||||
let mut repo_name: Option<String> = None;
|
||||
let mut path_prefix: Option<String> = None;
|
||||
let mut language: Option<String> = None;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--config" => config_path = PathBuf::from(take(&mut args, "--config")?),
|
||||
"--query" => query = Some(take(&mut args, "--query")?),
|
||||
"--message-id" => message_id = Some(take(&mut args, "--message-id")?),
|
||||
"--limit" => limit = take(&mut args, "--limit")?.parse()?,
|
||||
"--before" => before = take(&mut args, "--before")?.parse()?,
|
||||
"--after" => after = take(&mut args, "--after")?.parse()?,
|
||||
"--guild-id" => guild_id = Some(take(&mut args, "--guild-id")?),
|
||||
"--channel-id" => channel_id = Some(take(&mut args, "--channel-id")?),
|
||||
"--author-id" => author_id = Some(take(&mut args, "--author-id")?),
|
||||
"--repo-name" => repo_name = Some(take(&mut args, "--repo-name")?),
|
||||
"--path-prefix" => path_prefix = Some(take(&mut args, "--path-prefix")?),
|
||||
"--language" => language = Some(take(&mut args, "--language")?),
|
||||
other => return Err(anyhow!("unknown argument {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
limit = limit.clamp(1, MAX_LIMIT);
|
||||
before = before.min(MAX_LIMIT);
|
||||
after = after.min(MAX_LIMIT);
|
||||
let config = BridgeConfig::load(&config_path)?;
|
||||
let output = match command.as_str() {
|
||||
"status" => run_status(&config),
|
||||
"search-history" => run_search(
|
||||
&config,
|
||||
query.context("missing --query")?,
|
||||
limit,
|
||||
SearchCorpus::History {
|
||||
guild_id,
|
||||
channel_id,
|
||||
author_id,
|
||||
},
|
||||
),
|
||||
"search-sources" => run_search(
|
||||
&config,
|
||||
query.context("missing --query")?,
|
||||
limit,
|
||||
SearchCorpus::Source {
|
||||
repo_name,
|
||||
path_prefix,
|
||||
language,
|
||||
},
|
||||
),
|
||||
"message-context" => run_message_context(
|
||||
&config,
|
||||
message_id.context("missing --message-id")?,
|
||||
before,
|
||||
after,
|
||||
),
|
||||
"smoke" => run_smoke(),
|
||||
_ => return usage(),
|
||||
}?;
|
||||
println!("{}", serde_json::to_string_pretty(&output)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct BridgeConfig {
|
||||
database_dsn_env: Option<String>,
|
||||
database_dsn: String,
|
||||
qdrant_url: String,
|
||||
qdrant_api_key_env: Option<String>,
|
||||
qdrant_history_collection: String,
|
||||
qdrant_source_collection: String,
|
||||
qdrant_timeout_ms: u64,
|
||||
ollama_base_url: String,
|
||||
ollama_model: String,
|
||||
ollama_timeout_ms: u64,
|
||||
history_query_instruction: String,
|
||||
source_query_instruction: String,
|
||||
archive_path: PathBuf,
|
||||
}
|
||||
|
||||
impl BridgeConfig {
|
||||
fn load(path: &Path) -> Result<Self> {
|
||||
let raw = fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
Ok(Self {
|
||||
database_dsn_env: toml_string(&raw, "database_dsn_env"),
|
||||
database_dsn: toml_string(&raw, "database_dsn")
|
||||
.unwrap_or_else(|| "postgres://voidbot:voidbot@localhost:5432/voidbot".to_string()),
|
||||
qdrant_url: normalize_base_url(
|
||||
&toml_string(&raw, "qdrant_url")
|
||||
.unwrap_or_else(|| "http://127.0.0.1:6333".to_string()),
|
||||
),
|
||||
qdrant_api_key_env: toml_string(&raw, "qdrant_api_key_env"),
|
||||
qdrant_history_collection: toml_string(&raw, "qdrant_history_collection")
|
||||
.unwrap_or_else(|| "voidbot_discord_history_chunks".to_string()),
|
||||
qdrant_source_collection: toml_string(&raw, "qdrant_source_collection")
|
||||
.unwrap_or_else(|| "voidbot_repository_source_chunks".to_string()),
|
||||
qdrant_timeout_ms: toml_number(&raw, "qdrant_timeout_ms").unwrap_or(30000),
|
||||
ollama_base_url: normalize_base_url(
|
||||
&toml_string(&raw, "ollama_base_url")
|
||||
.unwrap_or_else(|| "http://127.0.0.1:11434".to_string()),
|
||||
),
|
||||
ollama_model: toml_string(&raw, "ollama_model")
|
||||
.unwrap_or_else(|| "qwen3-embedding:0.6b".to_string()),
|
||||
ollama_timeout_ms: toml_number(&raw, "ollama_timeout_ms").unwrap_or(30000),
|
||||
history_query_instruction: toml_string(&raw, "history_query_instruction")
|
||||
.unwrap_or_else(|| "Given a Discord history question, retrieve relevant messages and discussion snippets that answer it.".to_string()),
|
||||
source_query_instruction: toml_string(&raw, "source_query_instruction")
|
||||
.unwrap_or_else(|| "Given a source-tree, codebase, or lore question, retrieve relevant files, code snippets, and lore passages that answer it.".to_string()),
|
||||
archive_path: PathBuf::from(
|
||||
toml_string(&raw, "archive_path")
|
||||
.unwrap_or_else(|| r"E:\Projects\VoidBot\.voidbot\rag\messages.json".to_string()),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn database_dsn(&self) -> String {
|
||||
self.database_dsn_env
|
||||
.as_ref()
|
||||
.and_then(|name| env::var(name).ok())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| self.database_dsn.clone())
|
||||
}
|
||||
|
||||
fn qdrant_api_key(&self) -> Option<String> {
|
||||
self.qdrant_api_key_env
|
||||
.as_ref()
|
||||
.and_then(|name| env::var(name).ok())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
enum SearchCorpus {
|
||||
History {
|
||||
guild_id: Option<String>,
|
||||
channel_id: Option<String>,
|
||||
author_id: Option<String>,
|
||||
},
|
||||
Source {
|
||||
repo_name: Option<String>,
|
||||
path_prefix: Option<String>,
|
||||
language: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn run_status(config: &BridgeConfig) -> Result<Value> {
|
||||
let postgres = postgres_status(config);
|
||||
let qdrant = qdrant_status(config);
|
||||
let archive = archive_status(&config.archive_path);
|
||||
Ok(json!({
|
||||
"schemaVersion": STATUS_SCHEMA_VERSION,
|
||||
"ok": postgres["ok"].as_bool().unwrap_or(false)
|
||||
|| qdrant["history"]["ok"].as_bool().unwrap_or(false)
|
||||
|| archive["ok"].as_bool().unwrap_or(false),
|
||||
"postgres": postgres,
|
||||
"qdrant": qdrant,
|
||||
"archive": archive,
|
||||
"notes": [
|
||||
"Void currently keeps raw Discord archives file-backed under .voidbot while Qdrant owns semantic history/source vectors.",
|
||||
"Postgres is checked for live Void state organs: jobs, audit events, interaction memory, identity profiles, and rate limits."
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
fn run_search(
|
||||
config: &BridgeConfig,
|
||||
query: String,
|
||||
limit: usize,
|
||||
corpus: SearchCorpus,
|
||||
) -> Result<Value> {
|
||||
let (collection, instruction, filter, corpus_name) = match corpus {
|
||||
SearchCorpus::History {
|
||||
guild_id,
|
||||
channel_id,
|
||||
author_id,
|
||||
} => (
|
||||
config.qdrant_history_collection.as_str(),
|
||||
config.history_query_instruction.as_str(),
|
||||
qdrant_filter([
|
||||
("corpusKind", Some("discord_history".to_string())),
|
||||
("guildId", guild_id),
|
||||
("channelId", channel_id),
|
||||
("authorId", author_id),
|
||||
]),
|
||||
"discord_history",
|
||||
),
|
||||
SearchCorpus::Source {
|
||||
repo_name,
|
||||
path_prefix,
|
||||
language,
|
||||
} => (
|
||||
config.qdrant_source_collection.as_str(),
|
||||
config.source_query_instruction.as_str(),
|
||||
qdrant_filter([
|
||||
("corpusKind", Some("repository_source".to_string())),
|
||||
("repoName", repo_name),
|
||||
("pathPrefixes", path_prefix.map(normalize_path_prefix)),
|
||||
("language", language),
|
||||
]),
|
||||
"repository_source",
|
||||
),
|
||||
};
|
||||
let vector = embed_query(config, instruction, &query)?;
|
||||
let hits = query_qdrant(config, collection, &vector, limit, filter)?;
|
||||
Ok(json!({
|
||||
"schemaVersion": SEARCH_SCHEMA_VERSION,
|
||||
"ok": true,
|
||||
"corpus": corpus_name,
|
||||
"query": query,
|
||||
"collection": collection,
|
||||
"results": hits,
|
||||
}))
|
||||
}
|
||||
|
||||
fn run_message_context(
|
||||
config: &BridgeConfig,
|
||||
message_id: String,
|
||||
before: usize,
|
||||
after: usize,
|
||||
) -> Result<Value> {
|
||||
let archive = load_archive(&config.archive_path)?;
|
||||
let Some(anchor) = archive
|
||||
.messages
|
||||
.iter()
|
||||
.find(|message| message.id == message_id)
|
||||
else {
|
||||
return Ok(json!({
|
||||
"schemaVersion": CONTEXT_SCHEMA_VERSION,
|
||||
"ok": false,
|
||||
"reason": "message-not-found",
|
||||
"messageId": message_id,
|
||||
}));
|
||||
};
|
||||
let mut messages = archive
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|message| !message.deleted())
|
||||
.filter(|message| belongs_to_same_conversation(message, anchor))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
messages.sort_by(|left, right| left.timestamp.cmp(&right.timestamp));
|
||||
let anchor_index = messages
|
||||
.iter()
|
||||
.position(|message| message.id == message_id)
|
||||
.context("anchor disappeared from context set")?;
|
||||
let start = anchor_index.saturating_sub(before);
|
||||
let end = (anchor_index + after + 1).min(messages.len());
|
||||
Ok(json!({
|
||||
"schemaVersion": CONTEXT_SCHEMA_VERSION,
|
||||
"ok": true,
|
||||
"messageId": message_id,
|
||||
"archivePath": config.archive_path,
|
||||
"messages": messages[start..end].iter().map(format_message).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn postgres_status(config: &BridgeConfig) -> Value {
|
||||
match postgres::Client::connect(&config.database_dsn(), NoTls) {
|
||||
Ok(mut client) => {
|
||||
let tables = [
|
||||
"jobs",
|
||||
"audit_events",
|
||||
"interaction_memory_events",
|
||||
"interaction_identity_profiles",
|
||||
"void_usage_rate_limit_state",
|
||||
];
|
||||
let counts = tables
|
||||
.into_iter()
|
||||
.map(|table| {
|
||||
let count = client
|
||||
.query_one(&format!("select count(*)::bigint from {table}"), &[])
|
||||
.map(|row| row.get::<_, i64>(0));
|
||||
(table, count)
|
||||
})
|
||||
.map(|(table, count)| match count {
|
||||
Ok(count) => json!({"table": table, "ok": true, "count": count}),
|
||||
Err(error) => json!({"table": table, "ok": false, "error": error.to_string()}),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
json!({"ok": true, "dsn": redact_dsn(&config.database_dsn()), "tables": counts})
|
||||
}
|
||||
Err(error) => json!({
|
||||
"ok": false,
|
||||
"dsn": redact_dsn(&config.database_dsn()),
|
||||
"error": error.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn qdrant_status(config: &BridgeConfig) -> Value {
|
||||
json!({
|
||||
"url": config.qdrant_url,
|
||||
"history": qdrant_collection_status(config, &config.qdrant_history_collection),
|
||||
"source": qdrant_collection_status(config, &config.qdrant_source_collection),
|
||||
})
|
||||
}
|
||||
|
||||
fn qdrant_collection_status(config: &BridgeConfig, collection: &str) -> Value {
|
||||
let client = qdrant_client(config);
|
||||
match client
|
||||
.get(format!("{}/collections/{collection}", config.qdrant_url))
|
||||
.send()
|
||||
{
|
||||
Ok(response) => match decode_json(response) {
|
||||
Ok(payload) => json!({
|
||||
"ok": true,
|
||||
"collection": collection,
|
||||
"pointsCount": payload.pointer("/result/points_count"),
|
||||
"vectorsCount": payload.pointer("/result/vectors_count"),
|
||||
"status": payload.pointer("/result/status"),
|
||||
}),
|
||||
Err(error) => {
|
||||
json!({"ok": false, "collection": collection, "error": error.to_string()})
|
||||
}
|
||||
},
|
||||
Err(error) => json!({"ok": false, "collection": collection, "error": error.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
fn archive_status(path: &Path) -> Value {
|
||||
match load_archive(path) {
|
||||
Ok(archive) => json!({
|
||||
"ok": true,
|
||||
"path": path,
|
||||
"messageCount": archive.messages.len(),
|
||||
"activeMessageCount": archive.messages.iter().filter(|message| !message.deleted()).count(),
|
||||
}),
|
||||
Err(error) => json!({"ok": false, "path": path, "error": error.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
fn embed_query(config: &BridgeConfig, instruction: &str, query: &str) -> Result<Vec<f32>> {
|
||||
let client = ClientBuilder::new()
|
||||
.timeout(Duration::from_millis(config.ollama_timeout_ms))
|
||||
.build()
|
||||
.context("failed to build Ollama client")?;
|
||||
let formatted = format!("Instruct: {instruction}\nQuery: {query}");
|
||||
let response = client
|
||||
.post(format!("{}/api/embed", config.ollama_base_url))
|
||||
.json(&json!({"model": config.ollama_model, "input": [formatted]}))
|
||||
.send()
|
||||
.context("failed to contact Ollama embedding backend")?;
|
||||
let payload = decode_json(response).context("failed to decode Ollama embedding response")?;
|
||||
let embedding = payload["embeddings"]
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| anyhow!("Ollama embedding response did not include embeddings[0]"))?;
|
||||
embedding
|
||||
.iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.as_f64()
|
||||
.map(|value| value as f32)
|
||||
.ok_or_else(|| anyhow!("Ollama embedding included a non-number"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn query_qdrant(
|
||||
config: &BridgeConfig,
|
||||
collection: &str,
|
||||
vector: &[f32],
|
||||
limit: usize,
|
||||
filter: Option<Value>,
|
||||
) -> Result<Vec<Value>> {
|
||||
let response = qdrant_client(config)
|
||||
.post(format!(
|
||||
"{}/collections/{collection}/points/query",
|
||||
config.qdrant_url
|
||||
))
|
||||
.json(&json!({
|
||||
"query": vector,
|
||||
"limit": limit,
|
||||
"with_payload": true,
|
||||
"with_vector": false,
|
||||
"filter": filter,
|
||||
}))
|
||||
.send()
|
||||
.with_context(|| format!("failed to query Qdrant collection {collection}"))?;
|
||||
let payload = decode_json(response)?;
|
||||
let points = payload
|
||||
.pointer("/result/points")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(points.into_iter().filter_map(format_qdrant_point).collect())
|
||||
}
|
||||
|
||||
fn format_qdrant_point(point: Value) -> Option<Value> {
|
||||
let payload = point.get("payload")?;
|
||||
Some(json!({
|
||||
"score": point.get("score"),
|
||||
"chunkId": payload.get("chunkId"),
|
||||
"sourceId": payload.get("sourceId"),
|
||||
"sourceKind": payload.get("sourceKind"),
|
||||
"text": payload.get("text"),
|
||||
"metadata": payload.get("metadata"),
|
||||
}))
|
||||
}
|
||||
|
||||
fn qdrant_client(config: &BridgeConfig) -> Client {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(api_key) = config.qdrant_api_key() {
|
||||
if let Ok(value) = HeaderValue::from_str(&api_key) {
|
||||
headers.insert("api-key", value);
|
||||
}
|
||||
}
|
||||
ClientBuilder::new()
|
||||
.timeout(Duration::from_millis(config.qdrant_timeout_ms))
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.expect("Qdrant client should build")
|
||||
}
|
||||
|
||||
fn qdrant_filter<const N: usize>(fields: [(&str, Option<String>); N]) -> Option<Value> {
|
||||
let must = fields
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| {
|
||||
let value = value?.trim().to_string();
|
||||
(!value.is_empty()).then(|| json!({"key": key, "match": {"value": value}}))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(!must.is_empty()).then(|| json!({"must": must}))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct ArchiveStore {
|
||||
messages: Vec<ArchivedMessage>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ArchivedMessage {
|
||||
id: String,
|
||||
guild_id: Option<String>,
|
||||
channel_id: String,
|
||||
author_id: String,
|
||||
author_name: String,
|
||||
content: String,
|
||||
timestamp: String,
|
||||
deleted_at: Option<String>,
|
||||
thread_id: Option<String>,
|
||||
metadata: Option<Value>,
|
||||
}
|
||||
|
||||
impl ArchivedMessage {
|
||||
fn deleted(&self) -> bool {
|
||||
self.deleted_at
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn load_archive(path: &Path) -> Result<ArchiveStore> {
|
||||
let raw =
|
||||
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
|
||||
serde_json::from_str(&raw).with_context(|| format!("failed to decode {}", path.display()))
|
||||
}
|
||||
|
||||
fn belongs_to_same_conversation(message: &ArchivedMessage, anchor: &ArchivedMessage) -> bool {
|
||||
message.channel_id == anchor.channel_id && message.thread_id == anchor.thread_id
|
||||
}
|
||||
|
||||
fn format_message(message: &ArchivedMessage) -> Value {
|
||||
json!({
|
||||
"id": message.id,
|
||||
"timestamp": message.timestamp,
|
||||
"authorId": message.author_id,
|
||||
"authorName": message.author_name,
|
||||
"guildId": message.guild_id,
|
||||
"channelId": message.channel_id,
|
||||
"threadId": message.thread_id,
|
||||
"content": message.content,
|
||||
"jumpUrl": message.metadata.as_ref().and_then(|metadata| metadata.get("jumpUrl")),
|
||||
})
|
||||
}
|
||||
|
||||
fn run_smoke() -> Result<Value> {
|
||||
let dir = env::temp_dir().join(format!(
|
||||
"epiphany-void-memory-smoke-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(&dir)?;
|
||||
let archive_path = dir.join("messages.json");
|
||||
fs::write(
|
||||
&archive_path,
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"version": 1,
|
||||
"messages": [
|
||||
{"id":"1","guildId":"g","channelId":"c","authorId":"a","authorName":"A","content":"before","timestamp":"2026-05-06T00:00:00Z","threadId":"t","metadata":{"jumpUrl":"https://discord.invalid/1"}},
|
||||
{"id":"2","guildId":"g","channelId":"c","authorId":"b","authorName":"B","content":"anchor","timestamp":"2026-05-06T00:01:00Z","threadId":"t","metadata":{"jumpUrl":"https://discord.invalid/2"}},
|
||||
{"id":"3","guildId":"g","channelId":"c","authorId":"a","authorName":"A","content":"after","timestamp":"2026-05-06T00:02:00Z","threadId":"t","metadata":{"jumpUrl":"https://discord.invalid/3"}}
|
||||
]
|
||||
}))?,
|
||||
)?;
|
||||
let config = BridgeConfig {
|
||||
database_dsn_env: None,
|
||||
database_dsn: "postgres://voidbot:voidbot@localhost:5432/voidbot".to_string(),
|
||||
qdrant_url: "http://127.0.0.1:1".to_string(),
|
||||
qdrant_api_key_env: None,
|
||||
qdrant_history_collection: "history".to_string(),
|
||||
qdrant_source_collection: "source".to_string(),
|
||||
qdrant_timeout_ms: 50,
|
||||
ollama_base_url: "http://127.0.0.1:1".to_string(),
|
||||
ollama_model: "qwen3-embedding:0.6b".to_string(),
|
||||
ollama_timeout_ms: 50,
|
||||
history_query_instruction: "history".to_string(),
|
||||
source_query_instruction: "source".to_string(),
|
||||
archive_path,
|
||||
};
|
||||
let context = run_message_context(&config, "2".to_string(), 1, 1)?;
|
||||
let status = run_status(&config)?;
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
Ok(json!({
|
||||
"ok": context["ok"] == true
|
||||
&& context["messages"].as_array().is_some_and(|messages| messages.len() == 3),
|
||||
"context": context,
|
||||
"status": status,
|
||||
}))
|
||||
}
|
||||
|
||||
fn decode_json(response: reqwest::blocking::Response) -> Result<Value> {
|
||||
let status = response.status();
|
||||
let body = response.text().context("failed to read response body")?;
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("HTTP {status}: {body}"));
|
||||
}
|
||||
serde_json::from_str(&body).context("response was not JSON")
|
||||
}
|
||||
|
||||
fn toml_string(raw: &str, key: &str) -> Option<String> {
|
||||
raw.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.starts_with('#'))
|
||||
.find_map(|line| {
|
||||
let (left, right) = line.split_once('=')?;
|
||||
(left.trim() == key).then(|| parse_toml_string(right.trim()))?
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_toml_string(value: &str) -> Option<String> {
|
||||
value
|
||||
.strip_prefix('"')
|
||||
.and_then(|value| value.strip_suffix('"'))
|
||||
.map(|value| value.replace("\\\\", "\\"))
|
||||
}
|
||||
|
||||
fn toml_number(raw: &str, key: &str) -> Option<u64> {
|
||||
raw.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.starts_with('#'))
|
||||
.find_map(|line| {
|
||||
let (left, right) = line.split_once('=')?;
|
||||
(left.trim() == key).then(|| right.trim().parse().ok())?
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_base_url(value: &str) -> String {
|
||||
value.trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
fn normalize_path_prefix(value: String) -> String {
|
||||
value.replace('\\', "/").trim_start_matches('/').to_string()
|
||||
}
|
||||
|
||||
fn redact_dsn(dsn: &str) -> String {
|
||||
if let Some((prefix, rest)) = dsn.split_once("://") {
|
||||
if let Some(at) = rest.find('@') {
|
||||
return format!("{prefix}://***@{}", &rest[at + 1..]);
|
||||
}
|
||||
}
|
||||
dsn.to_string()
|
||||
}
|
||||
|
||||
fn take(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
args.next()
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
|
||||
fn usage() -> Result<()> {
|
||||
Err(anyhow!(
|
||||
"usage: epiphany-void-memory <status|search-history|search-sources|message-context|smoke> [--config <path>] [--query <text>] [--message-id <id>] [--limit <n>]"
|
||||
))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Reference in a new issue