Move public crossings onto typed Bifrost transport
This commit is contained in:
parent
bd3a5a974b
commit
cde3a78874
18 changed files with 1686 additions and 2073 deletions
|
|
@ -11,10 +11,6 @@ RUN dotnet publish src/Bifrost.Web/Bifrost.Web.csproj \
|
||||||
/p:UseAppHost=false
|
/p:UseAppHost=false
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS runtime
|
FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS runtime
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV ASPNETCORE_URLS=http://0.0.0.0:5080 \
|
ENV ASPNETCORE_URLS=http://0.0.0.0:5080 \
|
||||||
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true \
|
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true \
|
||||||
|
|
@ -23,7 +19,4 @@ ENV ASPNETCORE_URLS=http://0.0.0.0:5080 \
|
||||||
EXPOSE 5080
|
EXPOSE 5080
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
|
||||||
CMD curl -fsS http://127.0.0.1:5080/healthz || exit 1
|
|
||||||
|
|
||||||
ENTRYPOINT ["dotnet", "Bifrost.Web.dll"]
|
ENTRYPOINT ["dotnet", "Bifrost.Web.dll"]
|
||||||
|
|
|
||||||
32
README.md
32
README.md
|
|
@ -20,7 +20,8 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core
|
||||||
- GitHub App webhook ingestion for issue, pull request, and review sync
|
- GitHub App webhook ingestion for issue, pull request, and review sync
|
||||||
- Heimdall-signed external patron support intake for Patreon and PayPal support facts
|
- Heimdall-signed external patron support intake for Patreon and PayPal support facts
|
||||||
- Eve Motion Verse surface and command endpoint for governance participation, with Razor motion forms demoted to a transitional browser lowering
|
- Eve Motion Verse surface and command endpoint for governance participation, with Razor motion forms demoted to a transitional browser lowering
|
||||||
- local bridge tooling for agent-owned GitHub draft PRs and PR comments, Discord posts and DMs, Reddit self-posts, and receipt-only future-surface requests
|
- local bridge tooling for agent-owned GitHub draft PRs and PR comments, Discord posts and DMs, Reddit self-posts, and fail-closed future-surface request probes
|
||||||
|
- canonical `bifrost.crossing_receipt.v1` history in `.bifrost/bridge-receipts.cc` for GitHub, Discord, Reddit, governance, and agent-transport crossings
|
||||||
- local bridge tooling for Reddit self-posts in `r/GameCultOrg`, including Persona flair labels through the Bifrost Reddit app
|
- local bridge tooling for Reddit self-posts in `r/GameCultOrg`, including Persona flair labels through the Bifrost Reddit app
|
||||||
- CultCache-backed governance topic threads for feature requests, discussion comments, Persona approvals, and dispatch promotion
|
- CultCache-backed governance topic threads for feature requests, discussion comments, Persona approvals, and dispatch promotion
|
||||||
- CultCache/CultNet-backed agent intake tooling for repo Persona update requests
|
- CultCache/CultNet-backed agent intake tooling for repo Persona update requests
|
||||||
|
|
@ -74,23 +75,15 @@ For the full staged roadmap, including contribution points, revenue share, patro
|
||||||
- set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in
|
- set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in
|
||||||
- POST `/auth/bifrost/register` with `identity`, optional `displayName`, and optional `returnUrl` to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access
|
- POST `/auth/bifrost/register` with `identity`, optional `displayName`, and optional `returnUrl` to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access
|
||||||
- set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path
|
- set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path
|
||||||
- set `Bridge:LocalBridgeToken` to allow the transitional local bridge actuator to register and receipt governed actions through `/bridge/actions/*`
|
- HTTP bridge ledger configuration (`BIFROST_BRIDGE_BASE_URL` / `BIFROST_BRIDGE_TOKEN`) is removed from live CLI transport. Request-lane, governance, dispatch, and bridge receipts converge on Bifrost-owned `bifrost.crossing_receipt.v1` documents in `.bifrost/bridge-receipts.cc`.
|
||||||
- set `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` when `tools/bifrost-bridge.mjs` should round-trip every action through the app ledger before mutating GitHub, Discord, or Reddit, or before recording a receipt-only future-surface request
|
- external bridge actions must carry typed CultMesh command provenance such as `BIFROST_CULTMESH_COMMAND_ID`; unreceipted and HTTP-ledger hatches fail closed.
|
||||||
- the same bridge URL/token pair lets `tools/dispatch-agent-requests.mjs` record `/dispatch/runs/*` receipts for launched Codex work, so Bifrost keeps a live run trail even when a turn fails
|
- Persona and agent public crossings still require Bifrost identity, Heimdall capability/account reference, and Epiphany provenance, but those facts travel with typed command/receipt documents rather than a local HTTP bridge token.
|
||||||
- those hosted runtime receipt lanes are local-bridge-owned writes, not ordinary member-session writes: `/dispatch/runs/*`, `/transport/receipts`, and `/governance/receipts` now require the configured bridge token and do not inherit browser-user identity into agent activity rows
|
|
||||||
- those hosted runtime receipts also validate linkage instead of accepting free-floating text: dispatch runs require an existing request-lane receipt for the same request id, governance promotion receipts require a known promoted dispatch request, and request-lane rows cannot silently drift to a different repo or agent identity midstream
|
|
||||||
- GitHub bridge actions that claim Bifrost-owned provenance now validate that provenance too: a `bifrost_agent_transport_request` source must point at a known request-lane request for the same repo, and governance-backed bridge actions must point at a known Bifrost topic instead of a decorative source string
|
|
||||||
- governance-backed GitHub publication is also no longer allowed to jump the queue: a Bifrost governance topic must have been approved or promoted before it can justify a GitHub bridge action
|
|
||||||
- non-GitHub bridge activity is no longer allowed to drift off-book either: `discord-post`, `discord-dm`, `reddit-post`, and `other-request` now require `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` by default, with `--allow-unreceipted-activity true` reserved for explicit local recovery and refused inside dispatched work
|
|
||||||
- Persona and agent `discord-post`, `reddit-post`, and `other-request` bridge actions also require a Bifrost identity plus Heimdall-backed capability/account reference in the hosted ledger; pass `--identity`, `--heimdall-capability-ref`, and the Epiphany provenance flags or set the matching environment variables. Future-surface `other-request` actions must also carry `--surface-name`, and Bifrost rejects Heimdall references that do not match that named surface. The bridge token is an actuator credential, not identity or provider authority.
|
|
||||||
- Bifrost-dispatched Codex turns also inject Bifrost-owned GitHub gates through environment config: `git` and `gh` wrappers are prepended in `PATH`, a `pre-push` hook backs up raw Git pushes, raw `git push` stays blocked, and direct `gh` usage is limited to explicit read-only commands unless `tools/bifrost-bridge.mjs` authorizes the bridge-owned GitHub write path
|
- Bifrost-dispatched Codex turns also inject Bifrost-owned GitHub gates through environment config: `git` and `gh` wrappers are prepended in `PATH`, a `pre-push` hook backs up raw Git pushes, raw `git push` stays blocked, and direct `gh` usage is limited to explicit read-only commands unless `tools/bifrost-bridge.mjs` authorizes the bridge-owned GitHub write path
|
||||||
- dispatched Codex turns now default to `workspace-write` instead of full machine access, and the app-server sandbox policy disables network access unless the operator explicitly chooses a broader sandbox
|
- dispatched Codex turns now default to `workspace-write` instead of full machine access, and the app-server sandbox policy disables network access unless the operator explicitly chooses a broader sandbox
|
||||||
- `tools/agent-transport.mjs` also uses that bridge configuration to record `/transport/receipts` entries for queue, claim, release, and close events on the request lane
|
- `tools/agent-transport.mjs` records queue, claim, release, close, and snapshot activity in the typed request-lane store.
|
||||||
- `tools/agent-transport.mjs apply-snapshot` is also treated as a mutating import path now: it fails closed without the bridge receipt config and derives request-lane receipts from the imported state delta
|
- `tools/governance-threads.mjs` records topic opens, comments, approvals, and promotions in the typed governance store.
|
||||||
- `tools/governance-threads.mjs` uses that same bridge configuration to record `/governance/receipts` entries for topic opens, comments, approvals, and promotions
|
- GitHub bridge mutations now fail closed without typed CultMesh command provenance; ungated and HTTP-ledger hatches are removed.
|
||||||
- those agent activity scripts now fail closed without the bridge URL/token pair; `--allow-unreceipted-activity true` or `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY=true` is the explicit operator-recovery hatch
|
- dispatched Codex turns inherit the same invariant: a worker cannot downgrade itself out of Bifrost gate or receipt policy
|
||||||
- GitHub bridge mutations now fail closed without those values; `--allow-ungated-github true` is reserved for explicit operator recovery
|
|
||||||
- dispatched Codex turns now lock those recovery hatches too: local-only `--allow-ungated-github` and `--allow-unreceipted-activity` escapes are refused inside dispatched work so a worker cannot silently downgrade itself out of Bifrost receipts
|
|
||||||
- dispatched Codex turns also now scrub ambient GitHub auth state from the child environment: `GH_TOKEN` / `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompting is disabled before the worker starts
|
- dispatched Codex turns also now scrub ambient GitHub auth state from the child environment: `GH_TOKEN` / `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompting is disabled before the worker starts
|
||||||
- set `BIFROST_REDDIT_CLIENT_ID`, `BIFROST_REDDIT_REFRESH_TOKEN`, and optionally `BIFROST_REDDIT_CLIENT_SECRET` before posting Reddit organizing threads
|
- set `BIFROST_REDDIT_CLIENT_ID`, `BIFROST_REDDIT_REFRESH_TOKEN`, and optionally `BIFROST_REDDIT_CLIENT_SECRET` before posting Reddit organizing threads
|
||||||
- set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production
|
- set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production
|
||||||
|
|
@ -99,10 +92,13 @@ For the full staged roadmap, including contribution points, revenue share, patro
|
||||||
- test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed
|
- test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed
|
||||||
- dry-run a Reddit organizing thread with `node tools/bifrost-bridge.mjs reddit-post --title "Thread title" --persona-name Bifrost --content "Thread body" --dry-run true`
|
- dry-run a Reddit organizing thread with `node tools/bifrost-bridge.mjs reddit-post --title "Thread title" --persona-name Bifrost --content "Thread body" --dry-run true`
|
||||||
- print the Eve provider advertisement with `node tools/provider-advertisement.mjs print`
|
- print the Eve provider advertisement with `node tools/provider-advertisement.mjs print`
|
||||||
- export the Eve provider advertisement witness with `node tools/provider-advertisement.mjs export --out .bifrost/provider-advertisement.cc`
|
- export the Eve provider store with `node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc`
|
||||||
- inspect the interactive Motion Verse surface at `/eve/governance/surface` while signed in as an active member
|
- inspect the interactive Motion Verse surface at `/eve/governance/surface` while signed in as an active member
|
||||||
- container smoke test with `docker compose -f compose.local.yaml up --build`
|
- container smoke test with `docker compose -f compose.local.yaml up --build`
|
||||||
- container health checks live at `http://127.0.0.1:5080/healthz` and `http://127.0.0.1:5080/readyz`
|
- container smoke tests may hit `/healthz` and `/readyz` as product probes.
|
||||||
|
Provider advertisements, Docker health, and deploy gates must come from
|
||||||
|
Bifrost's typed CultMesh/Idunn operator state; `BIFROST_HEALTH_URL` and
|
||||||
|
`BIFROST_READY_URL` are not accepted by the provider exporter.
|
||||||
|
|
||||||
## Read First
|
## Read First
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,19 +125,15 @@ node .\tools\agent-transport.mjs apply-snapshot --in E:\tmp\bifrost-agent-transp
|
||||||
`enqueue` also mirrors the queued request into `#bifrost` by default. Use `--mirror-content-file` when a Persona has a better human-facing line than the fallback receipt; use `--mirror-dry-run true` only for smoke tests.
|
`enqueue` also mirrors the queued request into `#bifrost` by default. Use `--mirror-content-file` when a Persona has a better human-facing line than the fallback receipt; use `--mirror-dry-run true` only for smoke tests.
|
||||||
|
|
||||||
Dispatch receipts should use Bifrost's own public persona in the Bifrost governance channel. `tools/dispatch-agent-requests.mjs` reads `BIFROST_DISCORD_CHANNEL_ID` or `DISCORD_BIFROST_CHANNEL_ID` for the receipt target, uses persona name `Bifrost`, and defaults the persona avatar to the public `src/Bifrost.Web/wwwroot/img/bifrost-profile.png` raw GitHub URL unless `BIFROST_DISCORD_PERSONA_AVATAR_URL` or `DISCORD_PERSONA_AVATAR_URL_BIFROST` overrides it. Receipt text must lead with the concrete repo and work title, not a generic "recent consensus" summary, and must not expose request ids, workspace paths, log paths, or other debugging debris in Discord.
|
Dispatch receipts should use Bifrost's own public persona in the Bifrost governance channel. `tools/dispatch-agent-requests.mjs` reads `BIFROST_DISCORD_CHANNEL_ID` or `DISCORD_BIFROST_CHANNEL_ID` for the receipt target, uses persona name `Bifrost`, and defaults the persona avatar to the public `src/Bifrost.Web/wwwroot/img/bifrost-profile.png` raw GitHub URL unless `BIFROST_DISCORD_PERSONA_AVATAR_URL` or `DISCORD_PERSONA_AVATAR_URL_BIFROST` overrides it. Receipt text must lead with the concrete repo and work title, not a generic "recent consensus" summary, and must not expose request ids, workspace paths, log paths, or other debugging debris in Discord.
|
||||||
The dispatcher now tags those Discord receipts with the Bifrost request id as bridge provenance, and dispatched Codex turns inherit default bridge provenance environment variables so any explicit `tools/bifrost-bridge.mjs` crossing inside the turn can land on the same request trail without hand-carried flags. When `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` are configured, the dispatcher also records `/dispatch/runs/*` receipts in the hosted app so Bifrost keeps a first-class run ledger for launch, completion, cancellation, and failure.
|
The dispatcher now tags those Discord receipts with the Bifrost request id as bridge provenance, and dispatched Codex turns inherit default bridge provenance environment variables so any explicit `tools/bifrost-bridge.mjs` crossing inside the turn can land on the same request trail without hand-carried flags. Runtime receipt truth lives in Bifrost's typed request, governance, dispatch, and bridge command stores; the old hosted bridge base URL/token path is no longer a live transport.
|
||||||
Those hosted runtime receipt endpoints are owned by the local bridge token, not by ordinary member sessions. A signed-in member can govern work and request crossings, but they cannot POST `/dispatch/runs/*`, `/transport/receipts`, or `/governance/receipts` without the configured bridge token and pretend to be the worker runtime.
|
That same Bifrost request provenance now matters at the GitHub crossing too. Dispatched work must carry typed Bifrost CultMesh command provenance before a bridge actuator may cross into GitHub, Discord, Reddit, or another public surface.
|
||||||
Those hosted receipt endpoints now also cross-check each other. Dispatch starts require an existing request-lane receipt for the same request id, governance promotions that cite `dispatchRequestId` must point at a known request-lane request, and additional request-lane receipts for one request id cannot drift onto a different repo or agent identity. The ledger is still transitional, but it has stopped politely accepting shape-shifting runtime history.
|
|
||||||
That same Bifrost request provenance now matters at the GitHub crossing too. When a dispatched worker uses `tools/bifrost-bridge.mjs` with the inherited `bifrost_agent_transport_request` source, the hosted bridge checks that the cited request exists in the request-lane ledger and still belongs to the same repo before it authorizes the GitHub mutation.
|
|
||||||
Governance-backed GitHub publication is stricter now too. If a bridge action cites a Bifrost governance topic as its source, the hosted bridge requires that the topic has actually been approved or promoted before it will authorize a GitHub mutation. An opened topic or comment thread by itself is no longer enough to claim GitHub authority.
|
|
||||||
The rest of the bridge-owned external activity now follows the same receipt discipline. If dispatched work posts a Discord receipt, sends a Discord DM, or opens a Reddit organizing thread through `tools/bifrost-bridge.mjs`, the normal path requires the hosted bridge base URL/token so Bifrost records the crossing. `--allow-unreceipted-activity true` is now an explicit local recovery hatch and is refused inside dispatched work.
|
|
||||||
Those dispatched turns also inherit Bifrost-owned GitHub gates through environment config. `git` and `gh` wrappers are prepended in `PATH`, a `pre-push` hook backs up raw Git pushes, raw `git push` stays blocked inside dispatched work even with `--no-verify`, and direct `gh` usage is limited to explicit read-only commands unless the bridge explicitly authorizes the GitHub mutation it owns.
|
Those dispatched turns also inherit Bifrost-owned GitHub gates through environment config. `git` and `gh` wrappers are prepended in `PATH`, a `pre-push` hook backs up raw Git pushes, raw `git push` stays blocked inside dispatched work even with `--no-verify`, and direct `gh` usage is limited to explicit read-only commands unless the bridge explicitly authorizes the GitHub mutation it owns.
|
||||||
Those dispatched turns also lock the local-only recovery hatches. `--allow-ungated-github`, `BIFROST_ALLOW_UNGATED_GITHUB`, `--allow-unreceipted-activity`, and `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY` are refused inside dispatched work so an agent cannot quietly downgrade itself out of Bifrost's gate or receipt trail.
|
Those dispatched turns inherit the same gate and receipt invariant, so an agent cannot quietly downgrade itself out of Bifrost's gate or receipt trail.
|
||||||
Those dispatched turns also scrub ambient GitHub auth state from the child environment before the worker starts. `GH_TOKEN` and `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompts are disabled. That does not prove every imaginable direct-HTTP escape is impossible, but it removes the normal ambient credential path from dispatched work.
|
Those dispatched turns also scrub ambient GitHub auth state from the child environment before the worker starts. `GH_TOKEN` and `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompts are disabled. That does not prove every imaginable direct-HTTP escape is impossible, but it removes the normal ambient credential path from dispatched work.
|
||||||
Those dispatched turns now also default to the `workspace-write` sandbox instead of full machine access, and the app-server launch path now tells Codex to keep `networkAccess: false` unless the operator explicitly chooses a broader sandbox. The worker can still read the repo and do its job, but GitHub authority no longer leaks in through the default launch posture.
|
Those dispatched turns now also default to the `workspace-write` sandbox instead of full machine access, and the app-server launch path now tells Codex to keep `networkAccess: false` unless the operator explicitly chooses a broader sandbox. The worker can still read the repo and do its job, but GitHub authority no longer leaks in through the default launch posture.
|
||||||
When that same bridge configuration is present, `tools/agent-transport.mjs` records `/transport/receipts` entries for queue, claim, release, and close operations on the request lane. The packet still lives in CultCache. The hosted receipt row is the Bifrost-side witness that an agent or dispatcher touched it.
|
`tools/agent-transport.mjs` records queue, claim, release, and close operations directly in the typed request-lane store, then writes canonical `bifrost.crossing_receipt.v1` entries to `.bifrost/bridge-receipts.cc`. The packet lives in CultCache/CultMesh; hosted HTTP receipt rows are not a live authority.
|
||||||
`apply-snapshot` is treated as a mutating import path too. It now fails closed without that same bridge configuration and derives request-lane receipts from the imported state delta, so a snapshot can no longer rewrite queue or claim state behind Bifrost's back.
|
`apply-snapshot` is treated as a mutating import path too. It derives canonical crossing receipts from the imported state delta, so a snapshot can no longer rewrite queue or claim state behind Bifrost's back.
|
||||||
Those mutating scripts now fail closed without that bridge configuration. Use `--allow-unreceipted-activity true` or `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY=true` only for explicit local recovery work where you are intentionally accepting a blind spot.
|
Those mutating scripts fail closed if the removed HTTP bridge env is present. Repair the typed CultMesh route instead of accepting a blind spot.
|
||||||
|
|
||||||
The `snapshot` command writes a MessagePack-encoded `cultnet.snapshot_response_raw.v0` message. This keeps CultCache payload bytes intact for peers that share the document binding.
|
The `snapshot` command writes a MessagePack-encoded `cultnet.snapshot_response_raw.v0` message. This keeps CultCache payload bytes intact for peers that share the document binding.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,86 +45,61 @@ It intentionally uses boring local credentials while Heimdall's managed GitHub c
|
||||||
- GitHub actions use the local `gh` authenticated account.
|
- GitHub actions use the local `gh` authenticated account.
|
||||||
- Discord posts use `BIFROST_DISCORD_BOT_TOKEN` or `DISCORD_BOT_TOKEN`.
|
- Discord posts use `BIFROST_DISCORD_BOT_TOKEN` or `DISCORD_BOT_TOKEN`.
|
||||||
- Reddit self-posts use `BIFROST_REDDIT_CLIENT_ID`, `BIFROST_REDDIT_REFRESH_TOKEN`, and optional `BIFROST_REDDIT_CLIENT_SECRET`.
|
- Reddit self-posts use `BIFROST_REDDIT_CLIENT_ID`, `BIFROST_REDDIT_REFRESH_TOKEN`, and optional `BIFROST_REDDIT_CLIENT_SECRET`.
|
||||||
- Future outside-world requests use `other-request` as a receipt-only bridge action until the target surface has a named Bifrost contract and actuator.
|
- Future outside-world requests must have a named typed command/receipt contract before they get a surface actuator.
|
||||||
|
|
||||||
The CLI is not the final permission system. It is the working bridge actuator Bifrost owns now, so VoidBot and repo Personas can stop carrying this machinery themselves.
|
The CLI is not the final permission system. It is the working bridge actuator Bifrost owns now, so VoidBot and repo Personas can stop carrying this machinery themselves.
|
||||||
It now fails closed for all external write surfaces by default. GitHub mutations already required the hosted bridge gate; Discord posts, Discord DMs, and Reddit self-posts now require the same `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` receipt path unless an operator explicitly uses `--allow-unreceipted-activity true` for local recovery. Dispatched work is not allowed to use that recovery hatch.
|
It now fails closed for external write surfaces unless the action carries typed CultMesh command provenance such as `BIFROST_CULTMESH_COMMAND_ID`. The old HTTP bridge ledger environment (`BIFROST_BRIDGE_BASE_URL` / `BIFROST_BRIDGE_TOKEN`) is removed from live CLI transport.
|
||||||
`other-request` also fails closed by default. It does not execute a transport; it asks Bifrost to authorize and receipt the request under `BridgeTargetSurface.Other` so new Persona mouths can start behind identity, Heimdall linkage, and provenance policy before anyone writes a surface-specific actuator.
|
`other-request` fails closed by default. It does not execute a transport; it must become a typed Bifrost command/receipt document under a named surface before anyone writes a surface-specific actuator.
|
||||||
|
|
||||||
## Bridge Action Ledger
|
## Crossing Receipts
|
||||||
|
|
||||||
The hosted app now exposes a first-class bridge action ledger for governed transport requests:
|
The owner is Bifrost, not the local script. The script or agent may execute the crossing, but Bifrost records the request, policy decision, lifecycle state, and receipt in typed CultCache/CultMesh documents.
|
||||||
|
|
||||||
- `POST /bridge/actions/request`
|
|
||||||
- `GET /bridge/actions/{id}`
|
|
||||||
- `POST /bridge/actions/{id}/start`
|
|
||||||
- `POST /bridge/actions/{id}/complete`
|
|
||||||
- `POST /bridge/actions/{id}/fail`
|
|
||||||
|
|
||||||
The owner is Bifrost, not the local script. The script or agent may execute the crossing, but Bifrost records the request, policy decision, lifecycle state, and receipt.
|
|
||||||
Persona and agent actions that cross into Discord, Reddit, or a future
|
Persona and agent actions that cross into Discord, Reddit, or a future
|
||||||
outside-world surface (`Other` until the surface has a first-class enum value)
|
outside-world surface (`Other` until the surface has a first-class enum value)
|
||||||
must carry a Bifrost identity plus a Heimdall-backed capability/account reference
|
must carry a Bifrost identity plus a Heimdall-backed capability/account reference
|
||||||
at request time. The local bridge token only proves that the actuator may call
|
at request time. The command document stores the Bifrost identity, Heimdall reference, Epiphany run id, lane id,
|
||||||
the ledger; it is not the actor identity and not outside-provider authority. The
|
|
||||||
ledger stores the Bifrost identity, Heimdall reference, Epiphany run id, lane id,
|
|
||||||
and agent identity beside the action so later receipts can answer which agent
|
and agent identity beside the action so later receipts can answer which agent
|
||||||
did what without opening local transcripts or trusting helper-script folklore.
|
did what without opening local transcripts or trusting helper-script folklore.
|
||||||
|
|
||||||
The hosted app also exposes a dispatch-run ledger for the older Bifrost-launched worker bridge:
|
The canonical receipt document is `bifrost.crossing_receipt.v1`, stored in
|
||||||
|
`.bifrost/bridge-receipts.cc`. It records one crossing attempt with command,
|
||||||
|
source, authority, actor, target, external proof, lifecycle status, and error
|
||||||
|
details. Surface-specific receipts, including
|
||||||
|
`bifrost.bridge.discord_post_receipt.v1`, are details that reference the
|
||||||
|
canonical receipt. They do not own crossing completion.
|
||||||
|
|
||||||
- `POST /dispatch/runs/start`
|
The old hosted dispatch-run and bridge-action ledgers are historical/quarantine evidence where records already exist. The forward path is Epiphany-owned execution plus Bifrost-owned typed crossing receipts.
|
||||||
- `POST /dispatch/runs/{id}/complete`
|
|
||||||
- `POST /dispatch/runs/{id}/fail`
|
|
||||||
|
|
||||||
That dispatcher was a transitional way for VoidBot-era Persona consensus to queue Codex workers. It is not the blessed Epiphany launch organ. Keep its receipts as historical/quarantine evidence where they exist, but the forward path is Epiphany-owned execution plus Bifrost-owned crossing receipts.
|
|
||||||
|
|
||||||
For Epiphany work, a bridge action says "this Epiphany run/lane requested a governed crossing into GitHub/Discord/Reddit and Bifrost receipted the result." The receipt payload should carry Bifrost identity, Epiphany run id, lane id, agent identity, source object, authority reference, Heimdall capability/account reference, and any external receipt facts such as PR URL, comment id, commit SHA, or changed paths.
|
For Epiphany work, a bridge action says "this Epiphany run/lane requested a governed crossing into GitHub/Discord/Reddit and Bifrost receipted the result." The receipt payload should carry Bifrost identity, Epiphany run id, lane id, agent identity, source object, authority reference, Heimdall capability/account reference, and any external receipt facts such as PR URL, comment id, commit SHA, or changed paths.
|
||||||
|
|
||||||
The hosted app also exposes a request-lane receipt ledger for transport lifecycle events:
|
Request-lane receipts are canonical crossing receipts for typed request-store mutations: queue, claim, release, close, and snapshot import. Governance receipts are canonical crossing receipts for typed governance-store mutations: topic opens, comments, approvals, and promotions. Runtime HTTP receipt posts are not a live authority.
|
||||||
|
|
||||||
- `POST /transport/receipts`
|
|
||||||
|
|
||||||
This covers the activity that happens before or around a visible Codex turn: queueing a request, claiming it, releasing it, and closing it. The `.cc` packet is still the transport document. The hosted receipt row is the operator-facing witness that the activity happened.
|
|
||||||
|
|
||||||
The hosted app also exposes a governance activity receipt ledger:
|
|
||||||
|
|
||||||
- `POST /governance/receipts`
|
|
||||||
|
|
||||||
This covers the other side of the swarm's work: topic opens, comments, approvals, and promotions to dispatch. The topic and comment documents are still the governance substrate. The hosted receipt row is the Bifrost-side witness that an agent or operator performed the governance action.
|
|
||||||
|
|
||||||
These runtime receipt lanes are not generic member write surfaces. In the current machine, `/dispatch/runs/*`, `/transport/receipts`, and `/governance/receipts` are owned by the configured local bridge token. Active members may govern and request bridge actions through the app, but they may not mint worker-history rows directly through a browser session and impersonate runtime activity.
|
|
||||||
They also now validate linkage across the hosted ledger instead of accepting isolated claims. A dispatch run must point at an already-seen request-lane receipt for the same request id. A governance promotion receipt that cites `dispatchRequestId` must point at a known request-lane request. Repeated request-lane rows for one request id may add activity, but they may not quietly change the repo or agent identity the request belongs to.
|
|
||||||
GitHub bridge actions that cite Bifrost-owned provenance now validate that provenance too. If a bridge action says its source is `bifrost_agent_transport_request`, the hosted bridge checks that the request exists in the request-lane ledger and still belongs to the same repo. If a bridge action says its source is `bifrost_governance_topic` or legacy `governance_topic`, the hosted bridge checks that the topic exists in the governance receipt trail before authorizing the crossing.
|
|
||||||
For GitHub publication, mere topic existence is no longer enough. A governance-backed GitHub bridge action now requires that the cited topic has reached at least `TopicApproved` or `TopicPromoted` in the governance receipt trail. Opened/commented-only topics are evidence of discussion, not yet permission to mutate GitHub.
|
|
||||||
|
|
||||||
Current gate behavior:
|
Current gate behavior:
|
||||||
|
|
||||||
- active Bifrost members may request bridge actions from an authenticated app session
|
- active Bifrost members may request bridge actions from an authenticated app session
|
||||||
- the transitional local bridge actuator may call the same endpoints with `X-Bifrost-Bridge-Token`
|
- daemon and agent crossings must use typed CultMesh command/receipt documents
|
||||||
- agent, Persona, and service actions must cite provenance: `authorityReference`, `sourceKind` plus `sourceId`, `workItemId`, or `motionId`
|
- agent, Persona, and service actions must cite provenance: `authorityReference`, `sourceKind` plus `sourceId`, `workItemId`, or `motionId`
|
||||||
- completed actions must report a receipt URL, external receipt id, or serialized receipt payload
|
- completed visible actions must write `bifrost.crossing_receipt.v1` with a receipt URL, external receipt id, or serialized receipt payload where the target surface provides one
|
||||||
- GitHub mutation commands fail closed unless Bifrost authorization is configured; `--allow-ungated-github true` is the explicit operator-recovery hatch
|
- GitHub mutation commands fail closed unless typed Bifrost CultMesh command provenance is present
|
||||||
- the future blessed credential for public-world crossings is a Bifrost identity plus Heimdall-issued capability token or verifiable capability/account reference. New outside-world bridge surfaces must start behind that same identity/capability gate, using `Other` only as a temporary enum bucket until the target surface gets a named contract. The local bridge token is a transitional service actuator credential, not a substitute for Bifrost identity or Heimdall-backed outside-account authority.
|
- the future blessed credential for public-world crossings is a Bifrost identity plus Heimdall-issued capability reference or verifiable capability/account reference. New outside-world bridge surfaces must start behind that same identity/capability gate. Bifrost records claim `jti`, `account_id`, `access_revision`, `exp`, grant id/ref, and revoked/expired behavior as references or redacted snapshots, not bearer tokens.
|
||||||
|
|
||||||
Current CLI wiring:
|
Current CLI wiring:
|
||||||
|
|
||||||
- set `BIFROST_BRIDGE_BASE_URL` to the Bifrost app base URL
|
- provide `BIFROST_CULTMESH_COMMAND_ID` or `--cultmesh-command-id` from the typed command document being processed
|
||||||
- set `BIFROST_BRIDGE_TOKEN` to the configured local bridge token
|
- GitHub commands require typed CultMesh command provenance by default, even for dry-run, so the normal path cannot silently bypass the gate
|
||||||
- GitHub commands now require both values by default, even for dry-run, so the normal path cannot silently bypass the gate
|
|
||||||
- pass `--identity`, `--source-kind`, `--source-id`, `--authority-ref`, `--work-item-id`, or `--motion-id` so agent actions satisfy policy
|
- pass `--identity`, `--source-kind`, `--source-id`, `--authority-ref`, `--work-item-id`, or `--motion-id` so agent actions satisfy policy
|
||||||
- Epiphany callers should also pass `--epiphany-run-id`, `--epiphany-lane-id`, `--epiphany-agent-identity`, and `--heimdall-capability-ref` or provide `EPIPHANY_RUN_ID`, `EPIPHANY_LANE_ID`, `EPIPHANY_AGENT_IDENTITY`, and `HEIMDALL_CAPABILITY_REF` in the environment. The CLI stores the Bifrost identity and capability/account reference or fingerprint-shaped value, never the Heimdall bearer token.
|
- Epiphany callers should also pass `--epiphany-run-id`, `--epiphany-lane-id`, `--epiphany-agent-identity`, and `--heimdall-capability-ref` or provide `EPIPHANY_RUN_ID`, `EPIPHANY_LANE_ID`, `EPIPHANY_AGENT_IDENTITY`, and `HEIMDALL_CAPABILITY_REF` in the environment. The CLI stores the Bifrost identity and capability/account reference or fingerprint-shaped value, never the Heimdall bearer token.
|
||||||
- the dispatcher now preloads `BIFROST_BRIDGE_SOURCE_KIND`, `BIFROST_BRIDGE_SOURCE_ID`, and `BIFROST_BRIDGE_AUTHORITY_REF` into Codex turns launched from Bifrost update requests, so normal bridge use inside a dispatched turn carries request provenance by default
|
- the dispatcher now preloads `BIFROST_BRIDGE_SOURCE_KIND`, `BIFROST_BRIDGE_SOURCE_ID`, and `BIFROST_BRIDGE_AUTHORITY_REF` into Codex turns launched from Bifrost update requests, so normal bridge use inside a dispatched turn carries request provenance by default
|
||||||
- those dispatched turns also inject Bifrost-owned GitHub gates through environment config: `git` and `gh` wrappers are prepended in `PATH`, a `pre-push` hook backs up raw Git pushes, raw `git push` stays blocked even when `--no-verify` is used, and direct `gh` usage is limited to explicit read-only commands unless the bridge explicitly authorizes the GitHub write path
|
- those dispatched turns also inject Bifrost-owned GitHub gates through environment config: `git` and `gh` wrappers are prepended in `PATH`, a `pre-push` hook backs up raw Git pushes, raw `git push` stays blocked even when `--no-verify` is used, and direct `gh` usage is limited to explicit read-only commands unless the bridge explicitly authorizes the GitHub write path
|
||||||
- those dispatched turns also lock local-only recovery hatches: `--allow-ungated-github`, `BIFROST_ALLOW_UNGATED_GITHUB`, `--allow-unreceipted-activity`, and `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY` are refused inside dispatched work so a worker cannot opt itself out of Bifrost gate or receipt policy
|
- those dispatched turns inherit the same gate and receipt invariant, so a worker cannot opt itself out of Bifrost gate or receipt policy
|
||||||
- those dispatched turns also scrub ambient GitHub auth state from the child environment before the worker starts: `GH_TOKEN` and `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompts are disabled
|
- those dispatched turns also scrub ambient GitHub auth state from the child environment before the worker starts: `GH_TOKEN` and `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompts are disabled
|
||||||
- those dispatched turns now default to the `workspace-write` sandbox in both `codex exec` and app-server launch paths, and the non-danger app-server sandbox policy sets `networkAccess: false` so GitHub mutation authority stays with Bifrost instead of bleeding back into the child runtime by default
|
- those dispatched turns now default to the `workspace-write` sandbox in both `codex exec` and app-server launch paths, and the non-danger app-server sandbox policy sets `networkAccess: false` so GitHub mutation authority stays with Bifrost instead of bleeding back into the child runtime by default
|
||||||
- the dispatcher also uses that same bridge configuration to post dispatch-run start/complete/fail receipts, including the worker pid, thread/turn ids when available, and result/log paths for the local operator trail
|
- the dispatcher writes local run artifacts and request-store state for start/complete/fail visibility
|
||||||
- the agent transport CLI uses that same bridge configuration to post request-lane receipts for queue, claim, release, and close events, so Bifrost does not go blind between intake and dispatch
|
- the agent transport CLI writes canonical crossing receipts for queue, claim, release, close, and snapshot import events to `.bifrost/bridge-receipts.cc`
|
||||||
- the governance threads CLI uses that same bridge configuration to post governance receipts for topic opens, comments, approvals, and promotions, so Bifrost does not lose the pre-dispatch reasoning trail
|
- the governance threads CLI writes canonical crossing receipts for topic opens, comments, approvals, and promotions to `.bifrost/bridge-receipts.cc`
|
||||||
- those agent-activity CLIs now fail closed without that bridge configuration; `--allow-unreceipted-activity true` or `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY=true` is the deliberate operator-recovery hatch when you knowingly want local-only activity
|
- those agent-activity CLIs now fail closed without that bridge configuration
|
||||||
|
|
||||||
If the app bridge configuration is absent, `tools/bifrost-bridge.mjs` keeps working as a local actuator. When configured, it asks Bifrost for authorization before acting and reports success or failure back to the same bridge action row.
|
If hosted app authorization is absent, `tools/bifrost-bridge.mjs` keeps working as a local actuator only when typed CultMesh command provenance is present. It writes requested, running, completed, failed, or cancelled crossing receipts directly to the canonical receipt store.
|
||||||
|
|
||||||
### GitHub Draft PR
|
### GitHub Draft PR
|
||||||
|
|
||||||
|
|
@ -213,7 +188,7 @@ node .\tools\bifrost-bridge.mjs other-request `
|
||||||
--content "Persona asks Bifrost to authorize this public crossing."
|
--content "Persona asks Bifrost to authorize this public crossing."
|
||||||
```
|
```
|
||||||
|
|
||||||
The command records and completes a Bifrost bridge action using `targetSurface: Other` and `actionKind: Other`. It is the quarantine reliquary for public surfaces that are real enough to need identity and receipts, but not yet stable enough to deserve a first-class enum, actuator, or UI. Do not put provider bearer tokens in `--heimdall-capability-ref`; that value is the Heimdall-issued account/capability reference or a verifiable fingerprint-shaped reference.
|
The command is a dry-run shape probe for public surfaces that are real enough to need identity and receipts, but not yet stable enough to deserve a first-class enum, actuator, or UI. Live writes fail closed until that surface has a typed Bifrost command/receipt contract. Do not put provider bearer tokens in `--heimdall-capability-ref`; that value is the Heimdall-issued account/capability reference or a verifiable fingerprint-shaped reference.
|
||||||
|
|
||||||
## Discord Native Interface Target
|
## Discord Native Interface Target
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ Current witnesses:
|
||||||
- `.bifrost/agent-transport.cc` stores
|
- `.bifrost/agent-transport.cc` stores
|
||||||
`bifrost.agent-transport.update-request` documents and can emit raw CultNet
|
`bifrost.agent-transport.update-request` documents and can emit raw CultNet
|
||||||
snapshots.
|
snapshots.
|
||||||
|
- `.bifrost/bridge-receipts.cc` stores canonical
|
||||||
|
`bifrost.crossing_receipt.v1` documents for governed public crossing
|
||||||
|
attempts.
|
||||||
|
|
||||||
Required witness path for the hosted service:
|
Required witness path for the hosted service:
|
||||||
|
|
||||||
|
|
@ -103,12 +106,12 @@ cache directly for debugging and development, but that is not the xeno boundary.
|
||||||
Bifrost has a read-only first-cut provider advertisement command:
|
Bifrost has a read-only first-cut provider advertisement command:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
node tools/provider-advertisement.mjs export --out .bifrost/provider-advertisement.cc
|
node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc
|
||||||
node tools/provider-advertisement.mjs publish-odin --odin-cultmesh-rudp 127.0.0.1:17871
|
$env:BIFROST_ODIN_CULTMESH_URI="cultmesh://odin/rendezvous/provider-catalog"; node tools/provider-advertisement.mjs publish-odin
|
||||||
```
|
```
|
||||||
|
|
||||||
It writes Bifrost-owned Eve discovery documents into
|
It writes Bifrost-owned Eve discovery documents into
|
||||||
`.bifrost/provider-advertisement.cc`:
|
`.bifrost/provider-store.cc`:
|
||||||
|
|
||||||
- `gamecult.eve.provider_advertisement.v1` names Bifrost's Account, Patron,
|
- `gamecult.eve.provider_advertisement.v1` names Bifrost's Account, Patron,
|
||||||
Project, Work, Motion, and Operator Verse surfaces; current and planned
|
Project, Work, Motion, and Operator Verse surfaces; current and planned
|
||||||
|
|
@ -125,9 +128,11 @@ This export does not migrate Postgres state, read secrets, execute bridge
|
||||||
actions, or make Razor Pages, HTTP probes, Discord mirrors, Reddit threads, or
|
actions, or make Razor Pages, HTTP probes, Discord mirrors, Reddit threads, or
|
||||||
local dispatch JSON canonical. The operator surface may display those probes, but the provider
|
local dispatch JSON canonical. The operator surface may display those probes, but the provider
|
||||||
owned `.cc` witness remains the discovery and dashboard source.
|
owned `.cc` witness remains the discovery and dashboard source.
|
||||||
When `BIFROST_ODIN_CULTMESH_RUDP` or `--odin-cultmesh-rudp` is configured, the
|
When `BIFROST_ODIN_CULTMESH_URI` or `--odin-cultmesh-uri` is configured, the
|
||||||
same provider advertisement can be published once to Odin's CultMesh/RUDP
|
same provider advertisement can be published once to Odin's CultMesh rendezvous
|
||||||
document catalog through the provider-advertisement tool.
|
URI through the provider-advertisement tool. Concrete RUDP bootstrap endpoints
|
||||||
|
belong behind CultMesh URI resolution, not in Bifrost provider publication
|
||||||
|
configuration.
|
||||||
|
|
||||||
For protocol-debug inspection without writing a witness:
|
For protocol-debug inspection without writing a witness:
|
||||||
|
|
||||||
|
|
@ -146,6 +151,19 @@ Product surfaces are the canonical interface compositions for:
|
||||||
- work claims, review, completion, receipts, and contributor credit;
|
- work claims, review, completion, receipts, and contributor credit;
|
||||||
- bridge receipts and public handoff targets.
|
- bridge receipts and public handoff targets.
|
||||||
|
|
||||||
|
Canonical crossing receipts use `bifrost.crossing_receipt.v1` in
|
||||||
|
`.bifrost/bridge-receipts.cc`. A receipt records `receiptId`, `commandId`,
|
||||||
|
`crossingKind`, lifecycle status, Bifrost actor, source provenance, authority
|
||||||
|
reference, optional Heimdall claim/grant references, optional Epiphany run/lane
|
||||||
|
identity, target locator, external receipt facts, error details, and related
|
||||||
|
receipt ids. Surface-specific receipts such as
|
||||||
|
`bifrost.bridge.discord_post_receipt.v1` may remain as details, but they must
|
||||||
|
reference the canonical crossing receipt and cannot decide crossing completion.
|
||||||
|
Heimdall values recorded here are stable references or redacted snapshots:
|
||||||
|
claim `jti`, `account_id`, `access_revision`, `exp`, grant id/ref, and
|
||||||
|
revoked/expired behavior. Provider bearer tokens do not belong in Bifrost
|
||||||
|
receipts.
|
||||||
|
|
||||||
Operator surfaces are the canonical interface compositions for:
|
Operator surfaces are the canonical interface compositions for:
|
||||||
|
|
||||||
- readiness, build/version, config validation, and deploy target;
|
- readiness, build/version, config validation, and deploy target;
|
||||||
|
|
@ -160,11 +178,18 @@ Operator surfaces are the canonical interface compositions for:
|
||||||
- Reddit bridge readiness for `r/GameCultOrg` organizing posts and Persona flair.
|
- Reddit bridge readiness for `r/GameCultOrg` organizing posts and Persona flair.
|
||||||
|
|
||||||
The Motion Verse has begun the migration from Razor-owned behavior to Eve-owned
|
The Motion Verse has begun the migration from Razor-owned behavior to Eve-owned
|
||||||
presentation. `/eve/governance/surface` emits a `gamecult.eve.surface.v1`
|
presentation. Bifrost publishes the motion surface and command target as
|
||||||
composition for motions and voting. `/eve/governance/commands` accepts
|
CultMesh addresses discovered through Odin:
|
||||||
`motion.create`, `motion.vote`, and `motion.close` commands, then routes those
|
|
||||||
commands through the same Bifrost motion governance service used by the Razor
|
```text
|
||||||
forms.
|
cultmesh://asgard.starfire.bifrost/eve/governance/surface
|
||||||
|
cultmesh://asgard.starfire.bifrost/commands/motion
|
||||||
|
```
|
||||||
|
|
||||||
|
The transitional browser product may lower motion state for members, but it is
|
||||||
|
not a Verse command transport. `motion.create`, `motion.vote`, and
|
||||||
|
`motion.close` are command documents for the CultMesh route, then commit through
|
||||||
|
the same Bifrost motion governance service used by the Razor forms.
|
||||||
|
|
||||||
The current Razor Pages app is now a transitional browser lowering for motions,
|
The current Razor Pages app is now a transitional browser lowering for motions,
|
||||||
not the behavior owner. Existing health/readiness endpoints lower operator
|
not the behavior owner. Existing health/readiness endpoints lower operator
|
||||||
|
|
@ -226,10 +251,10 @@ Bifrost.
|
||||||
## Migration Order
|
## Migration Order
|
||||||
|
|
||||||
1. Catalog current Bifrost state owners: EF/Postgres tables, `.cc` stores,
|
1. Catalog current Bifrost state owners: EF/Postgres tables, `.cc` stores,
|
||||||
bridge receipts, Discord mirrors, Reddit receipts, webhook cache, and app
|
crossing receipts, Discord mirrors, Reddit receipts, webhook cache, and app
|
||||||
readiness state.
|
readiness state.
|
||||||
2. Define schema ids for missing Bifrost documents: work item, motion, vote,
|
2. Define schema ids for missing Bifrost documents: work item, motion, vote,
|
||||||
ledger entry, bridge action, bridge receipt, member capability snapshot,
|
ledger entry, bridge action, crossing receipt, member capability snapshot,
|
||||||
service registration, and Eve surface publication.
|
service registration, and Eve surface publication.
|
||||||
3. Add Postgres-to-CultCache witness export for alpha entities before replacing
|
3. Add Postgres-to-CultCache witness export for alpha entities before replacing
|
||||||
the transactional store.
|
the transactional store.
|
||||||
|
|
@ -253,10 +278,15 @@ Demoted surfaces and stores:
|
||||||
|
|
||||||
- Razor Pages views are no longer the canonical Bifrost presentation owner; they
|
- Razor Pages views are no longer the canonical Bifrost presentation owner; they
|
||||||
are browser lowerings of Bifrost product/operator surfaces.
|
are browser lowerings of Bifrost product/operator surfaces.
|
||||||
- `/healthz`, `/readyz`, and any future HTTP dashboard/status JSON are probes or
|
- `/healthz`, `/readyz`, and any future HTTP dashboard/status JSON are product
|
||||||
xenos-boundary exports, not service truth.
|
smoke probes or xenos-boundary exports, not service truth. Provider
|
||||||
|
advertisements and Odin-facing operator readiness must reject HTTP probe
|
||||||
|
configuration and derive status from typed CultMesh/Idunn state.
|
||||||
- `.bifrost/discord-webhook-cache.json` is webhook address cache only; it does
|
- `.bifrost/discord-webhook-cache.json` is webhook address cache only; it does
|
||||||
not prove governance, speech authority, or receipt completion.
|
not prove governance, speech authority, or receipt completion.
|
||||||
|
- `bifrost.bridge.discord_post_receipt.v1` and other surface receipts are
|
||||||
|
surface-specific evidence only. `bifrost.crossing_receipt.v1` is the
|
||||||
|
canonical crossing receipt owner.
|
||||||
- `.bifrost/agent-dispatch/**/request.json`, `dispatch.json`, `result.json`, and
|
- `.bifrost/agent-dispatch/**/request.json`, `dispatch.json`, `result.json`, and
|
||||||
app-server protocol JSON are local dispatch/protocol artifacts. They may be
|
app-server protocol JSON are local dispatch/protocol artifacts. They may be
|
||||||
evidence attached to typed receipts, but they cannot decide request status,
|
evidence attached to typed receipts, but they cannot decide request status,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ namespace Bifrost.Web.Features.Motions;
|
||||||
|
|
||||||
public sealed class MotionEveSurfaceService
|
public sealed class MotionEveSurfaceService
|
||||||
{
|
{
|
||||||
|
private const string MotionCommandTarget = "cultmesh://asgard.starfire.bifrost/commands/motion";
|
||||||
|
|
||||||
public EveSurfaceDocument BuildSurface(MotionGovernanceState state)
|
public EveSurfaceDocument BuildSurface(MotionGovernanceState state)
|
||||||
{
|
{
|
||||||
var motionNodes = state.Motions.Count > 0
|
var motionNodes = state.Motions.Count > 0
|
||||||
|
|
@ -23,7 +25,9 @@ public sealed class MotionEveSurfaceService
|
||||||
["subtitle"] = "Motions, weighted votes, thresholds, and receipts",
|
["subtitle"] = "Motions, weighted votes, thresholds, and receipts",
|
||||||
["actor"] = state.ActorName,
|
["actor"] = state.ActorName,
|
||||||
["effectiveVotingWeight"] = state.EffectiveVotingWeight,
|
["effectiveVotingWeight"] = state.EffectiveVotingWeight,
|
||||||
["canonicalCommandTarget"] = "/eve/governance/commands"
|
["canonicalCommandTarget"] = MotionCommandTarget,
|
||||||
|
["commandTransport"] = "cultmesh-command-document",
|
||||||
|
["commandSchema"] = "bifrost.motion_command.v0"
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
new EveNode(
|
new EveNode(
|
||||||
|
|
@ -33,8 +37,8 @@ public sealed class MotionEveSurfaceService
|
||||||
{
|
{
|
||||||
["title"] = "Open Motion",
|
["title"] = "Open Motion",
|
||||||
["command"] = "motion.create",
|
["command"] = "motion.create",
|
||||||
["target"] = "/eve/governance/commands",
|
["target"] = MotionCommandTarget,
|
||||||
["method"] = "POST"
|
["transport"] = "cultmesh-command-document"
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
SelectNode("scope", "Scope", EnumNames<MotionScope>()),
|
SelectNode("scope", "Scope", EnumNames<MotionScope>()),
|
||||||
|
|
@ -121,7 +125,11 @@ public sealed class MotionEveSurfaceService
|
||||||
new EveNode(
|
new EveNode(
|
||||||
$"motion-commands-{motion.Id}",
|
$"motion-commands-{motion.Id}",
|
||||||
"commands",
|
"commands",
|
||||||
new Dictionary<string, object?> { ["target"] = "/eve/governance/commands" },
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["target"] = MotionCommandTarget,
|
||||||
|
["transport"] = "cultmesh-command-document"
|
||||||
|
},
|
||||||
commandNodes)
|
commandNodes)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -139,8 +147,8 @@ public sealed class MotionEveSurfaceService
|
||||||
{
|
{
|
||||||
["label"] = label,
|
["label"] = label,
|
||||||
["command"] = command,
|
["command"] = command,
|
||||||
["target"] = "/eve/governance/commands",
|
["target"] = MotionCommandTarget,
|
||||||
["method"] = "POST",
|
["transport"] = "cultmesh-command-document",
|
||||||
["payload"] = payload,
|
["payload"] = payload,
|
||||||
["tone"] = tone
|
["tone"] = tone
|
||||||
});
|
});
|
||||||
|
|
@ -193,15 +201,3 @@ public sealed record EveNode(
|
||||||
IReadOnlyList<EveNode>? Children = null);
|
IReadOnlyList<EveNode>? Children = null);
|
||||||
|
|
||||||
public sealed record EveOption(string Value, string Label);
|
public sealed record EveOption(string Value, string Label);
|
||||||
|
|
||||||
public sealed record MotionEveCommandRequest(
|
|
||||||
string Command,
|
|
||||||
Guid? MotionId,
|
|
||||||
MotionScope? Scope,
|
|
||||||
Guid? ProjectId,
|
|
||||||
MotionCategory? Category,
|
|
||||||
string? Title,
|
|
||||||
string? Summary,
|
|
||||||
DateTimeOffset? ClosesAtUtc,
|
|
||||||
VoteChoice? Choice,
|
|
||||||
string? Comment);
|
|
||||||
|
|
|
||||||
|
|
@ -470,282 +470,6 @@ app.MapPost("/patronage/stripe/webhook", async (
|
||||||
};
|
};
|
||||||
}).AllowAnonymous();
|
}).AllowAnonymous();
|
||||||
|
|
||||||
var bridgeActions = app.MapGroup("/bridge/actions");
|
|
||||||
|
|
||||||
bridgeActions.MapPost("/request", async (
|
|
||||||
HttpRequest request,
|
|
||||||
BridgeActionRequest command,
|
|
||||||
ICurrentBifrostActorAccessor actorAccessor,
|
|
||||||
BridgeActionService bridgeActionService,
|
|
||||||
IOptions<BridgeOptions> bridgeOptions,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
{
|
|
||||||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
|
||||||
var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value);
|
|
||||||
if (!caller.IsAllowedTransport)
|
|
||||||
{
|
|
||||||
return Results.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await bridgeActionService.RequestAsync(command, caller, cancellationToken);
|
|
||||||
return result.Status == BridgeActionStatus.Denied
|
|
||||||
? JsonResult(result, appJsonSerializerOptions, StatusCodes.Status403Forbidden)
|
|
||||||
: JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
|
||||||
});
|
|
||||||
|
|
||||||
bridgeActions.MapGet("/{id:guid}", async (
|
|
||||||
Guid id,
|
|
||||||
HttpRequest request,
|
|
||||||
ICurrentBifrostActorAccessor actorAccessor,
|
|
||||||
BifrostDbContext dbContext,
|
|
||||||
IOptions<BridgeOptions> bridgeOptions,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
{
|
|
||||||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
|
||||||
var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value);
|
|
||||||
if (!caller.IsAllowedTransport)
|
|
||||||
{
|
|
||||||
return Results.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var action = await dbContext.BridgeActions
|
|
||||||
.AsNoTracking()
|
|
||||||
.SingleOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
||||||
|
|
||||||
if (action is null)
|
|
||||||
{
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!caller.CanOperate(action))
|
|
||||||
{
|
|
||||||
return Results.Forbid();
|
|
||||||
}
|
|
||||||
|
|
||||||
return JsonResult(BridgeActionResult.From(action), appJsonSerializerOptions);
|
|
||||||
});
|
|
||||||
|
|
||||||
bridgeActions.MapPost("/{id:guid}/start", async (
|
|
||||||
Guid id,
|
|
||||||
HttpRequest request,
|
|
||||||
ICurrentBifrostActorAccessor actorAccessor,
|
|
||||||
BridgeActionService bridgeActionService,
|
|
||||||
IOptions<BridgeOptions> bridgeOptions,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
{
|
|
||||||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
|
||||||
var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value);
|
|
||||||
if (!caller.IsAllowedTransport)
|
|
||||||
{
|
|
||||||
return Results.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await bridgeActionService.StartAsync(id, caller, cancellationToken);
|
|
||||||
return result is null
|
|
||||||
? Results.NotFound()
|
|
||||||
: JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
return Results.Forbid();
|
|
||||||
}
|
|
||||||
catch (BridgeActionException error)
|
|
||||||
{
|
|
||||||
return Results.BadRequest(error.Message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
bridgeActions.MapPost("/{id:guid}/complete", async (
|
|
||||||
Guid id,
|
|
||||||
HttpRequest request,
|
|
||||||
BridgeActionReceiptRequest command,
|
|
||||||
ICurrentBifrostActorAccessor actorAccessor,
|
|
||||||
BridgeActionService bridgeActionService,
|
|
||||||
IOptions<BridgeOptions> bridgeOptions,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
{
|
|
||||||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
|
||||||
var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value);
|
|
||||||
if (!caller.IsAllowedTransport)
|
|
||||||
{
|
|
||||||
return Results.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await bridgeActionService.CompleteAsync(id, command, caller, cancellationToken);
|
|
||||||
return result is null
|
|
||||||
? Results.NotFound()
|
|
||||||
: JsonResult(result, appJsonSerializerOptions);
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
return Results.Forbid();
|
|
||||||
}
|
|
||||||
catch (BridgeActionException error)
|
|
||||||
{
|
|
||||||
return Results.BadRequest(error.Message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
bridgeActions.MapPost("/{id:guid}/fail", async (
|
|
||||||
Guid id,
|
|
||||||
HttpRequest request,
|
|
||||||
BridgeActionFailureRequest command,
|
|
||||||
ICurrentBifrostActorAccessor actorAccessor,
|
|
||||||
BridgeActionService bridgeActionService,
|
|
||||||
IOptions<BridgeOptions> bridgeOptions,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
{
|
|
||||||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
|
||||||
var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value);
|
|
||||||
if (!caller.IsAllowedTransport)
|
|
||||||
{
|
|
||||||
return Results.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await bridgeActionService.FailAsync(id, command, caller, cancellationToken);
|
|
||||||
return result is null
|
|
||||||
? Results.NotFound()
|
|
||||||
: JsonResult(result, appJsonSerializerOptions);
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
return Results.Forbid();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var dispatchRuns = app.MapGroup("/dispatch/runs");
|
|
||||||
|
|
||||||
dispatchRuns.MapPost("/start", async (
|
|
||||||
HttpRequest request,
|
|
||||||
AgentDispatchRunStartRequest command,
|
|
||||||
AgentDispatchRunService agentDispatchRunService,
|
|
||||||
IOptions<BridgeOptions> bridgeOptions,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
{
|
|
||||||
if (!HasValidLocalBridgeToken(request, bridgeOptions.Value))
|
|
||||||
{
|
|
||||||
return Results.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await agentDispatchRunService.StartAsync(command, null, cancellationToken);
|
|
||||||
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
|
||||||
}
|
|
||||||
catch (AgentDispatchRunException error)
|
|
||||||
{
|
|
||||||
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
dispatchRuns.MapPost("/{id:guid}/complete", async (
|
|
||||||
Guid id,
|
|
||||||
HttpRequest request,
|
|
||||||
AgentDispatchRunCompletionRequest command,
|
|
||||||
AgentDispatchRunService agentDispatchRunService,
|
|
||||||
IOptions<BridgeOptions> bridgeOptions,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
{
|
|
||||||
if (!HasValidLocalBridgeToken(request, bridgeOptions.Value))
|
|
||||||
{
|
|
||||||
return Results.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await agentDispatchRunService.CompleteAsync(id, command, null, cancellationToken);
|
|
||||||
return result is null
|
|
||||||
? Results.NotFound()
|
|
||||||
: JsonResult(result, appJsonSerializerOptions);
|
|
||||||
}
|
|
||||||
catch (AgentDispatchRunException error)
|
|
||||||
{
|
|
||||||
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
dispatchRuns.MapPost("/{id:guid}/fail", async (
|
|
||||||
Guid id,
|
|
||||||
HttpRequest request,
|
|
||||||
AgentDispatchRunFailureRequest command,
|
|
||||||
AgentDispatchRunService agentDispatchRunService,
|
|
||||||
IOptions<BridgeOptions> bridgeOptions,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
{
|
|
||||||
if (!HasValidLocalBridgeToken(request, bridgeOptions.Value))
|
|
||||||
{
|
|
||||||
return Results.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await agentDispatchRunService.FailAsync(id, command, null, cancellationToken);
|
|
||||||
return result is null
|
|
||||||
? Results.NotFound()
|
|
||||||
: JsonResult(result, appJsonSerializerOptions);
|
|
||||||
}
|
|
||||||
catch (AgentDispatchRunException error)
|
|
||||||
{
|
|
||||||
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var transportReceipts = app.MapGroup("/transport/receipts");
|
|
||||||
|
|
||||||
transportReceipts.MapPost("", async (
|
|
||||||
HttpRequest request,
|
|
||||||
AgentTransportReceiptRequest command,
|
|
||||||
AgentTransportReceiptService agentTransportReceiptService,
|
|
||||||
IOptions<BridgeOptions> bridgeOptions,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
{
|
|
||||||
if (!HasValidLocalBridgeToken(request, bridgeOptions.Value))
|
|
||||||
{
|
|
||||||
return Results.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await agentTransportReceiptService.RecordAsync(command, null, cancellationToken);
|
|
||||||
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
|
||||||
}
|
|
||||||
catch (AgentTransportReceiptException error)
|
|
||||||
{
|
|
||||||
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var governanceReceipts = app.MapGroup("/governance/receipts");
|
|
||||||
|
|
||||||
governanceReceipts.MapPost("", async (
|
|
||||||
HttpRequest request,
|
|
||||||
GovernanceActivityReceiptRequest command,
|
|
||||||
GovernanceActivityReceiptService governanceActivityReceiptService,
|
|
||||||
IOptions<BridgeOptions> bridgeOptions,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
{
|
|
||||||
if (!HasValidLocalBridgeToken(request, bridgeOptions.Value))
|
|
||||||
{
|
|
||||||
return Results.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await governanceActivityReceiptService.RecordAsync(command, null, cancellationToken);
|
|
||||||
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
|
||||||
}
|
|
||||||
catch (GovernanceActivityReceiptException error)
|
|
||||||
{
|
|
||||||
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var eveGovernance = app.MapGroup("/eve/governance")
|
var eveGovernance = app.MapGroup("/eve/governance")
|
||||||
.RequireAuthorization(ActiveMemberRequirement.PolicyName);
|
.RequireAuthorization(ActiveMemberRequirement.PolicyName);
|
||||||
|
|
||||||
|
|
@ -763,77 +487,6 @@ eveGovernance.MapGet("/surface", async (
|
||||||
"application/json");
|
"application/json");
|
||||||
});
|
});
|
||||||
|
|
||||||
eveGovernance.MapPost("/commands", async (
|
|
||||||
MotionEveCommandRequest command,
|
|
||||||
ICurrentBifrostActorAccessor actorAccessor,
|
|
||||||
MotionGovernanceService motionGovernanceService,
|
|
||||||
MotionEveSurfaceService motionEveSurfaceService,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
{
|
|
||||||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
switch (command.Command)
|
|
||||||
{
|
|
||||||
case "motion.create":
|
|
||||||
if (command.Scope is null ||
|
|
||||||
command.Category is null ||
|
|
||||||
command.ClosesAtUtc is null ||
|
|
||||||
string.IsNullOrWhiteSpace(command.Title) ||
|
|
||||||
string.IsNullOrWhiteSpace(command.Summary))
|
|
||||||
{
|
|
||||||
return Results.BadRequest("motion.create requires scope, category, title, summary, and closesAtUtc.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await motionGovernanceService.CreateMotionAsync(
|
|
||||||
actor,
|
|
||||||
new CreateMotionCommand(
|
|
||||||
command.Scope.Value,
|
|
||||||
command.ProjectId,
|
|
||||||
command.Category.Value,
|
|
||||||
command.Title,
|
|
||||||
command.Summary,
|
|
||||||
command.ClosesAtUtc.Value),
|
|
||||||
cancellationToken);
|
|
||||||
break;
|
|
||||||
case "motion.vote":
|
|
||||||
if (command.MotionId is null || command.Choice is null)
|
|
||||||
{
|
|
||||||
return Results.BadRequest("motion.vote requires motionId and choice.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await motionGovernanceService.CastVoteAsync(
|
|
||||||
actor,
|
|
||||||
command.MotionId.Value,
|
|
||||||
command.Choice.Value,
|
|
||||||
command.Comment,
|
|
||||||
cancellationToken);
|
|
||||||
break;
|
|
||||||
case "motion.close":
|
|
||||||
if (command.MotionId is null)
|
|
||||||
{
|
|
||||||
return Results.BadRequest("motion.close requires motionId.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await motionGovernanceService.CloseMotionAsync(actor, command.MotionId.Value, cancellationToken);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return Results.BadRequest($"Unknown Eve governance command '{command.Command}'.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
return Results.Forbid();
|
|
||||||
}
|
|
||||||
|
|
||||||
var state = await motionGovernanceService.GetStateAsync(actor, cancellationToken);
|
|
||||||
var surface = motionEveSurfaceService.BuildSurface(state);
|
|
||||||
return Results.Text(
|
|
||||||
JsonSerializer.Serialize(surface, appJsonSerializerOptions),
|
|
||||||
"application/json");
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapGet("/auth/heimdall/wait", async (
|
app.MapGet("/auth/heimdall/wait", async (
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
HeimdallAuthService heimdallAuthService,
|
HeimdallAuthService heimdallAuthService,
|
||||||
|
|
@ -926,28 +579,6 @@ static JsonSerializerOptions CreateAppJsonSerializerOptions()
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
static BridgeCaller BuildBridgeCaller(
|
|
||||||
CurrentBifrostActor actor,
|
|
||||||
HttpRequest request,
|
|
||||||
BridgeOptions bridgeOptions)
|
|
||||||
{
|
|
||||||
var isLocalBridge = HasValidLocalBridgeToken(request, bridgeOptions);
|
|
||||||
|
|
||||||
return new BridgeCaller(
|
|
||||||
actor.IsActiveMember,
|
|
||||||
isLocalBridge,
|
|
||||||
actor.UserAccount?.Id,
|
|
||||||
actor.DisplayName);
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool HasValidLocalBridgeToken(HttpRequest request, BridgeOptions bridgeOptions)
|
|
||||||
{
|
|
||||||
var bridgeToken = request.Headers["X-Bifrost-Bridge-Token"].ToString();
|
|
||||||
return
|
|
||||||
bridgeOptions.HasLocalBridgeToken &&
|
|
||||||
string.Equals(bridgeToken, bridgeOptions.LocalBridgeToken, StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|
||||||
public sealed record NativeBifrostRegistrationRequest(
|
public sealed record NativeBifrostRegistrationRequest(
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,27 @@ public sealed class BridgeCliTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GitHub_bridge_command_allows_explicit_operator_recovery_dry_run()
|
public async Task GitHub_bridge_command_allows_bifrost_gate_dry_run()
|
||||||
|
{
|
||||||
|
var result = await RunNodeAsync([
|
||||||
|
"tools/bifrost-bridge.mjs",
|
||||||
|
"github-pr-comment",
|
||||||
|
"--repo-root", RepoRoot,
|
||||||
|
"--identity", "nibu",
|
||||||
|
"--pr", "1",
|
||||||
|
"--content", "test comment",
|
||||||
|
"--target-repository-full-name", "GameCult/Bifrost",
|
||||||
|
"--dry-run", "true",
|
||||||
|
], TypedCommandEnv());
|
||||||
|
|
||||||
|
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
|
||||||
|
using var payload = JsonDocument.Parse(result.Stdout);
|
||||||
|
Assert.Equal("github-pr-comment", payload.RootElement.GetProperty("action").GetString());
|
||||||
|
Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GitHub_bridge_recovery_hatch_is_rejected()
|
||||||
{
|
{
|
||||||
var result = await RunNodeAsync([
|
var result = await RunNodeAsync([
|
||||||
"tools/bifrost-bridge.mjs",
|
"tools/bifrost-bridge.mjs",
|
||||||
|
|
@ -38,30 +58,6 @@ public sealed class BridgeCliTests
|
||||||
"--dry-run", "true",
|
"--dry-run", "true",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
|
|
||||||
using var payload = JsonDocument.Parse(result.Stdout);
|
|
||||||
Assert.Equal("github-pr-comment", payload.RootElement.GetProperty("action").GetString());
|
|
||||||
Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GitHub_bridge_recovery_hatch_is_rejected_inside_dispatched_turn()
|
|
||||||
{
|
|
||||||
var result = await RunNodeAsync([
|
|
||||||
"tools/bifrost-bridge.mjs",
|
|
||||||
"github-pr-comment",
|
|
||||||
"--repo-root", RepoRoot,
|
|
||||||
"--identity", "nibu",
|
|
||||||
"--pr", "1",
|
|
||||||
"--content", "test comment",
|
|
||||||
"--target-repository-full-name", "GameCult/Bifrost",
|
|
||||||
"--allow-ungated-github", "true",
|
|
||||||
"--dry-run", "true",
|
|
||||||
], new Dictionary<string, string?>
|
|
||||||
{
|
|
||||||
["BIFROST_LOCK_RECOVERY_HATCHES"] = "true",
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.NotEqual(0, result.ExitCode);
|
Assert.NotEqual(0, result.ExitCode);
|
||||||
Assert.Contains("cannot use --allow-ungated-github", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("cannot use --allow-ungated-github", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
@ -70,6 +66,7 @@ public sealed class BridgeCliTests
|
||||||
public async Task GitHub_pr_comment_returns_concrete_github_receipt()
|
public async Task GitHub_pr_comment_returns_concrete_github_receipt()
|
||||||
{
|
{
|
||||||
var fakeToolsDir = Path.Combine(Path.GetTempPath(), $"bifrost-gh-{Guid.NewGuid():N}");
|
var fakeToolsDir = Path.Combine(Path.GetTempPath(), $"bifrost-gh-{Guid.NewGuid():N}");
|
||||||
|
var receiptStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-crossing-receipts-{Guid.NewGuid():N}.cc");
|
||||||
Directory.CreateDirectory(fakeToolsDir);
|
Directory.CreateDirectory(fakeToolsDir);
|
||||||
var fakeGhPath = Path.Combine(fakeToolsDir, "fake-gh.js");
|
var fakeGhPath = Path.Combine(fakeToolsDir, "fake-gh.js");
|
||||||
await File.WriteAllTextAsync(fakeGhPath, """
|
await File.WriteAllTextAsync(fakeGhPath, """
|
||||||
|
|
@ -97,8 +94,8 @@ console.log(JSON.stringify({
|
||||||
"--epiphany-lane-id", "hands-publication",
|
"--epiphany-lane-id", "hands-publication",
|
||||||
"--epiphany-agent-identity", "huginn",
|
"--epiphany-agent-identity", "huginn",
|
||||||
"--heimdall-capability-ref", "heimdall-capability-abc",
|
"--heimdall-capability-ref", "heimdall-capability-abc",
|
||||||
"--allow-ungated-github", "true",
|
"--receipt-store", receiptStorePath,
|
||||||
]);
|
], TypedCommandEnv());
|
||||||
|
|
||||||
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
|
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
|
||||||
using var payload = JsonDocument.Parse(result.Stdout);
|
using var payload = JsonDocument.Parse(result.Stdout);
|
||||||
|
|
@ -114,6 +111,57 @@ console.log(JSON.stringify({
|
||||||
Assert.Equal("hands-publication", provenance.GetProperty("epiphanyLaneId").GetString());
|
Assert.Equal("hands-publication", provenance.GetProperty("epiphanyLaneId").GetString());
|
||||||
Assert.Equal("huginn", provenance.GetProperty("epiphanyAgentIdentity").GetString());
|
Assert.Equal("huginn", provenance.GetProperty("epiphanyAgentIdentity").GetString());
|
||||||
Assert.Equal("heimdall-capability-abc", provenance.GetProperty("heimdallCapabilityRef").GetString());
|
Assert.Equal("heimdall-capability-abc", provenance.GetProperty("heimdallCapabilityRef").GetString());
|
||||||
|
|
||||||
|
var crossingReceiptId = payload.RootElement.GetProperty("crossingReceiptId").GetString();
|
||||||
|
Assert.Equal("crossing_test-cultmesh-command", crossingReceiptId);
|
||||||
|
|
||||||
|
using var receipts = await ReadCrossingReceiptsAsync(receiptStorePath);
|
||||||
|
var receipt = Assert.Single(receipts.RootElement.EnumerateArray());
|
||||||
|
Assert.Equal("bifrost.crossing_receipt.v1", receipt.GetProperty("schemaVersion").GetString());
|
||||||
|
Assert.Equal("github.pr_comment", receipt.GetProperty("crossingKind").GetString());
|
||||||
|
Assert.Equal("completed", receipt.GetProperty("status").GetString());
|
||||||
|
Assert.True(receipt.GetProperty("ok").GetBoolean());
|
||||||
|
Assert.Equal("repo-work-public-proof-huginn", receipt.GetProperty("source").GetProperty("id").GetString());
|
||||||
|
Assert.Equal("bifrost_publication_gate", receipt.GetProperty("authority").GetProperty("authorityRef").GetString());
|
||||||
|
Assert.Equal("heimdall-capability-abc", receipt.GetProperty("authority").GetProperty("heimdallCapabilityRef").GetString());
|
||||||
|
Assert.Equal("https://github.com/GameCult/Bifrost/pull/1#issuecomment-123456", receipt.GetProperty("externalReceipt").GetProperty("url").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GitHub_pr_comment_writes_failed_crossing_receipt_on_actuator_failure()
|
||||||
|
{
|
||||||
|
var fakeToolsDir = Path.Combine(Path.GetTempPath(), $"bifrost-gh-{Guid.NewGuid():N}");
|
||||||
|
var receiptStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-crossing-receipts-{Guid.NewGuid():N}.cc");
|
||||||
|
Directory.CreateDirectory(fakeToolsDir);
|
||||||
|
var fakeGhPath = Path.Combine(fakeToolsDir, "fake-gh.js");
|
||||||
|
await File.WriteAllTextAsync(fakeGhPath, """
|
||||||
|
console.error("synthetic gh failure");
|
||||||
|
process.exit(42);
|
||||||
|
""", Encoding.UTF8);
|
||||||
|
|
||||||
|
var result = await RunNodeAsync([
|
||||||
|
"tools/bifrost-bridge.mjs",
|
||||||
|
"github-pr-comment",
|
||||||
|
"--repo-root", RepoRoot,
|
||||||
|
"--identity", "nibu",
|
||||||
|
"--pr", "1",
|
||||||
|
"--content", "test comment",
|
||||||
|
"--target-repository-full-name", "GameCult/Bifrost",
|
||||||
|
"--gh-executable", "node",
|
||||||
|
"--gh-exec-args", fakeGhPath,
|
||||||
|
"--source-kind", "epiphany_repo_work",
|
||||||
|
"--source-id", "repo-work-public-proof-huginn",
|
||||||
|
"--authority-ref", "bifrost_publication_gate",
|
||||||
|
"--receipt-store", receiptStorePath,
|
||||||
|
], TypedCommandEnv());
|
||||||
|
|
||||||
|
Assert.NotEqual(0, result.ExitCode);
|
||||||
|
using var receipts = await ReadCrossingReceiptsAsync(receiptStorePath);
|
||||||
|
var receipt = Assert.Single(receipts.RootElement.EnumerateArray());
|
||||||
|
Assert.Equal("github.pr_comment", receipt.GetProperty("crossingKind").GetString());
|
||||||
|
Assert.Equal("failed", receipt.GetProperty("status").GetString());
|
||||||
|
Assert.False(receipt.GetProperty("ok").GetBoolean());
|
||||||
|
Assert.Contains("synthetic gh failure", receipt.GetProperty("error").GetProperty("message").GetString(), StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -128,11 +176,28 @@ console.log(JSON.stringify({
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Assert.NotEqual(0, result.ExitCode);
|
Assert.NotEqual(0, result.ExitCode);
|
||||||
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("CultMesh command id", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Discord_bridge_command_allows_explicit_operator_recovery_dry_run()
|
public async Task Discord_bridge_command_allows_bifrost_receipt_gate_dry_run()
|
||||||
|
{
|
||||||
|
var result = await RunNodeAsync([
|
||||||
|
"tools/bifrost-bridge.mjs",
|
||||||
|
"discord-post",
|
||||||
|
"--channel-id", "1501196543150264332",
|
||||||
|
"--content", "test message",
|
||||||
|
"--dry-run", "true",
|
||||||
|
], TypedCommandEnv());
|
||||||
|
|
||||||
|
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
|
||||||
|
using var payload = JsonDocument.Parse(result.Stdout);
|
||||||
|
Assert.Equal("discord-post", payload.RootElement.GetProperty("action").GetString());
|
||||||
|
Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Discord_bridge_recovery_hatch_is_rejected()
|
||||||
{
|
{
|
||||||
var result = await RunNodeAsync([
|
var result = await RunNodeAsync([
|
||||||
"tools/bifrost-bridge.mjs",
|
"tools/bifrost-bridge.mjs",
|
||||||
|
|
@ -143,29 +208,55 @@ console.log(JSON.stringify({
|
||||||
"--dry-run", "true",
|
"--dry-run", "true",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
|
Assert.NotEqual(0, result.ExitCode);
|
||||||
using var payload = JsonDocument.Parse(result.Stdout);
|
Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.Equal("discord-post", payload.RootElement.GetProperty("action").GetString());
|
|
||||||
Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Discord_bridge_recovery_hatch_is_rejected_inside_dispatched_turn()
|
public async Task Discord_cultmesh_command_receipt_references_canonical_crossing_receipt()
|
||||||
{
|
{
|
||||||
var result = await RunNodeAsync([
|
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-provider-store-{Guid.NewGuid():N}.cc");
|
||||||
"tools/bifrost-bridge.mjs",
|
var receiptStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-crossing-receipts-{Guid.NewGuid():N}.cc");
|
||||||
"discord-post",
|
var commandId = $"discord-command-{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
var written = await RunNodeAsync([
|
||||||
|
"tools/cultmesh-bridge-commands.mjs",
|
||||||
|
"write-smoke",
|
||||||
|
"--store", storePath,
|
||||||
|
"--command-id", commandId,
|
||||||
"--channel-id", "1501196543150264332",
|
"--channel-id", "1501196543150264332",
|
||||||
"--content", "test message",
|
"--content", "test message",
|
||||||
"--allow-unreceipted-activity", "true",
|
]);
|
||||||
"--dry-run", "true",
|
Assert.Equal(0, written.ExitCode);
|
||||||
], new Dictionary<string, string?>
|
|
||||||
{
|
|
||||||
["BIFROST_LOCK_RECOVERY_HATCHES"] = "true",
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.NotEqual(0, result.ExitCode);
|
var processed = await RunNodeAsync([
|
||||||
Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
"tools/cultmesh-bridge-commands.mjs",
|
||||||
|
"process",
|
||||||
|
"--store", storePath,
|
||||||
|
"--receipt-store", receiptStorePath,
|
||||||
|
"--command-id", commandId,
|
||||||
|
]);
|
||||||
|
Assert.Equal(0, processed.ExitCode);
|
||||||
|
using var processedPayload = JsonDocument.Parse(processed.Stdout);
|
||||||
|
Assert.False(processedPayload.RootElement.GetProperty("ok").GetBoolean());
|
||||||
|
|
||||||
|
var surfaceReceiptResult = await RunNodeAsync([
|
||||||
|
"tools/cultmesh-bridge-commands.mjs",
|
||||||
|
"receipt",
|
||||||
|
"--store", storePath,
|
||||||
|
"--command-id", commandId,
|
||||||
|
]);
|
||||||
|
Assert.Equal(0, surfaceReceiptResult.ExitCode);
|
||||||
|
using var surfaceReceiptPayload = JsonDocument.Parse(surfaceReceiptResult.Stdout);
|
||||||
|
var surfaceReceipt = surfaceReceiptPayload.RootElement.GetProperty("receipt");
|
||||||
|
Assert.Equal("failed", surfaceReceipt.GetProperty("status").GetString());
|
||||||
|
Assert.Equal($"crossing_{commandId}", surfaceReceipt.GetProperty("canonicalReceiptId").GetString());
|
||||||
|
|
||||||
|
using var receipts = await ReadCrossingReceiptsAsync(receiptStorePath);
|
||||||
|
var receipt = Assert.Single(receipts.RootElement.EnumerateArray());
|
||||||
|
Assert.Equal("discord.post", receipt.GetProperty("crossingKind").GetString());
|
||||||
|
Assert.Equal("failed", receipt.GetProperty("status").GetString());
|
||||||
|
Assert.Equal($"crossing_{commandId}", receipt.GetProperty("receiptId").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -180,7 +271,7 @@ console.log(JSON.stringify({
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Assert.NotEqual(0, result.ExitCode);
|
Assert.NotEqual(0, result.ExitCode);
|
||||||
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("CultMesh command id", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -197,11 +288,11 @@ console.log(JSON.stringify({
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Assert.NotEqual(0, result.ExitCode);
|
Assert.NotEqual(0, result.ExitCode);
|
||||||
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("CultMesh command id", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Other_bridge_request_allows_explicit_operator_recovery_dry_run()
|
public async Task Other_bridge_request_allows_bifrost_receipt_gate_dry_run()
|
||||||
{
|
{
|
||||||
var result = await RunNodeAsync([
|
var result = await RunNodeAsync([
|
||||||
"tools/bifrost-bridge.mjs",
|
"tools/bifrost-bridge.mjs",
|
||||||
|
|
@ -211,9 +302,8 @@ console.log(JSON.stringify({
|
||||||
"--target-locator", "future://surface/thread/1",
|
"--target-locator", "future://surface/thread/1",
|
||||||
"--content", "test message",
|
"--content", "test message",
|
||||||
"--heimdall-capability-ref", "heimdall:future-surface:capability:epiphany-persona",
|
"--heimdall-capability-ref", "heimdall:future-surface:capability:epiphany-persona",
|
||||||
"--allow-unreceipted-activity", "true",
|
|
||||||
"--dry-run", "true",
|
"--dry-run", "true",
|
||||||
]);
|
], TypedCommandEnv());
|
||||||
|
|
||||||
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
|
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
|
||||||
using var payload = JsonDocument.Parse(result.Stdout);
|
using var payload = JsonDocument.Parse(result.Stdout);
|
||||||
|
|
@ -227,7 +317,7 @@ console.log(JSON.stringify({
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Other_bridge_request_recovery_hatch_is_rejected_inside_dispatched_turn()
|
public async Task Other_bridge_request_recovery_hatch_is_rejected()
|
||||||
{
|
{
|
||||||
var result = await RunNodeAsync([
|
var result = await RunNodeAsync([
|
||||||
"tools/bifrost-bridge.mjs",
|
"tools/bifrost-bridge.mjs",
|
||||||
|
|
@ -238,10 +328,7 @@ console.log(JSON.stringify({
|
||||||
"--content", "test message",
|
"--content", "test message",
|
||||||
"--allow-unreceipted-activity", "true",
|
"--allow-unreceipted-activity", "true",
|
||||||
"--dry-run", "true",
|
"--dry-run", "true",
|
||||||
], new Dictionary<string, string?>
|
]);
|
||||||
{
|
|
||||||
["BIFROST_LOCK_RECOVERY_HATCHES"] = "true",
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.NotEqual(0, result.ExitCode);
|
Assert.NotEqual(0, result.ExitCode);
|
||||||
Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
@ -259,11 +346,11 @@ console.log(JSON.stringify({
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Assert.NotEqual(0, result.ExitCode);
|
Assert.NotEqual(0, result.ExitCode);
|
||||||
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("CultMesh command id", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Agent_transport_mutation_fails_closed_without_bifrost_receipt_gate()
|
public async Task Agent_transport_mutation_rejects_removed_http_bridge_config()
|
||||||
{
|
{
|
||||||
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.cc");
|
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.cc");
|
||||||
var result = await RunNodeAsync([
|
var result = await RunNodeAsync([
|
||||||
|
|
@ -275,10 +362,10 @@ console.log(JSON.stringify({
|
||||||
"--request", "Do the thing.",
|
"--request", "Do the thing.",
|
||||||
"--store", storePath,
|
"--store", storePath,
|
||||||
"--allow-unmirrored", "true",
|
"--allow-unmirrored", "true",
|
||||||
]);
|
], RemovedHttpBridgeEnv());
|
||||||
|
|
||||||
Assert.NotEqual(0, result.ExitCode);
|
Assert.NotEqual(0, result.ExitCode);
|
||||||
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("were removed", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -293,15 +380,42 @@ console.log(JSON.stringify({
|
||||||
"--title", "Discord inheritance guard",
|
"--title", "Discord inheritance guard",
|
||||||
"--request", "Do not post fixture noise.",
|
"--request", "Do not post fixture noise.",
|
||||||
"--store", storePath,
|
"--store", storePath,
|
||||||
"--allow-unreceipted-activity", "true",
|
], TypedCommandEnv());
|
||||||
]);
|
|
||||||
|
|
||||||
Assert.NotEqual(0, result.ExitCode);
|
Assert.NotEqual(0, result.ExitCode);
|
||||||
Assert.Contains("require a Discord mirror", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("require a Discord mirror", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Agent_transport_mutation_allows_explicit_operator_recovery()
|
public async Task Agent_transport_mutation_allows_bifrost_receipt_gate()
|
||||||
|
{
|
||||||
|
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.cc");
|
||||||
|
var receiptStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-crossing-receipts-{Guid.NewGuid():N}.cc");
|
||||||
|
var result = await RunNodeAsync([
|
||||||
|
"tools/agent-transport.mjs",
|
||||||
|
"enqueue",
|
||||||
|
"--repo", "Bifrost",
|
||||||
|
"--agent", "nibu",
|
||||||
|
"--title", "Receipt gate recovery",
|
||||||
|
"--request", "Do the thing.",
|
||||||
|
"--store", storePath,
|
||||||
|
"--receipt-store", receiptStorePath,
|
||||||
|
"--allow-unmirrored", "true",
|
||||||
|
], TypedCommandEnv());
|
||||||
|
|
||||||
|
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
|
||||||
|
using var payload = JsonDocument.Parse(result.Stdout);
|
||||||
|
Assert.Equal("queued", payload.RootElement.GetProperty("status").GetString());
|
||||||
|
|
||||||
|
using var receipts = await ReadCrossingReceiptsAsync(receiptStorePath);
|
||||||
|
var receipt = Assert.Single(receipts.RootElement.EnumerateArray());
|
||||||
|
Assert.Equal("agent_transport.request", receipt.GetProperty("crossingKind").GetString());
|
||||||
|
Assert.Equal("completed", receipt.GetProperty("status").GetString());
|
||||||
|
Assert.Equal(payload.RootElement.GetProperty("id").GetString(), receipt.GetProperty("source").GetProperty("requestId").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Agent_transport_recovery_hatch_is_rejected()
|
||||||
{
|
{
|
||||||
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.cc");
|
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.cc");
|
||||||
var result = await RunNodeAsync([
|
var result = await RunNodeAsync([
|
||||||
|
|
@ -316,36 +430,12 @@ console.log(JSON.stringify({
|
||||||
"--allow-unreceipted-activity", "true",
|
"--allow-unreceipted-activity", "true",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
|
|
||||||
using var payload = JsonDocument.Parse(result.Stdout);
|
|
||||||
Assert.Equal("queued", payload.RootElement.GetProperty("status").GetString());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Agent_transport_recovery_hatch_is_rejected_inside_dispatched_turn()
|
|
||||||
{
|
|
||||||
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.cc");
|
|
||||||
var result = await RunNodeAsync([
|
|
||||||
"tools/agent-transport.mjs",
|
|
||||||
"enqueue",
|
|
||||||
"--repo", "Bifrost",
|
|
||||||
"--agent", "nibu",
|
|
||||||
"--title", "Receipt gate recovery",
|
|
||||||
"--request", "Do the thing.",
|
|
||||||
"--store", storePath,
|
|
||||||
"--allow-unmirrored", "true",
|
|
||||||
"--allow-unreceipted-activity", "true",
|
|
||||||
], new Dictionary<string, string?>
|
|
||||||
{
|
|
||||||
["BIFROST_LOCK_RECOVERY_HATCHES"] = "true",
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.NotEqual(0, result.ExitCode);
|
Assert.NotEqual(0, result.ExitCode);
|
||||||
Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Agent_transport_apply_snapshot_fails_closed_without_bifrost_receipt_gate()
|
public async Task Agent_transport_apply_snapshot_rejects_removed_http_bridge_config()
|
||||||
{
|
{
|
||||||
var sourceStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-source-{Guid.NewGuid():N}.cc");
|
var sourceStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-source-{Guid.NewGuid():N}.cc");
|
||||||
var targetStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-target-{Guid.NewGuid():N}.cc");
|
var targetStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-target-{Guid.NewGuid():N}.cc");
|
||||||
|
|
@ -360,8 +450,7 @@ console.log(JSON.stringify({
|
||||||
"--request", "Do the thing.",
|
"--request", "Do the thing.",
|
||||||
"--store", sourceStorePath,
|
"--store", sourceStorePath,
|
||||||
"--allow-unmirrored", "true",
|
"--allow-unmirrored", "true",
|
||||||
"--allow-unreceipted-activity", "true",
|
], TypedCommandEnv());
|
||||||
]);
|
|
||||||
|
|
||||||
await RunNodeAsync([
|
await RunNodeAsync([
|
||||||
"tools/agent-transport.mjs",
|
"tools/agent-transport.mjs",
|
||||||
|
|
@ -375,14 +464,14 @@ console.log(JSON.stringify({
|
||||||
"apply-snapshot",
|
"apply-snapshot",
|
||||||
"--store", targetStorePath,
|
"--store", targetStorePath,
|
||||||
"--in", snapshotPath,
|
"--in", snapshotPath,
|
||||||
]);
|
], RemovedHttpBridgeEnv());
|
||||||
|
|
||||||
Assert.NotEqual(0, result.ExitCode);
|
Assert.NotEqual(0, result.ExitCode);
|
||||||
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("were removed", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Agent_transport_apply_snapshot_allows_explicit_operator_recovery()
|
public async Task Agent_transport_apply_snapshot_allows_bifrost_receipt_gate()
|
||||||
{
|
{
|
||||||
var sourceStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-source-{Guid.NewGuid():N}.cc");
|
var sourceStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-source-{Guid.NewGuid():N}.cc");
|
||||||
var targetStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-target-{Guid.NewGuid():N}.cc");
|
var targetStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-target-{Guid.NewGuid():N}.cc");
|
||||||
|
|
@ -397,8 +486,7 @@ console.log(JSON.stringify({
|
||||||
"--request", "Do the thing.",
|
"--request", "Do the thing.",
|
||||||
"--store", sourceStorePath,
|
"--store", sourceStorePath,
|
||||||
"--allow-unmirrored", "true",
|
"--allow-unmirrored", "true",
|
||||||
"--allow-unreceipted-activity", "true",
|
], TypedCommandEnv());
|
||||||
]);
|
|
||||||
|
|
||||||
await RunNodeAsync([
|
await RunNodeAsync([
|
||||||
"tools/agent-transport.mjs",
|
"tools/agent-transport.mjs",
|
||||||
|
|
@ -412,8 +500,7 @@ console.log(JSON.stringify({
|
||||||
"apply-snapshot",
|
"apply-snapshot",
|
||||||
"--store", targetStorePath,
|
"--store", targetStorePath,
|
||||||
"--in", snapshotPath,
|
"--in", snapshotPath,
|
||||||
"--allow-unreceipted-activity", "true",
|
], TypedCommandEnv());
|
||||||
]);
|
|
||||||
|
|
||||||
Assert.Equal(0, result.ExitCode);
|
Assert.Equal(0, result.ExitCode);
|
||||||
|
|
||||||
|
|
@ -431,7 +518,7 @@ console.log(JSON.stringify({
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Governance_mutation_fails_closed_without_bifrost_receipt_gate()
|
public async Task Governance_mutation_rejects_removed_http_bridge_config()
|
||||||
{
|
{
|
||||||
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-governance-{Guid.NewGuid():N}.cc");
|
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-governance-{Guid.NewGuid():N}.cc");
|
||||||
var result = await RunNodeAsync([
|
var result = await RunNodeAsync([
|
||||||
|
|
@ -443,16 +530,17 @@ console.log(JSON.stringify({
|
||||||
"--summary", "A topic body.",
|
"--summary", "A topic body.",
|
||||||
"--store", storePath,
|
"--store", storePath,
|
||||||
"--allow-unmirrored", "true",
|
"--allow-unmirrored", "true",
|
||||||
]);
|
], RemovedHttpBridgeEnv());
|
||||||
|
|
||||||
Assert.NotEqual(0, result.ExitCode);
|
Assert.NotEqual(0, result.ExitCode);
|
||||||
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("were removed", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Governance_mutation_allows_explicit_operator_recovery()
|
public async Task Governance_mutation_allows_bifrost_receipt_gate()
|
||||||
{
|
{
|
||||||
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-governance-{Guid.NewGuid():N}.cc");
|
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-governance-{Guid.NewGuid():N}.cc");
|
||||||
|
var receiptStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-crossing-receipts-{Guid.NewGuid():N}.cc");
|
||||||
var result = await RunNodeAsync([
|
var result = await RunNodeAsync([
|
||||||
"tools/governance-threads.mjs",
|
"tools/governance-threads.mjs",
|
||||||
"open",
|
"open",
|
||||||
|
|
@ -461,17 +549,23 @@ console.log(JSON.stringify({
|
||||||
"--title", "Governance receipt recovery",
|
"--title", "Governance receipt recovery",
|
||||||
"--summary", "A topic body.",
|
"--summary", "A topic body.",
|
||||||
"--store", storePath,
|
"--store", storePath,
|
||||||
|
"--receipt-store", receiptStorePath,
|
||||||
"--allow-unmirrored", "true",
|
"--allow-unmirrored", "true",
|
||||||
"--allow-unreceipted-activity", "true",
|
], TypedCommandEnv());
|
||||||
]);
|
|
||||||
|
|
||||||
Assert.Equal(0, result.ExitCode);
|
Assert.Equal(0, result.ExitCode);
|
||||||
using var payload = JsonDocument.Parse(result.Stdout);
|
using var payload = JsonDocument.Parse(result.Stdout);
|
||||||
Assert.Equal("open", payload.RootElement.GetProperty("status").GetString());
|
Assert.Equal("open", payload.RootElement.GetProperty("status").GetString());
|
||||||
|
|
||||||
|
using var receipts = await ReadCrossingReceiptsAsync(receiptStorePath);
|
||||||
|
var receipt = Assert.Single(receipts.RootElement.EnumerateArray());
|
||||||
|
Assert.Equal("governance.topic", receipt.GetProperty("crossingKind").GetString());
|
||||||
|
Assert.Equal("completed", receipt.GetProperty("status").GetString());
|
||||||
|
Assert.Equal(payload.RootElement.GetProperty("id").GetString(), receipt.GetProperty("source").GetProperty("topicId").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Governance_recovery_hatch_is_rejected_inside_dispatched_turn()
|
public async Task Governance_recovery_hatch_is_rejected()
|
||||||
{
|
{
|
||||||
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-governance-{Guid.NewGuid():N}.cc");
|
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-governance-{Guid.NewGuid():N}.cc");
|
||||||
var result = await RunNodeAsync([
|
var result = await RunNodeAsync([
|
||||||
|
|
@ -484,17 +578,14 @@ console.log(JSON.stringify({
|
||||||
"--store", storePath,
|
"--store", storePath,
|
||||||
"--allow-unmirrored", "true",
|
"--allow-unmirrored", "true",
|
||||||
"--allow-unreceipted-activity", "true",
|
"--allow-unreceipted-activity", "true",
|
||||||
], new Dictionary<string, string?>
|
]);
|
||||||
{
|
|
||||||
["BIFROST_LOCK_RECOVERY_HATCHES"] = "true",
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.NotEqual(0, result.ExitCode);
|
Assert.NotEqual(0, result.ExitCode);
|
||||||
Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Dispatch_worker_fails_closed_without_bifrost_receipt_gate()
|
public async Task Dispatch_worker_rejects_removed_http_bridge_config()
|
||||||
{
|
{
|
||||||
var tempDir = Path.Combine(Path.GetTempPath(), $"bifrost-dispatch-{Guid.NewGuid():N}");
|
var tempDir = Path.Combine(Path.GetTempPath(), $"bifrost-dispatch-{Guid.NewGuid():N}");
|
||||||
Directory.CreateDirectory(tempDir);
|
Directory.CreateDirectory(tempDir);
|
||||||
|
|
@ -525,8 +616,8 @@ console.log(JSON.stringify({
|
||||||
"claimedAt": "2026-06-17T00:00:00Z",
|
"claimedAt": "2026-06-17T00:00:00Z",
|
||||||
"closedAt": ""
|
"closedAt": ""
|
||||||
}
|
}
|
||||||
""", Encoding.UTF8);
|
""", new UTF8Encoding(false));
|
||||||
await File.WriteAllTextAsync(promptPath, "Hello", Encoding.UTF8);
|
await File.WriteAllTextAsync(promptPath, "Hello", new UTF8Encoding(false));
|
||||||
|
|
||||||
var result = await RunNodeAsync([
|
var result = await RunNodeAsync([
|
||||||
"tools/dispatch-agent-requests.mjs",
|
"tools/dispatch-agent-requests.mjs",
|
||||||
|
|
@ -536,10 +627,10 @@ console.log(JSON.stringify({
|
||||||
"--prompt-file", promptPath,
|
"--prompt-file", promptPath,
|
||||||
"--log", logPath,
|
"--log", logPath,
|
||||||
"--launch-mode", "codex-exec",
|
"--launch-mode", "codex-exec",
|
||||||
]);
|
], RemovedHttpBridgeEnv());
|
||||||
|
|
||||||
Assert.NotEqual(0, result.ExitCode);
|
Assert.NotEqual(0, result.ExitCode);
|
||||||
Assert.Contains("requires BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("were removed", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -629,8 +720,7 @@ writeFileSync(process.env.DISPATCH_ENV_DUMP, JSON.stringify({
|
||||||
"--title", "Dispatch env seed",
|
"--title", "Dispatch env seed",
|
||||||
"--request", "Seed the live request lane.",
|
"--request", "Seed the live request lane.",
|
||||||
"--allow-unmirrored", "true",
|
"--allow-unmirrored", "true",
|
||||||
"--allow-unreceipted-activity", "true",
|
], TypedCommandEnv());
|
||||||
]);
|
|
||||||
|
|
||||||
var result = await RunNodeAsync([
|
var result = await RunNodeAsync([
|
||||||
"tools/dispatch-agent-requests.mjs",
|
"tools/dispatch-agent-requests.mjs",
|
||||||
|
|
@ -642,13 +732,12 @@ writeFileSync(process.env.DISPATCH_ENV_DUMP, JSON.stringify({
|
||||||
"--launch-mode", "codex-exec",
|
"--launch-mode", "codex-exec",
|
||||||
"--codex-executable", "node",
|
"--codex-executable", "node",
|
||||||
"--codex-exec-args", fakeCodexPath,
|
"--codex-exec-args", fakeCodexPath,
|
||||||
], new Dictionary<string, string?>
|
], TypedCommandEnv(new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
["BIFROST_ALLOW_UNRECEIPTED_ACTIVITY"] = "true",
|
|
||||||
["GH_TOKEN"] = "parent-gh-token",
|
["GH_TOKEN"] = "parent-gh-token",
|
||||||
["GITHUB_TOKEN"] = "parent-github-token",
|
["GITHUB_TOKEN"] = "parent-github-token",
|
||||||
["DISPATCH_ENV_DUMP"] = dumpPath,
|
["DISPATCH_ENV_DUMP"] = dumpPath,
|
||||||
});
|
}));
|
||||||
|
|
||||||
Assert.Equal(0, result.ExitCode);
|
Assert.Equal(0, result.ExitCode);
|
||||||
Assert.True(File.Exists(dumpPath), $"Dispatch env dump was not created. stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
|
Assert.True(File.Exists(dumpPath), $"Dispatch env dump was not created. stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
|
||||||
|
|
@ -771,8 +860,7 @@ rl.on("line", (line) => {
|
||||||
"--title", "Dispatch app-server seed",
|
"--title", "Dispatch app-server seed",
|
||||||
"--request", "Seed the live request lane.",
|
"--request", "Seed the live request lane.",
|
||||||
"--allow-unmirrored", "true",
|
"--allow-unmirrored", "true",
|
||||||
"--allow-unreceipted-activity", "true",
|
], TypedCommandEnv());
|
||||||
]);
|
|
||||||
|
|
||||||
var result = await RunNodeAsync([
|
var result = await RunNodeAsync([
|
||||||
"tools/dispatch-agent-requests.mjs",
|
"tools/dispatch-agent-requests.mjs",
|
||||||
|
|
@ -785,11 +873,10 @@ rl.on("line", (line) => {
|
||||||
"--codex-executable", "node",
|
"--codex-executable", "node",
|
||||||
"--codex-exec-args", fakeServerPath,
|
"--codex-exec-args", fakeServerPath,
|
||||||
"--no-discord", "true",
|
"--no-discord", "true",
|
||||||
], new Dictionary<string, string?>
|
], TypedCommandEnv(new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
["BIFROST_ALLOW_UNRECEIPTED_ACTIVITY"] = "true",
|
|
||||||
["DISPATCH_APP_SERVER_DUMP"] = dumpPath,
|
["DISPATCH_APP_SERVER_DUMP"] = dumpPath,
|
||||||
});
|
}));
|
||||||
|
|
||||||
Assert.Equal(0, result.ExitCode);
|
Assert.Equal(0, result.ExitCode);
|
||||||
Assert.True(File.Exists(dumpPath), "Fake app-server did not record dispatch policy.");
|
Assert.True(File.Exists(dumpPath), "Fake app-server did not record dispatch policy.");
|
||||||
|
|
@ -887,7 +974,7 @@ console.log("https://github.com/GameCult/Bifrost/pull/999");
|
||||||
""", Encoding.UTF8);
|
""", Encoding.UTF8);
|
||||||
|
|
||||||
var environment = BuildDispatchedGitGateEnv();
|
var environment = BuildDispatchedGitGateEnv();
|
||||||
environment["BIFROST_ALLOW_UNGATED_GITHUB"] = "true";
|
environment["BIFROST_CULTMESH_COMMAND_ID"] = "test-cultmesh-command";
|
||||||
environment["BIFROST_REAL_GH"] = ResolveExecutable("node");
|
environment["BIFROST_REAL_GH"] = ResolveExecutable("node");
|
||||||
environment["BIFROST_REAL_GH_ARGS"] = fakeGhPath;
|
environment["BIFROST_REAL_GH_ARGS"] = fakeGhPath;
|
||||||
|
|
||||||
|
|
@ -1058,6 +1145,56 @@ exit /b 0
|
||||||
private static string RepoRoot =>
|
private static string RepoRoot =>
|
||||||
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
|
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
|
||||||
|
|
||||||
|
private static Dictionary<string, string?> TypedCommandEnv(
|
||||||
|
IReadOnlyDictionary<string, string?>? extras = null)
|
||||||
|
{
|
||||||
|
var environment = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["BIFROST_CULTMESH_COMMAND_ID"] = "test-cultmesh-command",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (extras is null)
|
||||||
|
{
|
||||||
|
return environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in extras)
|
||||||
|
{
|
||||||
|
environment[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string?> RemovedHttpBridgeEnv()
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
["BIFROST_BRIDGE_BASE_URL"] = "http://127.0.0.1:1/",
|
||||||
|
["BIFROST_BRIDGE_TOKEN"] = "removed-test-token",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<JsonDocument> ReadCrossingReceiptsAsync(string storePath)
|
||||||
|
{
|
||||||
|
var result = await RunNodeAsync([
|
||||||
|
"-e",
|
||||||
|
"""
|
||||||
|
import('./tools/bifrost-crossing-documents.mjs')
|
||||||
|
.then(async module => {
|
||||||
|
const store = await module.openCrossingReceiptStore(process.argv[1]);
|
||||||
|
console.log(JSON.stringify(store.getAll()));
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
""",
|
||||||
|
storePath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
|
||||||
|
return JsonDocument.Parse(result.Stdout);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<ProcessResult> RunNodeAsync(string[] args, IReadOnlyDictionary<string, string?>? environmentOverrides = null)
|
private static async Task<ProcessResult> RunNodeAsync(string[] args, IReadOnlyDictionary<string, string?>? environmentOverrides = null)
|
||||||
{
|
{
|
||||||
using var process = new Process
|
using var process = new Process
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -19,16 +19,18 @@ public sealed class ProviderAdvertisementTests
|
||||||
using var payload = JsonDocument.Parse(result.Stdout);
|
using var payload = JsonDocument.Parse(result.Stdout);
|
||||||
var root = payload.RootElement;
|
var root = payload.RootElement;
|
||||||
|
|
||||||
AssertEndpoint(root, "bridge-action-ledger", "bifrost.bridge_action.v0", "bridge");
|
AssertEndpoint(root, "crossing-receipts", "bifrost.crossing_receipt.v1", "bridge");
|
||||||
AssertEndpoint(root, "patron-support-intake", "bifrost.patron_support_event.v0", "patronage");
|
AssertEndpoint(root, "patron-support-intake", "bifrost.patron_support_event.v0", "patronage");
|
||||||
AssertEndpoint(root, "github-webhooks", "bifrost.work_item.v0", "github");
|
AssertEndpoint(root, "github-webhooks", "bifrost.work_item.v0", "github");
|
||||||
AssertSchemaPurpose(root, "bifrost.bridge_action.v0", "current hosted governed crossing command witness");
|
AssertSchemaPurpose(root, "bifrost.crossing_receipt.v1", "canonical Bifrost-owned public crossing receipt");
|
||||||
AssertSchemaPurpose(root, "bifrost.bridge_receipt.v0", "current hosted governed crossing result witness");
|
|
||||||
AssertSchemaPurpose(root, "bifrost.patron_support_event.v0", "current hosted Heimdall-signed patron support fact consumed by Bifrost");
|
AssertSchemaPurpose(root, "bifrost.patron_support_event.v0", "current hosted Heimdall-signed patron support fact consumed by Bifrost");
|
||||||
|
AssertWitness(root, ".bifrost/bridge-receipts.cc", "bifrost.crossing_receipt.v1", "current");
|
||||||
AssertBoundaryCommand(root, "bridge", "open GitHub draft PRs and PR comments through Bifrost gate");
|
AssertBoundaryCommand(root, "bridge", "open GitHub draft PRs and PR comments through Bifrost gate");
|
||||||
|
AssertBoundaryCommand(root, "bridge", "own canonical bifrost.crossing_receipt.v1 documents for every crossing attempt");
|
||||||
AssertBoundaryCommand(root, "bridge", "post Discord messages and DMs through Bifrost gate with Heimdall-linked actor capability");
|
AssertBoundaryCommand(root, "bridge", "post Discord messages and DMs through Bifrost gate with Heimdall-linked actor capability");
|
||||||
AssertBoundaryCommand(root, "bridge", "record receipt-only future-surface requests with named-surface Heimdall capability matching before named actuators exist");
|
AssertBoundaryCommand(root, "bridge", "record receipt-only future-surface requests with named-surface Heimdall capability matching before named actuators exist");
|
||||||
AssertBoundaryCommand(root, "bridge", "post Persona-flaired Reddit organizing threads");
|
AssertBoundaryCommand(root, "bridge", "post Persona-flaired Reddit organizing threads");
|
||||||
|
AssertBoundaryForbiddenAuthority(root, "bridge", "surface-specific receipts cannot decide crossing completion");
|
||||||
AssertBoundaryCommand(root, "patron", "consume Heimdall-signed Patreon and PayPal support facts");
|
AssertBoundaryCommand(root, "patron", "consume Heimdall-signed Patreon and PayPal support facts");
|
||||||
AssertBoundaryForbiddenAuthority(root, "patron", "does not store Patreon or PayPal provider tokens");
|
AssertBoundaryForbiddenAuthority(root, "patron", "does not store Patreon or PayPal provider tokens");
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +72,7 @@ public sealed class ProviderAdvertisementTests
|
||||||
Assert.Contains("GitHub live", bridgeLine, StringComparison.Ordinal);
|
Assert.Contains("GitHub live", bridgeLine, StringComparison.Ordinal);
|
||||||
Assert.Contains("Discord prepared", bridgeLine, StringComparison.Ordinal);
|
Assert.Contains("Discord prepared", bridgeLine, StringComparison.Ordinal);
|
||||||
Assert.Contains("Reddit prepared", bridgeLine, StringComparison.Ordinal);
|
Assert.Contains("Reddit prepared", bridgeLine, StringComparison.Ordinal);
|
||||||
Assert.Contains("Other live", bridgeLine, StringComparison.Ordinal);
|
Assert.Contains("Other prepared", bridgeLine, StringComparison.Ordinal);
|
||||||
Assert.Contains("Patron live", bridgeLine, StringComparison.Ordinal);
|
Assert.Contains("Patron live", bridgeLine, StringComparison.Ordinal);
|
||||||
|
|
||||||
Assert.Equal("warn", bridgeMetric.GetProperty("props").GetProperty("tone").GetString());
|
Assert.Equal("warn", bridgeMetric.GetProperty("props").GetProperty("tone").GetString());
|
||||||
|
|
@ -89,6 +91,16 @@ public sealed class ProviderAdvertisementTests
|
||||||
|
|
||||||
Assert.Contains(readinessRows, row => row is not null && row.Contains("reddit: prepared", StringComparison.Ordinal));
|
Assert.Contains(readinessRows, row => row is not null && row.Contains("reddit: prepared", StringComparison.Ordinal));
|
||||||
Assert.Contains(readinessRows, row => row is not null && row.Contains("patron: live", StringComparison.Ordinal) && row.Contains("Bifrost stores no provider tokens", StringComparison.Ordinal));
|
Assert.Contains(readinessRows, row => row is not null && row.Contains("patron: live", StringComparison.Ordinal) && row.Contains("Bifrost stores no provider tokens", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
var receiptMetric = root
|
||||||
|
.GetProperty("surface")
|
||||||
|
.GetProperty("root")
|
||||||
|
.GetProperty("children")
|
||||||
|
.EnumerateArray()
|
||||||
|
.SelectMany(panel => panel.GetProperty("children").EnumerateArray())
|
||||||
|
.Single(node => node.GetProperty("id").GetString() == "metric-crossing-receipts");
|
||||||
|
|
||||||
|
Assert.Contains("total", receiptMetric.GetProperty("props").GetProperty("value").GetString(), StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AssertEndpoint(JsonElement root, string id, string schemaId, string lowering)
|
private static void AssertEndpoint(JsonElement root, string id, string schemaId, string lowering)
|
||||||
|
|
@ -108,6 +120,15 @@ public sealed class ProviderAdvertisementTests
|
||||||
Assert.Equal(purpose, schema.GetProperty("purpose").GetString());
|
Assert.Equal(purpose, schema.GetProperty("purpose").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void AssertWitness(JsonElement root, string path, string schemaId, string status)
|
||||||
|
{
|
||||||
|
var witness = root.GetProperty("witnesses")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Single(item => item.GetProperty("path").GetString() == path);
|
||||||
|
Assert.Equal(schemaId, witness.GetProperty("schemaIds").GetString());
|
||||||
|
Assert.Equal(status, witness.GetProperty("status").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
private static void AssertBoundaryCommand(JsonElement root, string area, string command)
|
private static void AssertBoundaryCommand(JsonElement root, string area, string command)
|
||||||
{
|
{
|
||||||
var boundary = Boundary(root, area);
|
var boundary = Boundary(root, area);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ import { existsSync, readFileSync } from "node:fs";
|
||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
commandIdFromOptions,
|
||||||
|
crossingProvenanceFromOptions,
|
||||||
|
writeCrossingReceipt,
|
||||||
|
} from "./bifrost-crossing-documents.mjs";
|
||||||
|
|
||||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = resolve(scriptDir, "..");
|
const repoRoot = resolve(scriptDir, "..");
|
||||||
|
|
@ -164,29 +169,23 @@ function isMutatingCommand(command) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureTransportReceiptGate(options) {
|
function ensureTransportReceiptGate(options) {
|
||||||
if (wantsUnreceiptedActivity(options) && process.env.BIFROST_LOCK_RECOVERY_HATCHES === "true") {
|
if (wantsUnreceiptedActivity(options)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Dispatched Bifrost work cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
|
"Bifrost request-lane mutations cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
|
||||||
"Request-lane mutations from a dispatched turn must keep Bifrost receipts.",
|
"Use the typed Bifrost CultCache/CultMesh request store instead of mutating off-book.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasBifrostBridgeConfig() || allowsUnreceiptedActivity(options)) {
|
if (hasRemovedHttpBridgeConfig()) {
|
||||||
return;
|
throw new Error(
|
||||||
|
"BIFROST_BRIDGE_BASE_URL / BIFROST_BRIDGE_TOKEN were removed. " +
|
||||||
|
"Bifrost request-lane receipts are written to the typed CultCache/CultMesh store.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
"Bifrost agent transport mutations require BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can keep request-lane receipts. " +
|
|
||||||
"Set both values, or use --allow-unreceipted-activity true only for explicit operator recovery.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasBifrostBridgeConfig() {
|
function hasRemovedHttpBridgeConfig() {
|
||||||
return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) && optionalString(process.env.BIFROST_BRIDGE_TOKEN));
|
return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) || optionalString(process.env.BIFROST_BRIDGE_TOKEN));
|
||||||
}
|
|
||||||
|
|
||||||
function allowsUnreceiptedActivity(options) {
|
|
||||||
return wantsUnreceiptedActivity(options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function wantsUnreceiptedActivity(options) {
|
function wantsUnreceiptedActivity(options) {
|
||||||
|
|
@ -236,7 +235,7 @@ async function enqueue(cache, options) {
|
||||||
status: request.status,
|
status: request.status,
|
||||||
actorName: request.createdByAgent ?? "system",
|
actorName: request.createdByAgent ?? "system",
|
||||||
note: "Update request queued.",
|
note: "Update request queued.",
|
||||||
});
|
}, options);
|
||||||
printJson(request);
|
printJson(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,7 +292,7 @@ function allowsUnmirrored(options) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function bridgeRecoveryArgs(options) {
|
function bridgeRecoveryArgs(options) {
|
||||||
return allowsUnreceiptedActivity(options) ? ["--allow-unreceipted-activity", "true"] : [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRequestMirrorFallback(request) {
|
function renderRequestMirrorFallback(request) {
|
||||||
|
|
@ -371,7 +370,7 @@ async function claim(cache, options) {
|
||||||
status: claimed.status,
|
status: claimed.status,
|
||||||
actorName: claimed.claimedByAgent ?? claimed.targetAgentIdentity ?? "codex",
|
actorName: claimed.claimedByAgent ?? claimed.targetAgentIdentity ?? "codex",
|
||||||
note: `Request claimed for ${claimed.targetRepoName}.`,
|
note: `Request claimed for ${claimed.targetRepoName}.`,
|
||||||
});
|
}, options);
|
||||||
printJson(claimed);
|
printJson(claimed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -401,7 +400,7 @@ async function closeRequest(cache, options) {
|
||||||
status: closed.status,
|
status: closed.status,
|
||||||
actorName: current.claimedByAgent ?? current.targetAgentIdentity ?? "system",
|
actorName: current.claimedByAgent ?? current.targetAgentIdentity ?? "system",
|
||||||
note: closed.closeNote || `Request closed as ${closed.status}.`,
|
note: closed.closeNote || `Request closed as ${closed.status}.`,
|
||||||
});
|
}, options);
|
||||||
printJson(closed);
|
printJson(closed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -431,7 +430,7 @@ async function releaseRequest(cache, options) {
|
||||||
status: released.status,
|
status: released.status,
|
||||||
actorName: current.claimedByAgent ?? current.targetAgentIdentity ?? "system",
|
actorName: current.claimedByAgent ?? current.targetAgentIdentity ?? "system",
|
||||||
note: released.closeNote || "Claim released back to the queue.",
|
note: released.closeNote || "Claim released back to the queue.",
|
||||||
});
|
}, options);
|
||||||
printJson(released);
|
printJson(released);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,7 +473,7 @@ async function applySnapshot(cache, options) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const { request, receipt } of receipts) {
|
for (const { request, receipt } of receipts) {
|
||||||
await recordTransportReceiptOrThrow(request, receipt);
|
await recordTransportReceiptOrThrow(request, receipt, options);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await restoreRequests(cache, beforeRequests, afterRequests);
|
await restoreRequests(cache, beforeRequests, afterRequests);
|
||||||
|
|
@ -582,36 +581,53 @@ function stableJson(value) {
|
||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recordTransportReceiptOrThrow(request, receipt) {
|
async function recordTransportReceiptOrThrow(request, receipt, options = {}) {
|
||||||
const baseUrl = optionalString(process.env.BIFROST_BRIDGE_BASE_URL);
|
const commandId = commandIdFromOptions(options) || `agent-transport:${request.id}:${receipt.activityKind}`;
|
||||||
const token = optionalString(process.env.BIFROST_BRIDGE_TOKEN);
|
const provenance = crossingProvenanceFromOptions({
|
||||||
if (!baseUrl || !token) {
|
...options,
|
||||||
return;
|
"cultmesh-command-id": commandId,
|
||||||
}
|
"source-kind": request.sourceKind || "bifrost.agent-transport.update-request",
|
||||||
|
"source-id": request.id,
|
||||||
const response = await fetch(new URL("/transport/receipts", ensureTrailingSlash(baseUrl)), {
|
"request-id": request.id,
|
||||||
method: "POST",
|
"authority-ref": "bifrost_agent_transport",
|
||||||
headers: {
|
identity: receipt.actorName,
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Bifrost-Bridge-Token": token,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
requestId: request.id,
|
|
||||||
title: request.title,
|
|
||||||
targetRepoName: request.targetRepoName,
|
|
||||||
targetRepositoryFullName: request.targetRepositoryFullName ?? "",
|
|
||||||
targetAgentIdentity: request.targetAgentIdentity ?? "",
|
|
||||||
activityKind: receipt.activityKind,
|
|
||||||
status: receipt.status,
|
|
||||||
actorName: receipt.actorName,
|
|
||||||
note: receipt.note,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
const now = new Date().toISOString();
|
||||||
const text = await response.text();
|
await writeCrossingReceipt({
|
||||||
if (response.status !== 202) {
|
receiptId: `crossing_${slugifyReceiptPart(commandId)}_${slugifyReceiptPart(receipt.activityKind)}`,
|
||||||
throw new Error(`Bifrost transport receipt failed with ${response.status}: ${text}`);
|
commandId,
|
||||||
}
|
crossingKind: "agent_transport.request",
|
||||||
|
status: request.status === "cancelled" ? "cancelled" : "completed",
|
||||||
|
ok: true,
|
||||||
|
requestedAt: request.createdAt || now,
|
||||||
|
startedAt: request.updatedAt || now,
|
||||||
|
completedAt: now,
|
||||||
|
actor: {
|
||||||
|
kind: "agent",
|
||||||
|
name: receipt.actorName || "system",
|
||||||
|
bifrostIdentity: receipt.actorName || "system",
|
||||||
|
},
|
||||||
|
source: provenance.source,
|
||||||
|
authority: provenance.authority,
|
||||||
|
epiphany: provenance.epiphany,
|
||||||
|
target: {
|
||||||
|
surface: "agent-transport",
|
||||||
|
locator: request.id,
|
||||||
|
repositoryFullName: request.targetRepositoryFullName || request.targetRepoName,
|
||||||
|
requestId: request.id,
|
||||||
|
status: request.status,
|
||||||
|
},
|
||||||
|
externalReceipt: {
|
||||||
|
id: request.id,
|
||||||
|
transport: "cultcache",
|
||||||
|
url: "",
|
||||||
|
},
|
||||||
|
surfaceReceipt: {
|
||||||
|
activityKind: receipt.activityKind,
|
||||||
|
note: receipt.note || "",
|
||||||
|
requestStatus: receipt.status,
|
||||||
|
},
|
||||||
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
function filteredRequests(cache, options) {
|
function filteredRequests(cache, options) {
|
||||||
|
|
@ -781,6 +797,15 @@ function equalsIgnoreCase(left, right) {
|
||||||
&& left.toLowerCase() === right.toLowerCase();
|
&& left.toLowerCase() === right.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slugifyReceiptPart(value) {
|
||||||
|
return String(value)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._:-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 120) || "receipt";
|
||||||
|
}
|
||||||
|
|
||||||
function resolveOptionPath(path) {
|
function resolveOptionPath(path) {
|
||||||
return resolve(process.cwd(), path);
|
return resolve(process.cwd(), path);
|
||||||
}
|
}
|
||||||
|
|
@ -861,8 +886,6 @@ Mirror options for enqueue:
|
||||||
--mirror-content-file <path> Read custom mirror text from a file
|
--mirror-content-file <path> Read custom mirror text from a file
|
||||||
--mirror-dry-run true Exercise mirror plumbing without posting to Discord
|
--mirror-dry-run true Exercise mirror plumbing without posting to Discord
|
||||||
--allow-unmirrored true Fixture/debug escape hatch; production writes should not use this
|
--allow-unmirrored true Fixture/debug escape hatch; production writes should not use this
|
||||||
--allow-unreceipted-activity true Operator-recovery hatch; lets a mutation proceed without hosted Bifrost receipts
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
node tools/agent-transport.mjs enqueue --repo AetheriaLore --agent nibu --title "Wavecrafters" --request-file packet.md --priority 80
|
node tools/agent-transport.mjs enqueue --repo AetheriaLore --agent nibu --title "Wavecrafters" --request-file packet.md --priority 80
|
||||||
node tools/agent-transport.mjs claim --repo AetheriaLore --agent nibu --claimed-by nibu
|
node tools/agent-transport.mjs claim --repo AetheriaLore --agent nibu --claimed-by nibu
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
beginCrossingReceipt,
|
||||||
|
crossingProvenanceFromOptions,
|
||||||
|
} from "./bifrost-crossing-documents.mjs";
|
||||||
|
|
||||||
const PERSONA_WEBHOOK_NAME = "Bifrost Persona Pipe";
|
const PERSONA_WEBHOOK_NAME = "Bifrost Persona Pipe";
|
||||||
const PERSONA_WEBHOOK_CACHE_PATH = resolve(".bifrost/discord-webhook-cache.json");
|
const PERSONA_WEBHOOK_CACHE_PATH = resolve(".bifrost/discord-webhook-cache.json");
|
||||||
|
|
@ -190,6 +194,7 @@ async function createGitHubDraftPr(options) {
|
||||||
commitSha,
|
commitSha,
|
||||||
changedPaths,
|
changedPaths,
|
||||||
prUrl,
|
prUrl,
|
||||||
|
crossingReceiptId: bridgeAction?.receiptId ?? "",
|
||||||
provenance: buildBridgeProvenance(options),
|
provenance: buildBridgeProvenance(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -283,6 +288,7 @@ async function commentGitHubPr(options) {
|
||||||
pr,
|
pr,
|
||||||
receiptUrl,
|
receiptUrl,
|
||||||
externalReceiptId,
|
externalReceiptId,
|
||||||
|
crossingReceiptId: bridgeAction?.receiptId ?? "",
|
||||||
provenance: buildBridgeProvenance(options),
|
provenance: buildBridgeProvenance(options),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -314,10 +320,6 @@ async function postDiscordMessage(options) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before posting to Discord.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const bridgeAction = await beginBridgeAction(options, {
|
const bridgeAction = await beginBridgeAction(options, {
|
||||||
actorKind: personaName ? "Persona" : "Service",
|
actorKind: personaName ? "Persona" : "Service",
|
||||||
actorName: personaName ?? "bifrost",
|
actorName: personaName ?? "bifrost",
|
||||||
|
|
@ -332,6 +334,9 @@ async function postDiscordMessage(options) {
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
await bridgeAction?.start();
|
await bridgeAction?.start();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before posting to Discord.");
|
||||||
|
}
|
||||||
result = personaName
|
result = personaName
|
||||||
? await postDiscordPersonaMessage(token, channelId, content, {
|
? await postDiscordPersonaMessage(token, channelId, content, {
|
||||||
personaName,
|
personaName,
|
||||||
|
|
@ -361,6 +366,7 @@ async function postDiscordMessage(options) {
|
||||||
messageId: result.id,
|
messageId: result.id,
|
||||||
transport: result.transport,
|
transport: result.transport,
|
||||||
url: `https://discord.com/channels/${result.guildId ?? "@me"}/${channelId}/${result.id}`,
|
url: `https://discord.com/channels/${result.guildId ?? "@me"}/${channelId}/${result.id}`,
|
||||||
|
crossingReceiptId: bridgeAction?.receiptId ?? "",
|
||||||
provenance: buildBridgeProvenance(options),
|
provenance: buildBridgeProvenance(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -382,10 +388,6 @@ async function sendDiscordDm(options) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const bridgeAction = await beginBridgeAction(options, {
|
const bridgeAction = await beginBridgeAction(options, {
|
||||||
actorKind: "Service",
|
actorKind: "Service",
|
||||||
actorName: "bifrost",
|
actorName: "bifrost",
|
||||||
|
|
@ -401,6 +403,9 @@ async function sendDiscordDm(options) {
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
await bridgeAction?.start();
|
await bridgeAction?.start();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM.");
|
||||||
|
}
|
||||||
channelId = await openDiscordDmChannel(token, recipientId);
|
channelId = await openDiscordDmChannel(token, recipientId);
|
||||||
result = await postDiscordBotMessage(token, channelId, content, undefined);
|
result = await postDiscordBotMessage(token, channelId, content, undefined);
|
||||||
await bridgeAction?.complete({
|
await bridgeAction?.complete({
|
||||||
|
|
@ -425,6 +430,7 @@ async function sendDiscordDm(options) {
|
||||||
messageId: result.id,
|
messageId: result.id,
|
||||||
transport: result.transport,
|
transport: result.transport,
|
||||||
url: `https://discord.com/channels/@me/${channelId}/${result.id}`,
|
url: `https://discord.com/channels/@me/${channelId}/${result.id}`,
|
||||||
|
crossingReceiptId: bridgeAction?.receiptId ?? "",
|
||||||
provenance: buildBridgeProvenance(options),
|
provenance: buildBridgeProvenance(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -502,6 +508,7 @@ async function postRedditThread(options) {
|
||||||
personaFlairText,
|
personaFlairText,
|
||||||
thingId: result.thingId,
|
thingId: result.thingId,
|
||||||
url: result.url,
|
url: result.url,
|
||||||
|
crossingReceiptId: bridgeAction?.receiptId ?? "",
|
||||||
provenance: buildBridgeProvenance(options),
|
provenance: buildBridgeProvenance(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -529,45 +536,10 @@ async function requestOtherBridgeAction(options) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bridgeAction = await beginBridgeAction(options, {
|
throw new Error(
|
||||||
actorKind: "Agent",
|
"other-request was a receipt-only HTTP bridge hatch and has been removed. " +
|
||||||
actorName: identity,
|
"Define a typed Bifrost CultMesh command/receipt document for this surface before executing it.",
|
||||||
targetSurface: "Other",
|
);
|
||||||
actionKind: "Other",
|
|
||||||
targetSurfaceName: surfaceName,
|
|
||||||
targetRepositoryFullName: options["target-repository-full-name"] ?? "",
|
|
||||||
targetLocator,
|
|
||||||
title,
|
|
||||||
summary: content,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await bridgeAction?.start();
|
|
||||||
await bridgeAction?.complete({
|
|
||||||
receiptUrl: `bifrost://bridge/actions/${bridgeAction.id}`,
|
|
||||||
externalReceiptId: bridgeAction.id,
|
|
||||||
receiptPayload: JSON.stringify(buildReceiptPayload(options, {
|
|
||||||
surfaceName,
|
|
||||||
targetLocator,
|
|
||||||
title,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
await bridgeAction?.fail(error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
printJson({
|
|
||||||
action: "other-request",
|
|
||||||
ok: true,
|
|
||||||
bridgeActionId: bridgeAction.id,
|
|
||||||
receiptUrl: `bifrost://bridge/actions/${bridgeAction.id}`,
|
|
||||||
identity,
|
|
||||||
surfaceName,
|
|
||||||
targetLocator,
|
|
||||||
title,
|
|
||||||
provenance: buildBridgeProvenance(options),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRedditAccessToken() {
|
async function getRedditAccessToken() {
|
||||||
|
|
@ -1078,76 +1050,86 @@ function resolveGitCommand() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function beginBridgeAction(options, action) {
|
async function beginBridgeAction(options, action) {
|
||||||
const baseUrl = optionalString(options["bifrost-base-url"]) ?? optionalString(process.env.BIFROST_BRIDGE_BASE_URL);
|
rejectRemovedHttpBridgeConfig(options);
|
||||||
if (!baseUrl) {
|
const provenance = crossingProvenanceFromOptions(options);
|
||||||
return null;
|
const actorName = action.actorName ?? provenance.actor.name ?? "bifrost";
|
||||||
}
|
const targetSurface = normalizeSurface(action.targetSurface);
|
||||||
|
const crossingKind = crossingKindForAction(action.actionKind);
|
||||||
const token = optionalString(options["bifrost-token"]) ?? optionalString(process.env.BIFROST_BRIDGE_TOKEN);
|
const lifecycle = await beginCrossingReceipt(options, {
|
||||||
if (!token) {
|
commandId: provenance.commandId,
|
||||||
throw new Error("Set BIFROST_BRIDGE_TOKEN or pass --bifrost-token when using the Bifrost bridge action ledger.");
|
crossingKind,
|
||||||
}
|
actor: {
|
||||||
|
kind: action.actorKind ?? provenance.actor.kind ?? "Service",
|
||||||
const payload = {
|
name: actorName,
|
||||||
...action,
|
bifrostIdentity: provenance.actor.bifrostIdentity || actorName,
|
||||||
sourceKind: optionalString(options["source-kind"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_KIND) ?? "",
|
},
|
||||||
sourceId: optionalString(options["source-id"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_ID) ?? "",
|
source: provenance.source,
|
||||||
authorityReference: optionalString(options["authority-ref"]) ?? optionalString(process.env.BIFROST_BRIDGE_AUTHORITY_REF) ?? "",
|
authority: provenance.authority,
|
||||||
bifrostIdentity: optionalString(options.identity) ?? optionalString(process.env.BIFROST_IDENTITY) ?? "",
|
epiphany: provenance.epiphany,
|
||||||
heimdallCapabilityReference:
|
target: {
|
||||||
optionalString(options["heimdall-capability-ref"]) ??
|
surface: targetSurface,
|
||||||
optionalString(process.env.HEIMDALL_CAPABILITY_REF) ??
|
repositoryFullName: action.targetRepositoryFullName ?? "",
|
||||||
optionalString(process.env.BIFROST_HEIMDALL_CAPABILITY_REF) ??
|
locator: action.targetLocator ?? "",
|
||||||
"",
|
title: action.title ?? "",
|
||||||
epiphanyRunId: optionalString(options["epiphany-run-id"]) ?? optionalString(process.env.EPIPHANY_RUN_ID) ?? "",
|
summary: action.summary ?? "",
|
||||||
epiphanyLaneId: optionalString(options["epiphany-lane-id"]) ?? optionalString(process.env.EPIPHANY_LANE_ID) ?? "",
|
},
|
||||||
epiphanyAgentIdentity: optionalString(options["epiphany-agent-identity"]) ?? optionalString(process.env.EPIPHANY_AGENT_IDENTITY) ?? "",
|
});
|
||||||
workItemId: optionalString(options["work-item-id"]) ?? null,
|
|
||||||
motionId: optionalString(options["motion-id"]) ?? null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const requested = await postBridgeJson(
|
|
||||||
baseUrl,
|
|
||||||
token,
|
|
||||||
"/bridge/actions/request",
|
|
||||||
payload,
|
|
||||||
new Set([202, 403]),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (requested.status === 403) {
|
|
||||||
throw new Error(`Bifrost denied bridge action: ${requested.body?.policyDecision ?? requested.text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = requested.body?.id;
|
|
||||||
if (!id) {
|
|
||||||
throw new Error(`Bifrost bridge request returned no action id: ${requested.text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
receiptId: lifecycle.receiptId,
|
||||||
|
commandId: lifecycle.commandId,
|
||||||
async start() {
|
async start() {
|
||||||
await postBridgeJson(baseUrl, token, `/bridge/actions/${id}/start`, undefined, new Set([202]));
|
return lifecycle.start();
|
||||||
},
|
},
|
||||||
async complete(receipt) {
|
async complete(result) {
|
||||||
await postBridgeJson(baseUrl, token, `/bridge/actions/${id}/complete`, receipt, new Set([200]));
|
return lifecycle.complete({
|
||||||
|
externalReceipt: {
|
||||||
|
url: result?.receiptUrl ?? "",
|
||||||
|
id: result?.externalReceiptId ?? "",
|
||||||
|
transport: targetSurface.toLowerCase(),
|
||||||
|
payload: parseOptionalJson(result?.receiptPayload),
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
async fail(error) {
|
async fail(error) {
|
||||||
const failureReason = error instanceof Error ? error.message : String(error);
|
return lifecycle.fail(error);
|
||||||
try {
|
|
||||||
await postBridgeJson(
|
|
||||||
baseUrl,
|
|
||||||
token,
|
|
||||||
`/bridge/actions/${id}/fail`,
|
|
||||||
{ failureReason },
|
|
||||||
new Set([200, 400]),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// Best effort: the underlying command error is still the real failure.
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function crossingKindForAction(actionKind) {
|
||||||
|
switch (actionKind) {
|
||||||
|
case "GitHubDraftPullRequest":
|
||||||
|
return "github.draft_pr";
|
||||||
|
case "GitHubPullRequestComment":
|
||||||
|
return "github.pr_comment";
|
||||||
|
case "DiscordPost":
|
||||||
|
return "discord.post";
|
||||||
|
case "DiscordDirectMessage":
|
||||||
|
return "discord.dm";
|
||||||
|
case "RedditPost":
|
||||||
|
return "reddit.post";
|
||||||
|
default:
|
||||||
|
return "future_surface.request";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSurface(value) {
|
||||||
|
return optionalString(value) ?? "future-surface";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalJson(value) {
|
||||||
|
const text = optionalString(value);
|
||||||
|
if (!text) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return { raw: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseRequiredJson(text, label) {
|
function parseRequiredJson(text, label) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
|
|
@ -1158,61 +1140,69 @@ function parseRequiredJson(text, label) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureGitHubBridgeGate(options) {
|
function ensureGitHubBridgeGate(options) {
|
||||||
const baseUrl = optionalString(options["bifrost-base-url"]) ?? optionalString(process.env.BIFROST_BRIDGE_BASE_URL);
|
rejectRemovedHttpBridgeConfig(options);
|
||||||
const token = optionalString(options["bifrost-token"]) ?? optionalString(process.env.BIFROST_BRIDGE_TOKEN);
|
const cultMeshCommandId =
|
||||||
|
optionalString(options["cultmesh-command-id"]) ?? optionalString(process.env.BIFROST_CULTMESH_COMMAND_ID);
|
||||||
const allowUngated =
|
const allowUngated =
|
||||||
options["allow-ungated-github"] === "true" ||
|
options["allow-ungated-github"] === "true" ||
|
||||||
process.env.BIFROST_ALLOW_UNGATED_GITHUB === "true";
|
process.env.BIFROST_ALLOW_UNGATED_GITHUB === "true";
|
||||||
|
|
||||||
if (allowUngated && process.env.BIFROST_LOCK_RECOVERY_HATCHES === "true") {
|
if (allowUngated) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Dispatched Bifrost work cannot use --allow-ungated-github or BIFROST_ALLOW_UNGATED_GITHUB. " +
|
"Bifrost GitHub mutations cannot use --allow-ungated-github or BIFROST_ALLOW_UNGATED_GITHUB. " +
|
||||||
"GitHub mutations from a dispatched turn must go through the normal Bifrost gate and receipt path.",
|
"Use a typed Bifrost CultMesh command request/receipt route instead of mutating off-book.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (baseUrl && token) {
|
if (cultMeshCommandId) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allowUngated) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"GitHub bridge actions require Bifrost authorization and receipt logging. " +
|
"GitHub bridge actions require Bifrost authorization and receipt logging. " +
|
||||||
"Set BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN, or use --allow-ungated-github true only for explicit operator recovery.",
|
"Provide BIFROST_CULTMESH_COMMAND_ID from a typed CultMesh command request; HTTP bridge hatches have been removed.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureBridgeReceiptGate(options) {
|
function ensureBridgeReceiptGate(options) {
|
||||||
const baseUrl = optionalString(options["bifrost-base-url"]) ?? optionalString(process.env.BIFROST_BRIDGE_BASE_URL);
|
rejectRemovedHttpBridgeConfig(options);
|
||||||
const token = optionalString(options["bifrost-token"]) ?? optionalString(process.env.BIFROST_BRIDGE_TOKEN);
|
const cultMeshCommandId =
|
||||||
|
optionalString(options["cultmesh-command-id"]) ?? optionalString(process.env.BIFROST_CULTMESH_COMMAND_ID);
|
||||||
const allowUnreceipted =
|
const allowUnreceipted =
|
||||||
options["allow-unreceipted-activity"] === "true" ||
|
options["allow-unreceipted-activity"] === "true" ||
|
||||||
process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true";
|
process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true";
|
||||||
|
|
||||||
if (allowUnreceipted && process.env.BIFROST_LOCK_RECOVERY_HATCHES === "true") {
|
if (allowUnreceipted) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Dispatched Bifrost work cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
|
"Bifrost bridge activity cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
|
||||||
"External bridge activity from a dispatched turn must go through the normal Bifrost receipt path.",
|
"Use a typed Bifrost CultMesh command request/receipt route instead of mutating off-book.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (baseUrl && token) {
|
if (cultMeshCommandId) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allowUnreceipted) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Bridge activity requires BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can receipt the external crossing. " +
|
"Bridge activity requires a Bifrost CultMesh command id so Bifrost can receipt the external crossing. " +
|
||||||
"Set BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN, or use --allow-unreceipted-activity true only for explicit operator recovery.",
|
"Use a typed CultMesh command request/receipt route; HTTP and unreceipted bridge hatches have been removed.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rejectRemovedHttpBridgeConfig(options) {
|
||||||
|
if (
|
||||||
|
optionalString(options["bifrost-base-url"]) ||
|
||||||
|
optionalString(options["bifrost-token"]) ||
|
||||||
|
optionalString(process.env.BIFROST_BRIDGE_BASE_URL) ||
|
||||||
|
optionalString(process.env.BIFROST_BRIDGE_TOKEN)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"BIFROST_BRIDGE_BASE_URL / BIFROST_BRIDGE_TOKEN and --bifrost-base-url / --bifrost-token were removed. " +
|
||||||
|
"Use typed Bifrost CultMesh command request/receipt documents discovered through Odin.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function postBridgeJson(baseUrl, token, path, payload, allowedStatuses) {
|
async function postBridgeJson(baseUrl, token, path, payload, allowedStatuses) {
|
||||||
const url = new URL(path, ensureTrailingSlash(baseUrl));
|
const url = new URL(path, ensureTrailingSlash(baseUrl));
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
|
|
@ -1380,8 +1370,7 @@ Examples:
|
||||||
node tools/bifrost-bridge.mjs other-request --identity epiphany.Persona --surface-name bluesky --target-locator at://did:example/app.bsky.feed.post/123 --heimdall-capability-ref heimdall:bluesky:capability:epiphany-persona --content "Persona asks to speak."
|
node tools/bifrost-bridge.mjs other-request --identity epiphany.Persona --surface-name bluesky --target-locator at://did:example/app.bsky.feed.post/123 --heimdall-capability-ref heimdall:bluesky:capability:epiphany-persona --content "Persona asks to speak."
|
||||||
|
|
||||||
GitHub note:
|
GitHub note:
|
||||||
GitHub actions require BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can gate and receipt the crossing.
|
GitHub actions require typed Bifrost CultMesh command provenance such as BIFROST_CULTMESH_COMMAND_ID so Bifrost can gate and receipt the crossing.
|
||||||
Use --allow-ungated-github true only for explicit operator recovery.
|
|
||||||
|
|
||||||
Provenance note:
|
Provenance note:
|
||||||
--identity names the Bifrost identity acting through the bridge.
|
--identity names the Bifrost identity acting through the bridge.
|
||||||
|
|
|
||||||
336
tools/bifrost-crossing-documents.mjs
Normal file
336
tools/bifrost-crossing-documents.mjs
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
const repoRoot = resolve(import.meta.dirname, "..");
|
||||||
|
const projectsRoot = resolve(repoRoot, "..");
|
||||||
|
|
||||||
|
export const crossingReceiptDocumentType = "bifrost.crossing_receipt";
|
||||||
|
export const crossingReceiptSchemaId = "bifrost.crossing_receipt.v1";
|
||||||
|
export const defaultCrossingReceiptStorePath = resolve(repoRoot, ".bifrost", "bridge-receipts.cc");
|
||||||
|
export const crossingReceiptStatuses = Object.freeze(["requested", "running", "completed", "failed", "cancelled"]);
|
||||||
|
|
||||||
|
const validStatuses = new Set(crossingReceiptStatuses);
|
||||||
|
let cachedRuntime;
|
||||||
|
let cachedDefinition;
|
||||||
|
|
||||||
|
export function crossingReceiptDefinition(defineDocumentTypeInput) {
|
||||||
|
const defineDocumentType = defineDocumentTypeInput ?? loadCultCacheRuntime().defineDocumentType;
|
||||||
|
cachedDefinition ??= defineDocumentType({
|
||||||
|
type: crossingReceiptDocumentType,
|
||||||
|
schemaId: crossingReceiptSchemaId,
|
||||||
|
schemaName: crossingReceiptDocumentType,
|
||||||
|
schemaVersion: crossingReceiptSchemaId,
|
||||||
|
contentHash: crossingReceiptSchemaId,
|
||||||
|
global: false,
|
||||||
|
name: "receiptId",
|
||||||
|
indexes: {
|
||||||
|
commandId: "commandId",
|
||||||
|
crossingKind: "crossingKind",
|
||||||
|
status: "status",
|
||||||
|
},
|
||||||
|
schema: { parse: parseCrossingReceipt },
|
||||||
|
members: [
|
||||||
|
{ slot: 1, memberName: "receiptId", typeName: "string", isName: true },
|
||||||
|
{ slot: 2, memberName: "commandId", typeName: "string", indexAlias: "commandId" },
|
||||||
|
{ slot: 3, memberName: "crossingKind", typeName: "string", indexAlias: "crossingKind" },
|
||||||
|
{ slot: 4, memberName: "status", typeName: "string", indexAlias: "status" },
|
||||||
|
{ slot: 5, memberName: "ok", typeName: "boolean" },
|
||||||
|
{ slot: 6, memberName: "requestedAt", typeName: "string" },
|
||||||
|
{ slot: 7, memberName: "startedAt", typeName: "string" },
|
||||||
|
{ slot: 8, memberName: "completedAt", typeName: "string" },
|
||||||
|
{ slot: 9, memberName: "actor", typeName: "object" },
|
||||||
|
{ slot: 10, memberName: "source", typeName: "object" },
|
||||||
|
{ slot: 11, memberName: "authority", typeName: "object" },
|
||||||
|
{ slot: 12, memberName: "epiphany", typeName: "object" },
|
||||||
|
{ slot: 13, memberName: "target", typeName: "object" },
|
||||||
|
{ slot: 14, memberName: "externalReceipt", typeName: "object" },
|
||||||
|
{ slot: 15, memberName: "error", typeName: "object" },
|
||||||
|
{ slot: 16, memberName: "supersedes", typeName: "string" },
|
||||||
|
{ slot: 17, memberName: "relatedReceiptIds", typeName: "string", isMany: true },
|
||||||
|
{ slot: 18, memberName: "surfaceReceipt", typeName: "object" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return cachedDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openCrossingReceiptStore(storePath = defaultCrossingReceiptStorePath) {
|
||||||
|
const { CultCache, SingleFileMessagePackBackingStore } = loadCultCacheRuntime();
|
||||||
|
await mkdir(dirname(storePath), { recursive: true });
|
||||||
|
const cache = CultCache.builder()
|
||||||
|
.withDocumentType(crossingReceiptDefinition())
|
||||||
|
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||||||
|
.build();
|
||||||
|
await cache.pullAllBackingStores();
|
||||||
|
return {
|
||||||
|
storePath,
|
||||||
|
definition: crossingReceiptDefinition(),
|
||||||
|
cache,
|
||||||
|
async put(receipt) {
|
||||||
|
const parsed = buildCrossingReceipt(receipt);
|
||||||
|
await cache.put(crossingReceiptDefinition(), parsed.receiptId, parsed);
|
||||||
|
return parsed;
|
||||||
|
},
|
||||||
|
get(receiptId) {
|
||||||
|
return cache.get(crossingReceiptDefinition(), receiptId);
|
||||||
|
},
|
||||||
|
getAll() {
|
||||||
|
return cache.getAll(crossingReceiptDefinition());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCrossingReceiptStorePath(options = {}) {
|
||||||
|
return resolveOptionPath(
|
||||||
|
options["receipt-store"] ??
|
||||||
|
process.env.BIFROST_CROSSING_RECEIPT_STORE ??
|
||||||
|
process.env.BIFROST_BRIDGE_RECEIPT_STORE ??
|
||||||
|
defaultCrossingReceiptStorePath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeCrossingReceipt(receipt, options = {}) {
|
||||||
|
const store = await openCrossingReceiptStore(resolveCrossingReceiptStorePath(options));
|
||||||
|
return store.put(receipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function beginCrossingReceipt(options, input) {
|
||||||
|
const requestedAt = input.requestedAt ?? new Date().toISOString();
|
||||||
|
const commandId = requiredString(input.commandId ?? commandIdFromOptions(options), "commandId");
|
||||||
|
const receiptId = input.receiptId ?? `crossing_${commandId}`;
|
||||||
|
const base = buildCrossingReceipt({
|
||||||
|
schemaName: crossingReceiptDocumentType,
|
||||||
|
schemaVersion: crossingReceiptSchemaId,
|
||||||
|
receiptId,
|
||||||
|
commandId,
|
||||||
|
crossingKind: requiredString(input.crossingKind, "crossingKind"),
|
||||||
|
status: "requested",
|
||||||
|
ok: false,
|
||||||
|
requestedAt,
|
||||||
|
startedAt: "",
|
||||||
|
completedAt: "",
|
||||||
|
actor: normalizeObject(input.actor),
|
||||||
|
source: normalizeObject(input.source),
|
||||||
|
authority: normalizeObject(input.authority),
|
||||||
|
epiphany: normalizeObject(input.epiphany),
|
||||||
|
target: normalizeObject(input.target),
|
||||||
|
externalReceipt: {},
|
||||||
|
error: {},
|
||||||
|
supersedes: optionalString(input.supersedes) ?? "",
|
||||||
|
relatedReceiptIds: normalizeStringArray(input.relatedReceiptIds),
|
||||||
|
surfaceReceipt: normalizeObject(input.surfaceReceipt),
|
||||||
|
});
|
||||||
|
await writeCrossingReceipt(base, options);
|
||||||
|
let current = base;
|
||||||
|
|
||||||
|
return {
|
||||||
|
receiptId,
|
||||||
|
commandId,
|
||||||
|
requested: base,
|
||||||
|
async start(extra = {}) {
|
||||||
|
current = await writeCrossingReceipt({
|
||||||
|
...current,
|
||||||
|
...extra,
|
||||||
|
status: "running",
|
||||||
|
ok: false,
|
||||||
|
startedAt: extra.startedAt ?? new Date().toISOString(),
|
||||||
|
}, options);
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
async complete(extra = {}) {
|
||||||
|
current = await writeCrossingReceipt({
|
||||||
|
...current,
|
||||||
|
...extra,
|
||||||
|
status: "completed",
|
||||||
|
ok: true,
|
||||||
|
startedAt: extra.startedAt ?? current.startedAt,
|
||||||
|
completedAt: extra.completedAt ?? new Date().toISOString(),
|
||||||
|
externalReceipt: normalizeObject(extra.externalReceipt ?? current.externalReceipt),
|
||||||
|
surfaceReceipt: normalizeObject(extra.surfaceReceipt ?? current.surfaceReceipt),
|
||||||
|
}, options);
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
async fail(error, extra = {}) {
|
||||||
|
current = await writeCrossingReceipt({
|
||||||
|
...current,
|
||||||
|
...extra,
|
||||||
|
status: "failed",
|
||||||
|
ok: false,
|
||||||
|
startedAt: extra.startedAt ?? current.startedAt,
|
||||||
|
completedAt: extra.completedAt ?? new Date().toISOString(),
|
||||||
|
error: normalizeError(error),
|
||||||
|
externalReceipt: normalizeObject(extra.externalReceipt ?? current.externalReceipt),
|
||||||
|
surfaceReceipt: normalizeObject(extra.surfaceReceipt ?? current.surfaceReceipt),
|
||||||
|
}, options);
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCrossingReceipt(input) {
|
||||||
|
return parseCrossingReceipt({
|
||||||
|
schemaName: crossingReceiptDocumentType,
|
||||||
|
schemaVersion: crossingReceiptSchemaId,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function crossingProvenanceFromOptions(options = {}) {
|
||||||
|
const commandId = commandIdFromOptions(options);
|
||||||
|
return {
|
||||||
|
commandId,
|
||||||
|
actor: {
|
||||||
|
bifrostIdentity: optionalString(options.identity) ?? optionalString(process.env.BIFROST_IDENTITY) ?? "",
|
||||||
|
kind: optionalString(options["actor-kind"]) ?? "",
|
||||||
|
name: optionalString(options["actor-name"]) ?? optionalString(options.identity) ?? optionalString(process.env.BIFROST_IDENTITY) ?? "",
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
kind: optionalString(options["source-kind"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_KIND) ?? "cultmesh-command",
|
||||||
|
id: optionalString(options["source-id"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_ID) ?? commandId,
|
||||||
|
topicId: optionalString(options["topic-id"]) ?? "",
|
||||||
|
requestId: optionalString(options["request-id"]) ?? "",
|
||||||
|
workItemId: optionalString(options["work-item-id"]) ?? "",
|
||||||
|
motionId: optionalString(options["motion-id"]) ?? "",
|
||||||
|
channelId: optionalString(options["source-channel-id"]) ?? "",
|
||||||
|
messageId: optionalString(options["source-message-id"]) ?? "",
|
||||||
|
},
|
||||||
|
authority: {
|
||||||
|
authorityRef: optionalString(options["authority-ref"]) ?? optionalString(process.env.BIFROST_BRIDGE_AUTHORITY_REF) ?? `cultmesh-command:${commandId}`,
|
||||||
|
heimdallCapabilityRef:
|
||||||
|
optionalString(options["heimdall-capability-ref"]) ??
|
||||||
|
optionalString(process.env.HEIMDALL_CAPABILITY_REF) ??
|
||||||
|
optionalString(process.env.BIFROST_HEIMDALL_CAPABILITY_REF) ??
|
||||||
|
"",
|
||||||
|
heimdallClaimJti: optionalString(options["heimdall-claim-jti"]) ?? optionalString(process.env.HEIMDALL_CLAIM_JTI) ?? "",
|
||||||
|
heimdallAccountId: optionalString(options["heimdall-account-id"]) ?? optionalString(process.env.HEIMDALL_ACCOUNT_ID) ?? "",
|
||||||
|
heimdallAccessRevision: optionalString(options["heimdall-access-revision"]) ?? optionalString(process.env.HEIMDALL_ACCESS_REVISION) ?? "",
|
||||||
|
heimdallGrantRef: optionalString(options["heimdall-grant-ref"]) ?? optionalString(process.env.HEIMDALL_GRANT_REF) ?? "",
|
||||||
|
heimdallClaimExpiresAt: optionalString(options["heimdall-claim-exp"]) ?? optionalString(process.env.HEIMDALL_CLAIM_EXP) ?? "",
|
||||||
|
policyDecisionId: optionalString(options["policy-decision-id"]) ?? optionalString(process.env.BIFROST_POLICY_DECISION_ID) ?? "",
|
||||||
|
},
|
||||||
|
epiphany: {
|
||||||
|
runId: optionalString(options["epiphany-run-id"]) ?? optionalString(process.env.EPIPHANY_RUN_ID) ?? "",
|
||||||
|
laneId: optionalString(options["epiphany-lane-id"]) ?? optionalString(process.env.EPIPHANY_LANE_ID) ?? "",
|
||||||
|
agentIdentity: optionalString(options["epiphany-agent-identity"]) ?? optionalString(process.env.EPIPHANY_AGENT_IDENTITY) ?? "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commandIdFromOptions(options = {}) {
|
||||||
|
return optionalString(options["cultmesh-command-id"]) ?? optionalString(process.env.BIFROST_CULTMESH_COMMAND_ID) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCrossingReceipt(input) {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||||
|
throw new Error("Bifrost crossing receipt must be an object.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const receipt = {
|
||||||
|
schemaName: optionalString(input.schemaName) ?? crossingReceiptDocumentType,
|
||||||
|
schemaVersion: optionalString(input.schemaVersion) ?? crossingReceiptSchemaId,
|
||||||
|
receiptId: requiredString(input.receiptId, "receiptId"),
|
||||||
|
commandId: requiredString(input.commandId, "commandId"),
|
||||||
|
crossingKind: requiredString(input.crossingKind, "crossingKind"),
|
||||||
|
status: requiredString(input.status, "status"),
|
||||||
|
ok: Boolean(input.ok),
|
||||||
|
requestedAt: requiredString(input.requestedAt, "requestedAt"),
|
||||||
|
startedAt: optionalString(input.startedAt) ?? "",
|
||||||
|
completedAt: optionalString(input.completedAt) ?? "",
|
||||||
|
actor: normalizeObject(input.actor),
|
||||||
|
source: normalizeObject(input.source),
|
||||||
|
authority: normalizeObject(input.authority),
|
||||||
|
epiphany: normalizeObject(input.epiphany),
|
||||||
|
target: normalizeObject(input.target),
|
||||||
|
externalReceipt: normalizeObject(input.externalReceipt),
|
||||||
|
error: normalizeObject(input.error),
|
||||||
|
supersedes: optionalString(input.supersedes) ?? "",
|
||||||
|
relatedReceiptIds: normalizeStringArray(input.relatedReceiptIds),
|
||||||
|
surfaceReceipt: normalizeObject(input.surfaceReceipt),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!validStatuses.has(receipt.status)) {
|
||||||
|
throw new Error(`Bifrost crossing receipt status "${receipt.status}" is not valid.`);
|
||||||
|
}
|
||||||
|
if (!receipt.source.kind && !receipt.source.id) {
|
||||||
|
throw new Error("Bifrost crossing receipt requires source provenance.");
|
||||||
|
}
|
||||||
|
if (!receipt.authority.authorityRef && !receipt.authority.heimdallCapabilityRef && !receipt.authority.heimdallClaimJti) {
|
||||||
|
throw new Error("Bifrost crossing receipt requires authority provenance.");
|
||||||
|
}
|
||||||
|
return receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCultCacheRuntime() {
|
||||||
|
if (cachedRuntime) {
|
||||||
|
return cachedRuntime;
|
||||||
|
}
|
||||||
|
const packagePath = resolveFirstExisting("CultCache TypeScript runtime", [
|
||||||
|
resolve(projectsRoot, "CultLib", "packages", "cultcache-ts", "package.json"),
|
||||||
|
resolve(projectsRoot, "CultCacheTS", "package.json"),
|
||||||
|
resolve(projectsRoot, "CultCacheTS", "node_modules", "cultcache-ts", "package.json"),
|
||||||
|
]);
|
||||||
|
const requireCultCache = createRequire(packagePath);
|
||||||
|
cachedRuntime = requireCultCache("cultcache-ts");
|
||||||
|
return cachedRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFirstExisting(label, candidates) {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`${label} is unavailable. Tried: ${candidates.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeError(error) {
|
||||||
|
if (!error) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return {
|
||||||
|
code: error.name || "Error",
|
||||||
|
message: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code: "Error",
|
||||||
|
message: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeObject(value) {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStringArray(value) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value.map((item) => optionalString(item)).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredString(value, field) {
|
||||||
|
const normalized = optionalString(value);
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error(`Bifrost crossing receipt field "${field}" must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOptionPath(path) {
|
||||||
|
return resolve(process.cwd(), path);
|
||||||
|
}
|
||||||
47
tools/bifrost-discord-command-documents.mjs
Normal file
47
tools/bifrost-discord-command-documents.mjs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
export const discordPostCommandDocumentType = "bifrost.bridge.discord_post_command";
|
||||||
|
export const discordPostCommandSchemaId = "bifrost.bridge.discord_post_command.v1";
|
||||||
|
export const discordPostReceiptDocumentType = "bifrost.bridge.discord_post_receipt";
|
||||||
|
export const discordPostReceiptSchemaId = "bifrost.bridge.discord_post_receipt.v1";
|
||||||
|
|
||||||
|
let cachedDefinitions;
|
||||||
|
|
||||||
|
export function discordPostCommandDefinition(defineDocumentType) {
|
||||||
|
cachedDefinitions ??= {};
|
||||||
|
cachedDefinitions.command ??= defineDocumentType({
|
||||||
|
type: discordPostCommandDocumentType,
|
||||||
|
schemaName: discordPostCommandDocumentType,
|
||||||
|
schemaId: discordPostCommandSchemaId,
|
||||||
|
schemaVersion: discordPostCommandSchemaId,
|
||||||
|
contentHash: discordPostCommandSchemaId,
|
||||||
|
global: false,
|
||||||
|
name: "commandId",
|
||||||
|
schema: objectSchema("Bifrost Discord post command"),
|
||||||
|
});
|
||||||
|
return cachedDefinitions.command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discordPostReceiptDefinition(defineDocumentType) {
|
||||||
|
cachedDefinitions ??= {};
|
||||||
|
cachedDefinitions.receipt ??= defineDocumentType({
|
||||||
|
type: discordPostReceiptDocumentType,
|
||||||
|
schemaName: discordPostReceiptDocumentType,
|
||||||
|
schemaId: discordPostReceiptSchemaId,
|
||||||
|
schemaVersion: discordPostReceiptSchemaId,
|
||||||
|
contentHash: discordPostReceiptSchemaId,
|
||||||
|
global: false,
|
||||||
|
name: "commandId",
|
||||||
|
schema: objectSchema("Bifrost Discord post receipt"),
|
||||||
|
});
|
||||||
|
return cachedDefinitions.receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectSchema(label) {
|
||||||
|
return {
|
||||||
|
parse(input) {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||||
|
throw new Error(`${label} must be an object.`);
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
450
tools/cultmesh-bridge-commands.mjs
Normal file
450
tools/cultmesh-bridge-commands.mjs
Normal file
|
|
@ -0,0 +1,450 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { mkdir, writeFile, rm } from "node:fs/promises";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { crossingReceiptDefinition } from "./bifrost-crossing-documents.mjs";
|
||||||
|
import {
|
||||||
|
discordPostCommandDefinition,
|
||||||
|
discordPostCommandDocumentType as commandDocumentType,
|
||||||
|
discordPostCommandSchemaId as commandSchemaId,
|
||||||
|
discordPostReceiptDefinition,
|
||||||
|
discordPostReceiptDocumentType as receiptDocumentType,
|
||||||
|
discordPostReceiptSchemaId as receiptSchemaId,
|
||||||
|
} from "./bifrost-discord-command-documents.mjs";
|
||||||
|
|
||||||
|
const repoRoot = resolve(import.meta.dirname, "..");
|
||||||
|
const projectsRoot = resolve(repoRoot, "..");
|
||||||
|
const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-store.cc");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
loadBifrostLocalEnv();
|
||||||
|
const [command, ...rawArgs] = process.argv.slice(2);
|
||||||
|
const options = parseArgs(rawArgs);
|
||||||
|
|
||||||
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
||||||
|
printHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case "process":
|
||||||
|
await processCommands(options);
|
||||||
|
return;
|
||||||
|
case "write-smoke":
|
||||||
|
await writeSmokeCommand(options);
|
||||||
|
return;
|
||||||
|
case "receipt":
|
||||||
|
await printReceipt(options);
|
||||||
|
return;
|
||||||
|
case "delete":
|
||||||
|
await deleteCommand(options);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown command "${command}". Run "node tools/cultmesh-bridge-commands.mjs help".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processCommands(options) {
|
||||||
|
const storePath = resolve(options.store ?? defaultStorePath);
|
||||||
|
const commandIdFilter = optionalString(options["command-id"]);
|
||||||
|
const node = await openCommandNode(storePath);
|
||||||
|
await node.cache?.pullAllBackingStores?.();
|
||||||
|
const commands = listCommandRecords(node)
|
||||||
|
.filter((command) => command.status === "pending" || command.status === "running")
|
||||||
|
.filter((command) => !commandIdFilter || command.commandId === commandIdFilter)
|
||||||
|
.sort((left, right) => String(left.createdAt).localeCompare(String(right.createdAt)));
|
||||||
|
|
||||||
|
const processed = [];
|
||||||
|
for (const command of commands) {
|
||||||
|
processed.push(await processOneCommand(node, command, options));
|
||||||
|
}
|
||||||
|
await node.flush?.();
|
||||||
|
|
||||||
|
printJson({
|
||||||
|
ok: processed.every((receipt) => receipt.status === "completed"),
|
||||||
|
action: "process",
|
||||||
|
storePath,
|
||||||
|
commandCount: commands.length,
|
||||||
|
processed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeSmokeCommand(options) {
|
||||||
|
const storePath = resolve(options.store ?? defaultStorePath);
|
||||||
|
const commandId = optionalString(options["command-id"]) ?? `bifrost-smoke-${Date.now()}`;
|
||||||
|
const command = {
|
||||||
|
schemaName: commandDocumentType,
|
||||||
|
schemaVersion: commandSchemaId,
|
||||||
|
commandId,
|
||||||
|
command: "discord-post",
|
||||||
|
status: "pending",
|
||||||
|
requestedBy: "bifrost-smoke",
|
||||||
|
requestedAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
source: {
|
||||||
|
kind: "smoke",
|
||||||
|
id: commandId,
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
channelId: requireOption(options, "channel-id"),
|
||||||
|
content: optionalString(options.content) ?? "Bifrost CultMesh Discord command smoke.",
|
||||||
|
personaName: optionalString(options["persona-name"]),
|
||||||
|
personaAvatarUrl: optionalString(options["persona-avatar-url"]),
|
||||||
|
replyToMessageId: optionalString(options["reply-to-message-id"]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const node = await openCommandNode(storePath);
|
||||||
|
await node.put(commandDefinition(), command.commandId, command);
|
||||||
|
await node.flush?.();
|
||||||
|
printJson({ ok: true, action: "write-smoke", storePath, commandId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printReceipt(options) {
|
||||||
|
const commandId = requireOption(options, "command-id");
|
||||||
|
const storePath = resolve(options.store ?? defaultStorePath);
|
||||||
|
const node = await openCommandNode(storePath);
|
||||||
|
await node.cache?.pullAllBackingStores?.();
|
||||||
|
const receipt = unwrapRecord(await node.get(receiptDefinition(), commandId));
|
||||||
|
if (!receipt) {
|
||||||
|
printJson({ ok: false, commandId, receipt: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printJson({ ok: receipt.status === "completed", commandId, receipt });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCommand(options) {
|
||||||
|
const commandId = requireOption(options, "command-id");
|
||||||
|
const storePath = resolve(options.store ?? defaultStorePath);
|
||||||
|
const node = await openCommandNode(storePath);
|
||||||
|
await node.cache?.pullAllBackingStores?.();
|
||||||
|
const deletedCommand = typeof node.cache?.delete === "function"
|
||||||
|
? await node.cache.delete(commandDefinition(), commandId)
|
||||||
|
: false;
|
||||||
|
const deletedReceipt = typeof node.cache?.delete === "function"
|
||||||
|
? await node.cache.delete(receiptDefinition(), commandId)
|
||||||
|
: false;
|
||||||
|
await node.flush?.();
|
||||||
|
printJson({ ok: Boolean(deletedCommand || deletedReceipt), action: "delete", storePath, commandId, deletedCommand, deletedReceipt });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processOneCommand(node, command, options) {
|
||||||
|
const running = {
|
||||||
|
...command,
|
||||||
|
status: "running",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await node.put(commandDefinition(), command.commandId, running);
|
||||||
|
|
||||||
|
const payload = command.payload && typeof command.payload === "object" ? command.payload : {};
|
||||||
|
const tempDir = resolve(repoRoot, ".bifrost", "cultmesh-command-payloads", command.commandId);
|
||||||
|
const contentPath = resolve(tempDir, "content.md");
|
||||||
|
await mkdir(tempDir, { recursive: true });
|
||||||
|
await writeFile(contentPath, requireString(payload.content, "payload.content"), "utf8");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = [
|
||||||
|
"tools/bifrost-bridge.mjs",
|
||||||
|
"discord-post",
|
||||||
|
"--channel-id",
|
||||||
|
requireString(payload.channelId, "payload.channelId"),
|
||||||
|
"--content-file",
|
||||||
|
contentPath,
|
||||||
|
"--cultmesh-command-id",
|
||||||
|
command.commandId,
|
||||||
|
"--source-kind",
|
||||||
|
optionalString(command.source?.kind) ?? "cultmesh-command",
|
||||||
|
"--source-id",
|
||||||
|
optionalString(command.source?.id) ?? command.commandId,
|
||||||
|
"--identity",
|
||||||
|
optionalString(command.actor?.id) ?? optionalString(payload.identityId) ?? optionalString(payload.personaName) ?? "bifrost",
|
||||||
|
];
|
||||||
|
pushOption(args, "--persona-name", payload.personaName);
|
||||||
|
pushOption(args, "--persona-avatar-url", payload.personaAvatarUrl);
|
||||||
|
pushOption(args, "--reply-to-message-id", payload.replyToMessageId);
|
||||||
|
pushOption(args, "--receipt-store", options["receipt-store"]);
|
||||||
|
|
||||||
|
const result = spawnSync(process.execPath, args, {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: "utf8",
|
||||||
|
windowsHide: true,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BIFROST_CULTMESH_COMMAND_ID: command.commandId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.status !== 0 || result.error) {
|
||||||
|
throw new Error(renderSpawnFailure(result));
|
||||||
|
}
|
||||||
|
const posted = parseJson(result.stdout, "bifrost bridge discord-post receipt");
|
||||||
|
const receipt = buildReceipt(command, {
|
||||||
|
status: "completed",
|
||||||
|
ok: true,
|
||||||
|
action: "discord-post",
|
||||||
|
channelId: posted.channelId,
|
||||||
|
messageId: posted.messageId,
|
||||||
|
transport: posted.transport,
|
||||||
|
url: posted.url,
|
||||||
|
canonicalReceiptId: posted.crossingReceiptId,
|
||||||
|
payload: posted,
|
||||||
|
});
|
||||||
|
await node.put(receiptDefinition(), command.commandId, receipt);
|
||||||
|
await node.put(commandDefinition(), command.commandId, {
|
||||||
|
...running,
|
||||||
|
status: "completed",
|
||||||
|
updatedAt: receipt.completedAt,
|
||||||
|
receiptId: receipt.receiptId,
|
||||||
|
});
|
||||||
|
return receipt;
|
||||||
|
} catch (error) {
|
||||||
|
const receipt = buildReceipt(command, {
|
||||||
|
status: "failed",
|
||||||
|
ok: false,
|
||||||
|
action: "discord-post",
|
||||||
|
canonicalReceiptId: `crossing_${command.commandId}`,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
await node.put(receiptDefinition(), command.commandId, receipt);
|
||||||
|
await node.put(commandDefinition(), command.commandId, {
|
||||||
|
...running,
|
||||||
|
status: "failed",
|
||||||
|
updatedAt: receipt.completedAt,
|
||||||
|
receiptId: receipt.receiptId,
|
||||||
|
error: receipt.error,
|
||||||
|
});
|
||||||
|
return receipt;
|
||||||
|
} finally {
|
||||||
|
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReceipt(command, result) {
|
||||||
|
const completedAt = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
schemaName: receiptDocumentType,
|
||||||
|
schemaVersion: receiptSchemaId,
|
||||||
|
receiptId: command.commandId,
|
||||||
|
commandId: command.commandId,
|
||||||
|
command: command.command,
|
||||||
|
status: result.status,
|
||||||
|
ok: result.ok,
|
||||||
|
action: result.action,
|
||||||
|
channelId: result.channelId ?? command.payload?.channelId ?? "",
|
||||||
|
messageId: result.messageId ?? "",
|
||||||
|
transport: result.transport ?? "",
|
||||||
|
url: result.url ?? "",
|
||||||
|
canonicalReceiptId: result.canonicalReceiptId ?? "",
|
||||||
|
error: result.error ?? "",
|
||||||
|
requestedAt: command.requestedAt ?? command.createdAt ?? "",
|
||||||
|
completedAt,
|
||||||
|
source: command.source ?? {},
|
||||||
|
actor: command.actor ?? {},
|
||||||
|
payload: result.payload ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listCommandRecords(node) {
|
||||||
|
if (typeof node.cache?.getAll !== "function") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return node.cache.getAll(commandDefinition())
|
||||||
|
.map(unwrapRecord)
|
||||||
|
.filter((entry) => entry && entry.command === "discord-post" && entry.commandId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCommandNode(storePath) {
|
||||||
|
const { CultMesh, defineDocumentType } = loadCultMeshRuntime();
|
||||||
|
return CultMesh.createNode(storePath, {
|
||||||
|
documents: [
|
||||||
|
commandDefinition(defineDocumentType),
|
||||||
|
receiptDefinition(defineDocumentType),
|
||||||
|
crossingReceiptDefinition(defineDocumentType),
|
||||||
|
genericDocument(defineDocumentType, "gamecult.eve.provider_advertisement", "gamecult.eve.provider_advertisement.v1", "providerId"),
|
||||||
|
genericDocument(defineDocumentType, "gamecult.eve.surface_state", "gamecult.eve.surface_state.v1", "providerId"),
|
||||||
|
genericDocument(defineDocumentType, "gamecult.eve.interface_binding", "gamecult.eve.interface_binding.v1", "bindingId"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandDefinition(defineDocumentTypeInput) {
|
||||||
|
const defineDocumentType = defineDocumentTypeInput ?? loadCultMeshRuntime().defineDocumentType;
|
||||||
|
return discordPostCommandDefinition(defineDocumentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function receiptDefinition(defineDocumentTypeInput) {
|
||||||
|
const defineDocumentType = defineDocumentTypeInput ?? loadCultMeshRuntime().defineDocumentType;
|
||||||
|
return discordPostReceiptDefinition(defineDocumentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function genericDocument(defineDocumentType, type, schemaId, name) {
|
||||||
|
return defineDocumentType({
|
||||||
|
type,
|
||||||
|
schemaName: type,
|
||||||
|
schemaId,
|
||||||
|
schemaVersion: schemaId,
|
||||||
|
contentHash: schemaId,
|
||||||
|
global: false,
|
||||||
|
name,
|
||||||
|
schema: parseObjectDocument(type),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCultMeshRuntime() {
|
||||||
|
const candidates = [
|
||||||
|
resolve(projectsRoot, "CultLib", "packages", "cultmesh-ts", "package.json"),
|
||||||
|
resolve(projectsRoot, "CultMeshTS", "package.json"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const packageJson of candidates) {
|
||||||
|
if (!existsSync(packageJson)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const requireCult = createRequire(packageJson);
|
||||||
|
const { CultMesh } = requireCult("cultmesh-ts");
|
||||||
|
const { defineDocumentType } = requireCult("cultcache-ts");
|
||||||
|
if (CultMesh && defineDocumentType) {
|
||||||
|
return { CultMesh, defineDocumentType };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("CultMesh/CultCache TypeScript runtime is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObjectDocument(label) {
|
||||||
|
return {
|
||||||
|
parse(input) {
|
||||||
|
if (!input || typeof input !== "object") {
|
||||||
|
throw new Error(`${label} must be an object.`);
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBifrostLocalEnv() {
|
||||||
|
if (process.env.BIFROST_SKIP_LOCAL_ENV === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadLocalEnv(resolve(repoRoot, ".env"));
|
||||||
|
loadLocalEnv(resolve(projectsRoot, "VoidBot", ".env"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLocalEnv(path) {
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = readFileSync(path, "utf8");
|
||||||
|
for (const rawLine of content.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith("#")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||||
|
if (!match || process.env[match[1]] !== undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
process.env[match[1]] = match[2].replace(/^"(.*)"$/, "$1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(args) {
|
||||||
|
const options = {};
|
||||||
|
for (let index = 0; index < args.length; index += 1) {
|
||||||
|
const token = args[index];
|
||||||
|
if (!token.startsWith("--")) {
|
||||||
|
throw new Error(`Unexpected argument "${token}".`);
|
||||||
|
}
|
||||||
|
const key = token.slice(2);
|
||||||
|
const next = args[index + 1];
|
||||||
|
if (!next || next.startsWith("--")) {
|
||||||
|
options[key] = "true";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
options[key] = next;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireOption(options, name) {
|
||||||
|
const value = optionalString(options[name]);
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required option --${name}.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireString(value, name) {
|
||||||
|
const text = optionalString(value);
|
||||||
|
if (!text) {
|
||||||
|
throw new Error(`Missing required ${name}.`);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushOption(args, name, value) {
|
||||||
|
const text = optionalString(value);
|
||||||
|
if (text) {
|
||||||
|
args.push(name, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapRecord(record) {
|
||||||
|
const candidate = Array.isArray(record) && record.length === 1 ? record[0] : record?.value ?? record;
|
||||||
|
return candidate && typeof candidate === "object" ? candidate : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(value, label) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} returned non-JSON output: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSpawnFailure(result) {
|
||||||
|
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
||||||
|
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
|
||||||
|
const error = result.error instanceof Error ? result.error.message : "";
|
||||||
|
return [
|
||||||
|
`Bifrost Discord actuator failed with status ${result.status ?? "unknown"}.`,
|
||||||
|
error,
|
||||||
|
stderr,
|
||||||
|
stdout,
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function printJson(value) {
|
||||||
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
process.stdout.write(`Bifrost CultMesh bridge commands
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
node tools/cultmesh-bridge-commands.mjs process [--store <path>] [--command-id <id>]
|
||||||
|
node tools/cultmesh-bridge-commands.mjs receipt --command-id <id> [--store <path>]
|
||||||
|
node tools/cultmesh-bridge-commands.mjs delete --command-id <id> [--store <path>]
|
||||||
|
node tools/cultmesh-bridge-commands.mjs write-smoke --channel-id <id> [--store <path>]
|
||||||
|
|
||||||
|
The command request document is ${commandDocumentType} / ${commandSchemaId}.
|
||||||
|
The receipt document is ${receiptDocumentType} / ${receiptSchemaId}.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
|
@ -90,7 +90,6 @@ async function dispatchQueuedRequests(options) {
|
||||||
...optionalArg("--channel-id", options["channel-id"]),
|
...optionalArg("--channel-id", options["channel-id"]),
|
||||||
...optionalArg("--persona-name", options["persona-name"]),
|
...optionalArg("--persona-name", options["persona-name"]),
|
||||||
...optionalArg("--persona-avatar-url", options["persona-avatar-url"]),
|
...optionalArg("--persona-avatar-url", options["persona-avatar-url"]),
|
||||||
...(options["allow-unreceipted-activity"] === "true" ? ["--allow-unreceipted-activity", "true"] : []),
|
|
||||||
...(options["no-discord"] === "true" ? ["--no-discord", "true"] : []),
|
...(options["no-discord"] === "true" ? ["--no-discord", "true"] : []),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
|
@ -124,21 +123,26 @@ async function dispatchQueuedRequests(options) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureDispatchReceiptGate(options) {
|
function ensureDispatchReceiptGate(options) {
|
||||||
if (hasBifrostBridgeConfig() || allowsUnreceiptedActivity(options)) {
|
if (wantsUnreceiptedActivity(options)) {
|
||||||
return;
|
throw new Error(
|
||||||
|
"Bifrost dispatch cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
|
||||||
|
"Use the typed Bifrost CultCache/CultMesh dispatch store instead of launching off-book work.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
if (hasRemovedHttpBridgeConfig()) {
|
||||||
"Bifrost agent dispatch requires BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can keep request, run, and bridge receipts aligned. " +
|
throw new Error(
|
||||||
"Set both values, or use --allow-unreceipted-activity true only for explicit operator recovery.",
|
"BIFROST_BRIDGE_BASE_URL / BIFROST_BRIDGE_TOKEN were removed. " +
|
||||||
);
|
"Bifrost dispatch receipts are written to the typed CultCache/CultMesh request/run artifacts.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasBifrostBridgeConfig() {
|
function hasRemovedHttpBridgeConfig() {
|
||||||
return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) && optionalString(process.env.BIFROST_BRIDGE_TOKEN));
|
return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) || optionalString(process.env.BIFROST_BRIDGE_TOKEN));
|
||||||
}
|
}
|
||||||
|
|
||||||
function allowsUnreceiptedActivity(options) {
|
function wantsUnreceiptedActivity(options) {
|
||||||
return options["allow-unreceipted-activity"] === "true" || process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true";
|
return options["allow-unreceipted-activity"] === "true" || process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -550,7 +554,7 @@ function resolveDispatchReceiptChannelId(options) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function bridgeRecoveryArgs(options) {
|
function bridgeRecoveryArgs(options) {
|
||||||
return allowsUnreceiptedActivity(options) ? ["--allow-unreceipted-activity", "true"] : [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDispatchReceiptContent(request) {
|
function renderDispatchReceiptContent(request) {
|
||||||
|
|
@ -840,61 +844,9 @@ async function writeDispatchResult(logPath, result) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function beginDispatchRun(request, input) {
|
async function beginDispatchRun(request, input) {
|
||||||
const baseUrl = optionalString(process.env.BIFROST_BRIDGE_BASE_URL);
|
void request;
|
||||||
const token = optionalString(process.env.BIFROST_BRIDGE_TOKEN);
|
void input;
|
||||||
if (!baseUrl || !token) {
|
return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const started = await postDispatchRunJson(
|
|
||||||
baseUrl,
|
|
||||||
token,
|
|
||||||
"/dispatch/runs/start",
|
|
||||||
{
|
|
||||||
requestId: request.id,
|
|
||||||
targetRepoName: request.targetRepoName,
|
|
||||||
targetRepositoryFullName: request.targetRepositoryFullName ?? "",
|
|
||||||
targetAgentIdentity: request.targetAgentIdentity ?? "",
|
|
||||||
launchMode: input.launchMode,
|
|
||||||
workerProcessId: input.workerProcessId,
|
|
||||||
threadId: input.threadId,
|
|
||||||
turnId: input.turnId,
|
|
||||||
logPath: input.logPath,
|
|
||||||
resultPath: input.resultPath,
|
|
||||||
note: input.note,
|
|
||||||
},
|
|
||||||
new Set([202]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const id = started.body?.id;
|
|
||||||
if (!id) {
|
|
||||||
throw new Error(`Bifrost dispatch run start returned no run id: ${started.text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
async complete(payload) {
|
|
||||||
await postDispatchRunJson(
|
|
||||||
baseUrl,
|
|
||||||
token,
|
|
||||||
`/dispatch/runs/${id}/complete`,
|
|
||||||
payload,
|
|
||||||
new Set([200]),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async fail(payload) {
|
|
||||||
try {
|
|
||||||
await postDispatchRunJson(
|
|
||||||
baseUrl,
|
|
||||||
token,
|
|
||||||
`/dispatch/runs/${id}/fail`,
|
|
||||||
payload,
|
|
||||||
new Set([200]),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// Best effort: the run failure still lands in the local result/log files.
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postDispatchRunJson(baseUrl, token, path, payload, allowedStatuses) {
|
async function postDispatchRunJson(baseUrl, token, path, payload, allowedStatuses) {
|
||||||
|
|
@ -1098,10 +1050,6 @@ Commands:
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
node tools/dispatch-agent-requests.mjs dispatch --repo AquaSynth --agent aqua --max 1
|
node tools/dispatch-agent-requests.mjs dispatch --repo AquaSynth --agent aqua --max 1
|
||||||
node tools/dispatch-agent-requests.mjs dispatch --repo AquaSynth --agent aqua --allow-unreceipted-activity true
|
|
||||||
|
|
||||||
Recovery hatch:
|
|
||||||
BIFROST_ALLOW_UNRECEIPTED_ACTIVITY=true or --allow-unreceipted-activity true
|
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ import { existsSync, readFileSync } from "node:fs";
|
||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
commandIdFromOptions,
|
||||||
|
crossingProvenanceFromOptions,
|
||||||
|
writeCrossingReceipt,
|
||||||
|
} from "./bifrost-crossing-documents.mjs";
|
||||||
|
|
||||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = resolve(scriptDir, "..");
|
const repoRoot = resolve(scriptDir, "..");
|
||||||
|
|
@ -209,7 +214,7 @@ async function openTopic(cache, options) {
|
||||||
actorKind: "agent",
|
actorKind: "agent",
|
||||||
actorName: topic.createdByActor ?? "system",
|
actorName: topic.createdByActor ?? "system",
|
||||||
note: "Governance topic opened.",
|
note: "Governance topic opened.",
|
||||||
});
|
}, options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await cache.delete(topicDefinition, topic.id);
|
await cache.delete(topicDefinition, topic.id);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -249,7 +254,7 @@ async function addComment(cache, options) {
|
||||||
actorKind: comment.authorKind,
|
actorKind: comment.authorKind,
|
||||||
actorName: comment.authorId,
|
actorName: comment.authorId,
|
||||||
note: `Governance topic ${comment.stance} comment recorded.`,
|
note: `Governance topic ${comment.stance} comment recorded.`,
|
||||||
});
|
}, options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await cache.delete(commentDefinition, comment.id);
|
await cache.delete(commentDefinition, comment.id);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -304,7 +309,7 @@ async function approveTopic(cache, options) {
|
||||||
actorKind: "face",
|
actorKind: "face",
|
||||||
actorName: approvedBy,
|
actorName: approvedBy,
|
||||||
note: "Governance topic approved.",
|
note: "Governance topic approved.",
|
||||||
});
|
}, options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await cache.put(topicDefinition, topic.id, topic);
|
await cache.put(topicDefinition, topic.id, topic);
|
||||||
if (comment) {
|
if (comment) {
|
||||||
|
|
@ -389,7 +394,7 @@ async function promoteTopic(cache, options) {
|
||||||
actorKind: "system",
|
actorKind: "system",
|
||||||
actorName: topic.approvedByAgent ?? topic.createdByActor ?? "bifrost",
|
actorName: topic.approvedByAgent ?? topic.createdByActor ?? "bifrost",
|
||||||
note: `Governance topic promoted to update request ${request.id}.`,
|
note: `Governance topic promoted to update request ${request.id}.`,
|
||||||
});
|
}, options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await cache.put(topicDefinition, topic.id, topic);
|
await cache.put(topicDefinition, topic.id, topic);
|
||||||
await cache.delete(commentDefinition, receipt.id);
|
await cache.delete(commentDefinition, receipt.id);
|
||||||
|
|
@ -470,7 +475,7 @@ function allowsUnmirrored(options) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function bridgeRecoveryArgs(options) {
|
function bridgeRecoveryArgs(options) {
|
||||||
return allowsUnreceiptedActivity(options) ? ["--allow-unreceipted-activity", "true"] : [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGovernanceMirrorContent(topic, eventLabel, content) {
|
function renderGovernanceMirrorContent(topic, eventLabel, content) {
|
||||||
|
|
@ -523,37 +528,55 @@ function truncateMarkdown(value, maxLength) {
|
||||||
return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
|
return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recordGovernanceReceiptOrThrow(topic, receipt) {
|
async function recordGovernanceReceiptOrThrow(topic, receipt, options = {}) {
|
||||||
const baseUrl = optionalString(process.env.BIFROST_BRIDGE_BASE_URL);
|
const commandId = commandIdFromOptions(options) || `governance:${topic.id}:${receipt.activityKind}`;
|
||||||
const token = optionalString(process.env.BIFROST_BRIDGE_TOKEN);
|
const provenance = crossingProvenanceFromOptions({
|
||||||
if (!baseUrl || !token) {
|
...options,
|
||||||
return;
|
"cultmesh-command-id": commandId,
|
||||||
}
|
"source-kind": "bifrost.governance.topic",
|
||||||
|
"source-id": topic.id,
|
||||||
const response = await fetch(new URL("/governance/receipts", ensureTrailingSlash(baseUrl)), {
|
"topic-id": topic.id,
|
||||||
method: "POST",
|
"authority-ref": `bifrost_governance_${receipt.activityKind}`,
|
||||||
headers: {
|
identity: receipt.actorName,
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Bifrost-Bridge-Token": token,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
topicId: topic.id,
|
|
||||||
commentId: receipt.commentId,
|
|
||||||
dispatchRequestId: receipt.dispatchRequestId,
|
|
||||||
title: topic.title,
|
|
||||||
jurisdictionRepoName: topic.jurisdictionRepoName,
|
|
||||||
jurisdictionAgentIdentity: topic.jurisdictionAgentIdentity ?? "",
|
|
||||||
activityKind: receipt.activityKind,
|
|
||||||
actorKind: receipt.actorKind,
|
|
||||||
actorName: receipt.actorName,
|
|
||||||
note: receipt.note,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
const now = new Date().toISOString();
|
||||||
const text = await response.text();
|
await writeCrossingReceipt({
|
||||||
if (response.status !== 202) {
|
receiptId: `crossing_${slugifyReceiptPart(commandId)}_${slugifyReceiptPart(receipt.activityKind)}`,
|
||||||
throw new Error(`Bifrost governance receipt failed with ${response.status}: ${text}`);
|
commandId,
|
||||||
}
|
crossingKind: "governance.topic",
|
||||||
|
status: topic.status === "cancelled" ? "cancelled" : "completed",
|
||||||
|
ok: true,
|
||||||
|
requestedAt: topic.createdAt || now,
|
||||||
|
startedAt: topic.updatedAt || now,
|
||||||
|
completedAt: now,
|
||||||
|
actor: {
|
||||||
|
kind: receipt.actorKind || "system",
|
||||||
|
name: receipt.actorName || "bifrost",
|
||||||
|
bifrostIdentity: receipt.actorName || "bifrost",
|
||||||
|
},
|
||||||
|
source: provenance.source,
|
||||||
|
authority: provenance.authority,
|
||||||
|
epiphany: provenance.epiphany,
|
||||||
|
target: {
|
||||||
|
surface: "governance",
|
||||||
|
locator: topic.id,
|
||||||
|
repositoryFullName: topic.jurisdictionRepoName,
|
||||||
|
topicId: topic.id,
|
||||||
|
dispatchRequestId: receipt.dispatchRequestId || "",
|
||||||
|
status: topic.status,
|
||||||
|
},
|
||||||
|
externalReceipt: {
|
||||||
|
id: receipt.commentId || receipt.dispatchRequestId || topic.id,
|
||||||
|
transport: "cultcache",
|
||||||
|
url: "",
|
||||||
|
},
|
||||||
|
surfaceReceipt: {
|
||||||
|
activityKind: receipt.activityKind,
|
||||||
|
note: receipt.note || "",
|
||||||
|
commentId: receipt.commentId || "",
|
||||||
|
dispatchRequestId: receipt.dispatchRequestId || "",
|
||||||
|
},
|
||||||
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMutatingCommand(command) {
|
function isMutatingCommand(command) {
|
||||||
|
|
@ -561,29 +584,23 @@ function isMutatingCommand(command) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureGovernanceReceiptGate(options) {
|
function ensureGovernanceReceiptGate(options) {
|
||||||
if (wantsUnreceiptedActivity(options) && process.env.BIFROST_LOCK_RECOVERY_HATCHES === "true") {
|
if (wantsUnreceiptedActivity(options)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Dispatched Bifrost work cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
|
"Bifrost governance mutations cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
|
||||||
"Governance mutations from a dispatched turn must keep Bifrost receipts.",
|
"Use the typed Bifrost CultCache/CultMesh governance store instead of mutating off-book.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasBifrostBridgeConfig() || allowsUnreceiptedActivity(options)) {
|
if (hasRemovedHttpBridgeConfig()) {
|
||||||
return;
|
throw new Error(
|
||||||
|
"BIFROST_BRIDGE_BASE_URL / BIFROST_BRIDGE_TOKEN were removed. " +
|
||||||
|
"Bifrost governance receipts are written to the typed CultCache/CultMesh store.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
"Bifrost governance mutations require BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can keep governance receipts. " +
|
|
||||||
"Set both values, or use --allow-unreceipted-activity true only for explicit operator recovery.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasBifrostBridgeConfig() {
|
function hasRemovedHttpBridgeConfig() {
|
||||||
return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) && optionalString(process.env.BIFROST_BRIDGE_TOKEN));
|
return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) || optionalString(process.env.BIFROST_BRIDGE_TOKEN));
|
||||||
}
|
|
||||||
|
|
||||||
function allowsUnreceiptedActivity(options) {
|
|
||||||
return wantsUnreceiptedActivity(options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function wantsUnreceiptedActivity(options) {
|
function wantsUnreceiptedActivity(options) {
|
||||||
|
|
@ -843,6 +860,15 @@ function equalsIgnoreCase(left, right) {
|
||||||
&& left.toLowerCase() === right.toLowerCase();
|
&& left.toLowerCase() === right.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slugifyReceiptPart(value) {
|
||||||
|
return String(value)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._:-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 120) || "receipt";
|
||||||
|
}
|
||||||
|
|
||||||
function runNodeJson(args, cwd) {
|
function runNodeJson(args, cwd) {
|
||||||
const result = spawnSync(process.execPath, args, {
|
const result = spawnSync(process.execPath, args, {
|
||||||
cwd,
|
cwd,
|
||||||
|
|
@ -925,7 +951,6 @@ Mirror options:
|
||||||
--mirror-content-file <path> Read mirror text from a file
|
--mirror-content-file <path> Read mirror text from a file
|
||||||
--mirror-dry-run true Exercise mirror plumbing without posting to Discord
|
--mirror-dry-run true Exercise mirror plumbing without posting to Discord
|
||||||
--allow-unmirrored true Fixture/debug escape hatch; production writes should not use this
|
--allow-unmirrored true Fixture/debug escape hatch; production writes should not use this
|
||||||
--allow-unreceipted-activity true Operator-recovery hatch; lets a mutation proceed without hosted Bifrost receipts
|
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,27 @@ import { mkdir } from "node:fs/promises";
|
||||||
import { existsSync, statSync } from "node:fs";
|
import { existsSync, statSync } from "node:fs";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
crossingReceiptDefinition,
|
||||||
|
crossingReceiptDocumentType,
|
||||||
|
crossingReceiptSchemaId,
|
||||||
|
openCrossingReceiptStore,
|
||||||
|
resolveCrossingReceiptStorePath,
|
||||||
|
} from "./bifrost-crossing-documents.mjs";
|
||||||
|
import {
|
||||||
|
discordPostCommandDefinition,
|
||||||
|
discordPostCommandDocumentType,
|
||||||
|
discordPostCommandSchemaId,
|
||||||
|
discordPostReceiptDefinition,
|
||||||
|
discordPostReceiptDocumentType,
|
||||||
|
discordPostReceiptSchemaId,
|
||||||
|
} from "./bifrost-discord-command-documents.mjs";
|
||||||
|
|
||||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = resolve(scriptDir, "..");
|
const repoRoot = resolve(scriptDir, "..");
|
||||||
const projectsRoot = resolve(repoRoot, "..");
|
const projectsRoot = resolve(repoRoot, "..");
|
||||||
const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-advertisement.cc");
|
const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-store.cc");
|
||||||
|
const defaultOdinCultMeshUri = "cultmesh://odin/rendezvous/provider-catalog";
|
||||||
|
|
||||||
const cultCacheRequire = createRequire(resolveCultCachePackagePath());
|
const cultCacheRequire = createRequire(resolveCultCachePackagePath());
|
||||||
const cultMeshRequire = createRequire(resolveCultMeshPackagePath());
|
const cultMeshRequire = createRequire(resolveCultMeshPackagePath());
|
||||||
|
|
@ -146,6 +162,8 @@ const currentMachine = "starfire";
|
||||||
const canonicalService = `${rootVerse}.bifrost`;
|
const canonicalService = `${rootVerse}.bifrost`;
|
||||||
const locatedService = `${rootVerse}.${currentMachine}.bifrost`;
|
const locatedService = `${rootVerse}.${currentMachine}.bifrost`;
|
||||||
const plannedLocatedService = `${rootVerse}.yggdrasil.bifrost`;
|
const plannedLocatedService = `${rootVerse}.yggdrasil.bifrost`;
|
||||||
|
const motionSurfaceCultMeshUri = `cultmesh://${locatedService}/eve/governance/surface`;
|
||||||
|
const motionCommandCultMeshUri = `cultmesh://${locatedService}/commands/motion`;
|
||||||
|
|
||||||
const advertisementDefinition = defineDocumentType({
|
const advertisementDefinition = defineDocumentType({
|
||||||
type: documentType,
|
type: documentType,
|
||||||
|
|
@ -153,7 +171,12 @@ const advertisementDefinition = defineDocumentType({
|
||||||
schemaName: documentType,
|
schemaName: documentType,
|
||||||
schemaVersion: schemaId,
|
schemaVersion: schemaId,
|
||||||
schema: {
|
schema: {
|
||||||
parse: parseAdvertisement,
|
// Persisted provider stores can contain an older advertisement revision
|
||||||
|
// beside live command and receipt documents. Decode that generated
|
||||||
|
// discovery metadata so export can replace it without discarding the
|
||||||
|
// command ledger; only newly built advertisements must satisfy the current
|
||||||
|
// contract below.
|
||||||
|
parse: (input) => parseAdvertisement(input, { requireCurrentContract: false }),
|
||||||
},
|
},
|
||||||
name: "providerId",
|
name: "providerId",
|
||||||
indexes: {
|
indexes: {
|
||||||
|
|
@ -215,6 +238,8 @@ async function main() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rejectRemovedHttpProbeConfig(options);
|
||||||
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case "export":
|
case "export":
|
||||||
await exportAdvertisement(options);
|
await exportAdvertisement(options);
|
||||||
|
|
@ -255,6 +280,9 @@ async function exportAdvertisement(options) {
|
||||||
.withDocumentType(advertisementDefinition)
|
.withDocumentType(advertisementDefinition)
|
||||||
.withDocumentType(surfaceDefinition)
|
.withDocumentType(surfaceDefinition)
|
||||||
.withDocumentType(interfaceBindingDefinition)
|
.withDocumentType(interfaceBindingDefinition)
|
||||||
|
.withDocumentType(discordPostCommandDefinition(defineDocumentType))
|
||||||
|
.withDocumentType(discordPostReceiptDefinition(defineDocumentType))
|
||||||
|
.withDocumentType(crossingReceiptDefinition(defineDocumentType))
|
||||||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|
@ -294,15 +322,16 @@ function buildAdvertisement(options) {
|
||||||
endpoint("operator-tui", `${locatedService}/eve/tui`, "gamecult.eve.surface.v1", ["tui", "nightwing-tui"]),
|
endpoint("operator-tui", `${locatedService}/eve/tui`, "gamecult.eve.surface.v1", ["tui", "nightwing-tui"]),
|
||||||
endpoint("operator-gui", `${locatedService}/eve/gui`, "gamecult.eve.surface.v1", ["gui", "browser", "eve-native"]),
|
endpoint("operator-gui", `${locatedService}/eve/gui`, "gamecult.eve.surface.v1", ["gui", "browser", "eve-native"]),
|
||||||
endpoint("operator-commands", `${locatedService}/commands`, "bifrost.bridge_action.v0", ["command"]),
|
endpoint("operator-commands", `${locatedService}/commands`, "bifrost.bridge_action.v0", ["command"]),
|
||||||
endpoint("bridge-action-ledger", `${locatedService}/bridge/actions`, "bifrost.bridge_action.v0", ["command", "bridge", "receipt"]),
|
endpoint("crossing-receipts", `${locatedService}/receipts/crossings`, crossingReceiptSchemaId, ["receipt", "bridge", "cultmesh"]),
|
||||||
|
endpoint("discord-post-commands", `${locatedService}/commands/discord-post`, discordPostCommandSchemaId, ["command", "discord", "cultmesh"]),
|
||||||
|
endpoint("discord-post-receipts", `${locatedService}/receipts/discord-post`, discordPostReceiptSchemaId, ["receipt", "discord", "cultmesh"]),
|
||||||
endpoint("patron-support-intake", `${locatedService}/heimdall/patron-support/events`, "bifrost.patron_support_event.v0", ["heimdall", "patronage", "intake"]),
|
endpoint("patron-support-intake", `${locatedService}/heimdall/patron-support/events`, "bifrost.patron_support_event.v0", ["heimdall", "patronage", "intake"]),
|
||||||
endpoint("github-webhooks", `${locatedService}/github/webhooks`, "bifrost.work_item.v0", ["github", "work-sync", "review-sync"]),
|
endpoint("github-webhooks", `${locatedService}/github/webhooks`, "bifrost.work_item.v0", ["github", "work-sync", "review-sync"]),
|
||||||
endpoint("motion-surface", "https://bifrost.gamecult.org/eve/governance/surface", "gamecult.eve.surface.v1", ["product", "governance", "motion"]),
|
endpoint("motion-surface", motionSurfaceCultMeshUri, "gamecult.eve.surface.v1", ["cultmesh", "governance", "motion"]),
|
||||||
endpoint("motion-commands", "https://bifrost.gamecult.org/eve/governance/commands", "bifrost.motion_command.v0", ["command", "governance", "motion"]),
|
endpoint("motion-commands", motionCommandCultMeshUri, "bifrost.motion_command.v0", ["command", "cultmesh", "governance", "motion"]),
|
||||||
],
|
],
|
||||||
routes: [
|
routes: [
|
||||||
route("cultcache-witness", ".bifrost/provider-advertisement.cc", "local-cultcache", true),
|
route("cultmesh-store", ".bifrost/provider-store.cc", "local-cultmesh", true),
|
||||||
route("websocket-bridge", "ws://192.168.1.66:8797/eve/deck", "compatibility-eve-deck", true),
|
|
||||||
],
|
],
|
||||||
authority: {
|
authority: {
|
||||||
owner: "Bifrost",
|
owner: "Bifrost",
|
||||||
|
|
@ -334,22 +363,25 @@ function buildAdvertisement(options) {
|
||||||
schema("bifrost.ledger_entry.v0", "bifrost.ledger-entry", "planned contributor/patron ledger witness"),
|
schema("bifrost.ledger_entry.v0", "bifrost.ledger-entry", "planned contributor/patron ledger witness"),
|
||||||
schema("bifrost.bridge_action.v0", "bifrost.bridge-action", "current hosted governed crossing command witness"),
|
schema("bifrost.bridge_action.v0", "bifrost.bridge-action", "current hosted governed crossing command witness"),
|
||||||
schema("bifrost.bridge_receipt.v0", "bifrost.bridge-receipt", "current hosted governed crossing result witness"),
|
schema("bifrost.bridge_receipt.v0", "bifrost.bridge-receipt", "current hosted governed crossing result witness"),
|
||||||
|
schema(crossingReceiptSchemaId, crossingReceiptDocumentType, "canonical Bifrost-owned public crossing receipt"),
|
||||||
|
schema(discordPostCommandSchemaId, discordPostCommandDocumentType, "CultMesh command request for Bifrost-owned Discord persona/bot posts"),
|
||||||
|
schema(discordPostReceiptSchemaId, discordPostReceiptDocumentType, "CultMesh receipt for Bifrost-owned Discord persona/bot posts"),
|
||||||
schema("bifrost.patron_support_event.v0", "bifrost.patron-support-event", "current hosted Heimdall-signed patron support fact consumed by Bifrost"),
|
schema("bifrost.patron_support_event.v0", "bifrost.patron-support-event", "current hosted Heimdall-signed patron support fact consumed by Bifrost"),
|
||||||
schema("bifrost.member_capability_snapshot.v0", "bifrost.member-capability-snapshot", "planned Heimdall-consumed membership capability witness"),
|
schema("bifrost.member_capability_snapshot.v0", "bifrost.member-capability-snapshot", "planned Heimdall-consumed membership capability witness"),
|
||||||
],
|
],
|
||||||
witnesses: [
|
witnesses: [
|
||||||
witness(".bifrost/provider-advertisement.cc", schemaId, "current", "read-only provider advertisement exported by this tool"),
|
witness(".bifrost/provider-store.cc", `${schemaId}; ${surfaceSchemaId}; ${interfaceBindingSchemaId}; ${discordPostCommandSchemaId}; ${discordPostReceiptSchemaId}`, "current", "typed provider advertisement, operator surface, Eve interface binding, and Bifrost Discord command request/receipt store"),
|
||||||
witness(".bifrost/governance-threads.cc", "bifrost.governance.topic.v0; bifrost.governance.topic_comment.v0", "current", "governance discussion, approvals, and dispatch promotion topics"),
|
witness(".bifrost/governance-threads.cc", "bifrost.governance.topic.v0; bifrost.governance.topic_comment.v0", "current", "governance discussion, approvals, and dispatch promotion topics"),
|
||||||
witness(".bifrost/agent-transport.cc", "bifrost.agent-transport.update-request.v0", "current", "repo Persona update requests and dispatch queue state"),
|
witness(".bifrost/agent-transport.cc", "bifrost.agent-transport.update-request.v0", "current", "repo Persona update requests and dispatch queue state"),
|
||||||
witness(".bifrost/work-items.cc", "bifrost.work_item.v0", "planned-export", "work items exported from the alpha transactional store"),
|
witness(".bifrost/work-items.cc", "bifrost.work_item.v0", "planned-export", "work items exported from the alpha transactional store"),
|
||||||
witness(".bifrost/motions.cc", "bifrost.motion.v0; bifrost.vote.v0", "planned-export", "app-native motions and votes exported from the alpha transactional store"),
|
witness(".bifrost/motions.cc", "bifrost.motion.v0; bifrost.vote.v0", "planned-export", "app-native motions and votes exported from the alpha transactional store"),
|
||||||
witness(".bifrost/ledger.cc", "bifrost.ledger_entry.v0", "planned-export", "patron and contributor ledger entries exported from the alpha transactional store"),
|
witness(".bifrost/ledger.cc", "bifrost.ledger_entry.v0", "planned-export", "patron and contributor ledger entries exported from the alpha transactional store"),
|
||||||
witness(".bifrost/member-capabilities.cc", "bifrost.member_capability_snapshot.v0", "planned-export", "membership and account capability snapshots consumed by Bifrost"),
|
witness(".bifrost/member-capabilities.cc", "bifrost.member_capability_snapshot.v0", "planned-export", "membership and account capability snapshots consumed by Bifrost"),
|
||||||
witness(".bifrost/bridge-receipts.cc", "bifrost.bridge_action.v0; bifrost.bridge_receipt.v0", "planned-export", "governed public crossing actions and receipts"),
|
witness(".bifrost/bridge-receipts.cc", crossingReceiptSchemaId, "current", "canonical governed public crossing receipt history"),
|
||||||
witness(".bifrost/eve-surfaces.cc", "gamecult.eve.surface.v1", "planned-export", "product and operator Eve/CultUI surface publications"),
|
witness(".bifrost/eve-surfaces.cc", "gamecult.eve.surface.v1", "planned-export", "future product Eve/CultUI surfaces not yet in the provider store"),
|
||||||
],
|
],
|
||||||
surfaces: [
|
surfaces: [
|
||||||
surface("bifrost", "Bifrost Operator Dashboard", "gamecult.bifrost.surface.operator", "gamecult.eve.surface_state.v1", ".bifrost/provider-advertisement.cc", "eve", [
|
surface("bifrost", "Bifrost Operator Dashboard", "gamecult.bifrost.surface.operator", "gamecult.eve.surface_state.v1", ".bifrost/provider-store.cc", "eve", [
|
||||||
"service health",
|
"service health",
|
||||||
"compact service status",
|
"compact service status",
|
||||||
"topic and request status",
|
"topic and request status",
|
||||||
|
|
@ -445,12 +477,15 @@ function buildAdvertisement(options) {
|
||||||
"prepare governed public crossings",
|
"prepare governed public crossings",
|
||||||
"execute approved handoffs",
|
"execute approved handoffs",
|
||||||
"record receipts",
|
"record receipts",
|
||||||
|
"own canonical bifrost.crossing_receipt.v1 documents for every crossing attempt",
|
||||||
"open GitHub draft PRs and PR comments through Bifrost gate",
|
"open GitHub draft PRs and PR comments through Bifrost gate",
|
||||||
"post Discord messages and DMs through Bifrost gate with Heimdall-linked actor capability",
|
"post Discord messages and DMs through Bifrost gate with Heimdall-linked actor capability",
|
||||||
"record receipt-only future-surface requests with named-surface Heimdall capability matching before named actuators exist",
|
"record receipt-only future-surface requests with named-surface Heimdall capability matching before named actuators exist",
|
||||||
"post Persona-flaired Reddit organizing threads",
|
"post Persona-flaired Reddit organizing threads",
|
||||||
|
"accept typed CultMesh Discord post command requests and write typed Discord post receipts",
|
||||||
], [
|
], [
|
||||||
"does not treat local protocol JSON as work authority",
|
"does not treat local protocol JSON as work authority",
|
||||||
|
"surface-specific receipts cannot decide crossing completion",
|
||||||
"does not touch secrets in this advertisement",
|
"does not touch secrets in this advertisement",
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
|
@ -463,40 +498,54 @@ function buildAdvertisement(options) {
|
||||||
],
|
],
|
||||||
demotions: [
|
demotions: [
|
||||||
"Razor Pages are browser lowerings, not the canonical presentation owner.",
|
"Razor Pages are browser lowerings, not the canonical presentation owner.",
|
||||||
"HTTP health/readiness JSON is a probe, not service truth.",
|
"HTTP health/readiness JSON is product smoke output only; provider readiness comes from typed CultMesh/Idunn state.",
|
||||||
"Discord messages are mirrors and input surfaces until Bifrost commits typed state.",
|
"Discord messages are mirrors and input surfaces until Bifrost commits typed state.",
|
||||||
"Reddit threads are organizing surfaces until Bifrost commits typed votes, priority signals, comments, or receipts.",
|
"Reddit threads are organizing surfaces until Bifrost commits typed votes, priority signals, comments, or receipts.",
|
||||||
"Local dispatch JSON is evidence for receipts, not command authority.",
|
"Local dispatch JSON is evidence for receipts, not command authority.",
|
||||||
"This advertisement is read-only discovery metadata and does not migrate runtime state.",
|
"This advertisement is read-only discovery metadata and does not migrate runtime state.",
|
||||||
],
|
],
|
||||||
|
commands: [
|
||||||
|
command("discord.post", "write", "Write a bifrost.bridge.discord_post_command.v1 document into the provider CultMesh store and wait for a bifrost.bridge.discord_post_receipt.v1 receipt.", {
|
||||||
|
transport: "cultmesh-command-document",
|
||||||
|
commandDocumentType: discordPostCommandDocumentType,
|
||||||
|
commandSchemaId: discordPostCommandSchemaId,
|
||||||
|
receiptDocumentType: discordPostReceiptDocumentType,
|
||||||
|
receiptSchemaId: discordPostReceiptSchemaId,
|
||||||
|
storeRoute: ".bifrost/provider-store.cc",
|
||||||
|
cultMeshUri: `${locatedService}/commands/discord-post`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectStats(options) {
|
async function collectStats(options) {
|
||||||
const generatedAt = options["generated-at"] ?? new Date().toISOString();
|
const generatedAt = options["generated-at"] ?? new Date().toISOString();
|
||||||
const health = await fetchProbe("http://127.0.0.1:5080/healthz");
|
|
||||||
const ready = await fetchProbe("http://127.0.0.1:5080/readyz");
|
|
||||||
const transport = runJsonTool(["tools/agent-transport.mjs", "list", "--json"]);
|
const transport = runJsonTool(["tools/agent-transport.mjs", "list", "--json"]);
|
||||||
const governance = runJsonTool(["tools/governance-threads.mjs", "list", "--json"]);
|
const governance = runJsonTool(["tools/governance-threads.mjs", "list", "--json"]);
|
||||||
|
const crossingReceipts = await readCrossingReceipts(options);
|
||||||
const docker = dockerContainers();
|
const docker = dockerContainers();
|
||||||
const witnesses = [
|
const witnesses = [
|
||||||
witnessStat(".bifrost/provider-advertisement.cc"),
|
witnessStat(".bifrost/provider-store.cc"),
|
||||||
witnessStat(".bifrost/governance-threads.cc"),
|
witnessStat(".bifrost/governance-threads.cc"),
|
||||||
witnessStat(".bifrost/agent-transport.cc"),
|
witnessStat(".bifrost/agent-transport.cc"),
|
||||||
|
witnessStat(".bifrost/bridge-receipts.cc"),
|
||||||
witnessStat(".bifrost/discord-webhook-cache.json"),
|
witnessStat(".bifrost/discord-webhook-cache.json"),
|
||||||
];
|
];
|
||||||
const topics = Array.isArray(governance.value) ? governance.value : [];
|
const topics = Array.isArray(governance.value) ? governance.value : [];
|
||||||
const requests = Array.isArray(transport.value) ? transport.value : [];
|
const requests = Array.isArray(transport.value) ? transport.value : [];
|
||||||
const topicCounts = countBy(topics, "status");
|
const topicCounts = countBy(topics, "status");
|
||||||
const requestCounts = countBy(requests, "status");
|
const requestCounts = countBy(requests, "status");
|
||||||
|
const receiptCounts = countBy(crossingReceipts.value, "status");
|
||||||
const recentWindowHours = 24;
|
const recentWindowHours = 24;
|
||||||
const dockerHealthy = docker.items.filter((item) => /healthy/i.test(item.status)).length;
|
const dockerHealthy = docker.items.filter((item) => /healthy/i.test(item.status)).length;
|
||||||
const dockerRunning = docker.items.filter((item) => /^Up\b/i.test(item.status)).length;
|
const dockerRunning = docker.items.filter((item) => /^Up\b/i.test(item.status)).length;
|
||||||
|
const bridge = buildBridgeStats();
|
||||||
|
const service = buildTypedServiceStatus({ witnesses, transport, governance, bridge });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generatedAt,
|
generatedAt,
|
||||||
health,
|
health: service.health,
|
||||||
ready,
|
ready: service.ready,
|
||||||
docker,
|
docker,
|
||||||
governance: {
|
governance: {
|
||||||
ok: governance.ok,
|
ok: governance.ok,
|
||||||
|
|
@ -518,16 +567,27 @@ async function collectStats(options) {
|
||||||
recentChannelCounts: countRecentBy(requests, "sourceKind", recentWindowHours),
|
recentChannelCounts: countRecentBy(requests, "sourceKind", recentWindowHours),
|
||||||
latestUpdatedAt: latestTimestamp(requests.map((item) => item.updatedAt)),
|
latestUpdatedAt: latestTimestamp(requests.map((item) => item.updatedAt)),
|
||||||
},
|
},
|
||||||
|
crossingReceipts: {
|
||||||
|
ok: crossingReceipts.ok,
|
||||||
|
error: crossingReceipts.error,
|
||||||
|
count: crossingReceipts.value.length,
|
||||||
|
statusCounts: receiptCounts,
|
||||||
|
recentCount: countRecent(crossingReceipts.value, recentWindowHours),
|
||||||
|
gapRows: buildReceiptGapRows(crossingReceipts.value),
|
||||||
|
latestUpdatedAt: latestTimestamp(crossingReceipts.value.map((item) => item.completedAt || item.startedAt || item.requestedAt)),
|
||||||
|
},
|
||||||
witnesses,
|
witnesses,
|
||||||
bridge: buildBridgeStats(),
|
bridge,
|
||||||
summary: {
|
summary: {
|
||||||
status: health.ok && ready.ok ? "ready" : "degraded",
|
status: service.ready.ok ? "ready" : "degraded",
|
||||||
health: health.value ?? health.error,
|
health: service.health.value,
|
||||||
ready: ready.value ?? ready.error,
|
ready: service.ready.value,
|
||||||
dockerRunning,
|
dockerRunning,
|
||||||
dockerHealthy,
|
dockerHealthy,
|
||||||
governanceTopics: topics.length,
|
governanceTopics: topics.length,
|
||||||
transportRequests: requests.length,
|
transportRequests: requests.length,
|
||||||
|
crossingReceipts: crossingReceipts.value.length,
|
||||||
|
crossingReceiptGaps: buildReceiptGapRows(crossingReceipts.value).length,
|
||||||
recentGovernanceTopics: countRecent(topics, recentWindowHours),
|
recentGovernanceTopics: countRecent(topics, recentWindowHours),
|
||||||
recentTransportRequests: countRecent(requests, recentWindowHours),
|
recentTransportRequests: countRecent(requests, recentWindowHours),
|
||||||
openTopics: topicCounts.open ?? 0,
|
openTopics: topicCounts.open ?? 0,
|
||||||
|
|
@ -537,26 +597,67 @@ async function collectStats(options) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchProbe(url) {
|
async function readCrossingReceipts(options) {
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const storePath = resolveCrossingReceiptStorePath(options);
|
||||||
const timeout = setTimeout(() => controller.abort(), 2500);
|
if (!existsSync(storePath)) {
|
||||||
const response = await fetch(url, { signal: controller.signal });
|
return { ok: true, error: "", value: [] };
|
||||||
clearTimeout(timeout);
|
}
|
||||||
const text = (await response.text()).trim();
|
const store = await openCrossingReceiptStore(storePath);
|
||||||
return {
|
return { ok: true, error: "", value: store.getAll() };
|
||||||
ok: response.ok,
|
|
||||||
statusCode: response.status,
|
|
||||||
value: text,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
value: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildReceiptGapRows(receipts) {
|
||||||
|
return receipts
|
||||||
|
.filter((receipt) => receipt.status === "failed" || receipt.status === "running" || receipt.status === "requested")
|
||||||
|
.sort((left, right) => String(right.requestedAt).localeCompare(String(left.requestedAt)))
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((receipt) =>
|
||||||
|
`${receipt.receiptId}: ${receipt.crossingKind} ${receipt.status}; source=${receipt.source?.kind || "unknown"}/${receipt.source?.id || "unknown"}; target=${receipt.target?.surface || "unknown"} ${receipt.target?.locator || ""}`.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTypedServiceStatus({ witnesses, transport, governance, bridge }) {
|
||||||
|
const providerStore = witnesses.find((item) => item.path === ".bifrost/provider-store.cc");
|
||||||
|
const storesReady = witnesses.every((item) => item.exists);
|
||||||
|
const healthOk = Boolean(providerStore?.exists) && transport.ok && governance.ok;
|
||||||
|
const readyOk = healthOk && storesReady && bridge.ready;
|
||||||
|
return {
|
||||||
|
health: {
|
||||||
|
ok: healthOk,
|
||||||
|
value: healthOk
|
||||||
|
? `typed-store ${providerStore.path} / command stores readable`
|
||||||
|
: `typed-store ${providerStore?.exists ? "present" : "missing"} / command stores ${transport.ok && governance.ok ? "readable" : "degraded"}`,
|
||||||
|
},
|
||||||
|
ready: {
|
||||||
|
ok: readyOk,
|
||||||
|
value: readyOk
|
||||||
|
? "CultMesh command/surface store ready"
|
||||||
|
: `CultMesh command/surface store ${storesReady ? "present" : "incomplete"} / bridge ${bridge.ready ? "ready" : "not-ready"}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectRemovedHttpProbeConfig(options) {
|
||||||
|
const removed = [
|
||||||
|
["--health-url", options["health-url"]],
|
||||||
|
["--ready-url", options["ready-url"]],
|
||||||
|
["BIFROST_HEALTH_URL", process.env.BIFROST_HEALTH_URL],
|
||||||
|
["BIFROST_READY_URL", process.env.BIFROST_READY_URL],
|
||||||
|
].filter(([, value]) => optionalString(value));
|
||||||
|
|
||||||
|
if (removed.length > 0) {
|
||||||
|
throw new Error(`${removed.map(([name]) => name).join(" / ")} were removed from provider advertisement status. Publish Bifrost readiness through typed CultMesh/Idunn state instead of HTTP probes.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function runJsonTool(args) {
|
function runJsonTool(args) {
|
||||||
const result = spawnSync(process.execPath, args, {
|
const result = spawnSync(process.execPath, args, {
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
|
|
@ -637,16 +738,19 @@ function witnessStat(relativePath) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publishOdinAdvertisement(options) {
|
async function publishOdinAdvertisement(options) {
|
||||||
const endpoint = options["odin-cultmesh-rudp"] ?? options.odin ?? process.env.BIFROST_ODIN_CULTMESH_RUDP;
|
if (options["odin-cultmesh-rudp"] || process.env.BIFROST_ODIN_CULTMESH_RUDP) {
|
||||||
if (!endpoint) {
|
throw new Error("--odin-cultmesh-rudp and BIFROST_ODIN_CULTMESH_RUDP were removed. Configure --odin-cultmesh-uri cultmesh://odin/rendezvous/provider-catalog and let CultMesh resolve Odin's transport.");
|
||||||
throw new Error("Bifrost Odin publication requires --odin-cultmesh-rudp <host:port> or BIFROST_ODIN_CULTMESH_RUDP.");
|
}
|
||||||
|
const odinCultMeshUri = options["odin-cultmesh-uri"] ?? options.odin ?? process.env.BIFROST_ODIN_CULTMESH_URI ?? defaultOdinCultMeshUri;
|
||||||
|
if (!odinCultMeshUri) {
|
||||||
|
throw new Error("Bifrost Odin publication requires an Odin CultMesh URI.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const advertisement = buildAdvertisement(options);
|
const advertisement = buildAdvertisement(options);
|
||||||
await CultMesh.publishRudpDocumentOnce(
|
await CultMesh.publishRudpDocumentOnce(
|
||||||
"bifrost",
|
"bifrost",
|
||||||
0x0d1d0002,
|
0x0d1d0002,
|
||||||
normalizeRudpEndpoint(endpoint),
|
odinCultMeshUri,
|
||||||
defineCultNetDocumentBinding({ definition: advertisementDefinition }),
|
defineCultNetDocumentBinding({ definition: advertisementDefinition }),
|
||||||
advertisement.providerId,
|
advertisement.providerId,
|
||||||
advertisement,
|
advertisement,
|
||||||
|
|
@ -661,7 +765,7 @@ async function publishOdinAdvertisement(options) {
|
||||||
documentType,
|
documentType,
|
||||||
schemaId,
|
schemaId,
|
||||||
providerId: advertisement.providerId,
|
providerId: advertisement.providerId,
|
||||||
odinCultMeshRudp: endpoint,
|
odinCultMeshUri,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -674,17 +778,13 @@ function buildBridgeStats() {
|
||||||
const redditCredentialSource = process.env.BIFROST_REDDIT_CLIENT_ID && process.env.BIFROST_REDDIT_REFRESH_TOKEN
|
const redditCredentialSource = process.env.BIFROST_REDDIT_CLIENT_ID && process.env.BIFROST_REDDIT_REFRESH_TOKEN
|
||||||
? "BIFROST_REDDIT_CLIENT_ID+BIFROST_REDDIT_REFRESH_TOKEN"
|
? "BIFROST_REDDIT_CLIENT_ID+BIFROST_REDDIT_REFRESH_TOKEN"
|
||||||
: "missing";
|
: "missing";
|
||||||
const bridgeLedgerCredentialSource = process.env.BIFROST_BRIDGE_BASE_URL && process.env.BIFROST_BRIDGE_TOKEN
|
|
||||||
? "BIFROST_BRIDGE_BASE_URL+BIFROST_BRIDGE_TOKEN"
|
|
||||||
: "missing";
|
|
||||||
|
|
||||||
const surfaces = [
|
const surfaces = [
|
||||||
{
|
{
|
||||||
id: "github",
|
id: "github",
|
||||||
label: "GitHub",
|
label: "GitHub",
|
||||||
prepared: true,
|
prepared: true,
|
||||||
ready: true,
|
ready: true,
|
||||||
authority: "Bifrost bridge gate plus GitHub webhook sync",
|
authority: "Bifrost typed CultMesh command gate plus GitHub webhook sync",
|
||||||
credentialSource: "GitHub app/OAuth/gh runtime",
|
credentialSource: "GitHub app/OAuth/gh runtime",
|
||||||
note: "draft PRs, PR comments, and webhook work sync are hooked",
|
note: "draft PRs, PR comments, and webhook work sync are hooked",
|
||||||
},
|
},
|
||||||
|
|
@ -693,7 +793,7 @@ function buildBridgeStats() {
|
||||||
label: "Discord",
|
label: "Discord",
|
||||||
prepared: true,
|
prepared: true,
|
||||||
ready: discordCredentialSource !== "missing",
|
ready: discordCredentialSource !== "missing",
|
||||||
authority: "Bifrost bridge gate plus Heimdall-linked account/capability reference",
|
authority: "Bifrost typed CultMesh command gate plus Heimdall-linked account/capability reference",
|
||||||
credentialSource: discordCredentialSource,
|
credentialSource: discordCredentialSource,
|
||||||
note: discordCredentialSource === "missing"
|
note: discordCredentialSource === "missing"
|
||||||
? "transport actuator token is not visible to this process"
|
? "transport actuator token is not visible to this process"
|
||||||
|
|
@ -704,7 +804,7 @@ function buildBridgeStats() {
|
||||||
label: "Reddit",
|
label: "Reddit",
|
||||||
prepared: true,
|
prepared: true,
|
||||||
ready: redditCredentialSource !== "missing",
|
ready: redditCredentialSource !== "missing",
|
||||||
authority: "Bifrost bridge gate plus Heimdall-linked reddit capability reference",
|
authority: "Bifrost typed CultMesh command gate plus Heimdall-linked reddit capability reference",
|
||||||
credentialSource: redditCredentialSource,
|
credentialSource: redditCredentialSource,
|
||||||
note: redditCredentialSource === "missing"
|
note: redditCredentialSource === "missing"
|
||||||
? "reddit transport credentials are not visible to this process"
|
? "reddit transport credentials are not visible to this process"
|
||||||
|
|
@ -714,10 +814,10 @@ function buildBridgeStats() {
|
||||||
id: "other",
|
id: "other",
|
||||||
label: "Other",
|
label: "Other",
|
||||||
prepared: true,
|
prepared: true,
|
||||||
ready: true,
|
ready: false,
|
||||||
authority: "Bifrost receipt-only future-surface gate with named Heimdall reference",
|
authority: "typed CultMesh command contract required before use",
|
||||||
credentialSource: "not-required-for-receipt-only",
|
credentialSource: "not-configured",
|
||||||
note: "records governed future-surface requests without provider transport",
|
note: "the old receipt-only HTTP bridge hatch is removed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "patron",
|
id: "patron",
|
||||||
|
|
@ -739,10 +839,10 @@ function buildBridgeStats() {
|
||||||
githubWebhookSync: true,
|
githubWebhookSync: true,
|
||||||
otherRequest: true,
|
otherRequest: true,
|
||||||
patronSupportIntake: true,
|
patronSupportIntake: true,
|
||||||
bridgeLedgerConfigured: bridgeLedgerCredentialSource !== "missing",
|
|
||||||
credentialSource: discordCredentialSource,
|
credentialSource: discordCredentialSource,
|
||||||
redditCredentialSource,
|
redditCredentialSource,
|
||||||
bridgeLedgerCredentialSource,
|
bridgeLedgerConfigured: false,
|
||||||
|
bridgeLedgerCredentialSource: "removed-http-bridge",
|
||||||
patronSupportAuthority: "Heimdall HMAC via /heimdall/patron-support/events",
|
patronSupportAuthority: "Heimdall HMAC via /heimdall/patron-support/events",
|
||||||
prepared: surfaces.every((surface) => surface.prepared),
|
prepared: surfaces.every((surface) => surface.prepared),
|
||||||
ready: surfaces.every((surface) => surface.ready),
|
ready: surfaces.every((surface) => surface.ready),
|
||||||
|
|
@ -778,10 +878,13 @@ function buildOperatorSurface(stats) {
|
||||||
panelNode("activity", "Activity", [
|
panelNode("activity", "Activity", [
|
||||||
metricNode("topics", "Topics", `${stats.summary.governanceTopics} total / ${stats.summary.openTopics} open / ${stats.summary.recentGovernanceTopics} in ${stats.summary.recentWindowHours}h`, stats.governance.ok ? "ok" : "warn"),
|
metricNode("topics", "Topics", `${stats.summary.governanceTopics} total / ${stats.summary.openTopics} open / ${stats.summary.recentGovernanceTopics} in ${stats.summary.recentWindowHours}h`, stats.governance.ok ? "ok" : "warn"),
|
||||||
metricNode("requests", "Requests", `${stats.summary.transportRequests} total / ${stats.summary.queuedRequests} queued / ${stats.summary.recentTransportRequests} in ${stats.summary.recentWindowHours}h`, stats.transport.ok ? "ok" : "warn"),
|
metricNode("requests", "Requests", `${stats.summary.transportRequests} total / ${stats.summary.queuedRequests} queued / ${stats.summary.recentTransportRequests} in ${stats.summary.recentWindowHours}h`, stats.transport.ok ? "ok" : "warn"),
|
||||||
|
metricNode("crossing-receipts", "Crossing Receipts", `${stats.summary.crossingReceipts} total / ${stats.summary.crossingReceiptGaps} gaps`, stats.crossingReceipts.ok && stats.summary.crossingReceiptGaps === 0 ? "ok" : "warn"),
|
||||||
listNode("status", "Status", [
|
listNode("status", "Status", [
|
||||||
`topics: ${compactCounts(stats.governance.statusCounts)}`,
|
`topics: ${compactCounts(stats.governance.statusCounts)}`,
|
||||||
`requests: ${compactCounts(stats.transport.statusCounts)}`,
|
`requests: ${compactCounts(stats.transport.statusCounts)}`,
|
||||||
|
`crossing receipts: ${compactCounts(stats.crossingReceipts.statusCounts)}`,
|
||||||
]),
|
]),
|
||||||
|
listNode("receipt-gaps", "Receipt Gaps", stats.crossingReceipts.gapRows),
|
||||||
listNode("channels", "Dispatch Channels", compactChannelLines(stats.transport.channelCounts, stats.transport.recentChannelCounts, stats.summary.recentWindowHours)),
|
listNode("channels", "Dispatch Channels", compactChannelLines(stats.transport.channelCounts, stats.transport.recentChannelCounts, stats.summary.recentWindowHours)),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
@ -796,6 +899,17 @@ function buildOperatorSurface(stats) {
|
||||||
schema: "gamecult.eve.surface.v1",
|
schema: "gamecult.eve.surface.v1",
|
||||||
id: "bifrost-operator-dashboard",
|
id: "bifrost-operator-dashboard",
|
||||||
title: "Bifrost Operator Dashboard",
|
title: "Bifrost Operator Dashboard",
|
||||||
|
commands: [
|
||||||
|
command("discord.post", "write", "Post to Discord through Bifrost's CultMesh command request/receipt documents.", {
|
||||||
|
transport: "cultmesh-command-document",
|
||||||
|
commandDocumentType: discordPostCommandDocumentType,
|
||||||
|
commandSchemaId: discordPostCommandSchemaId,
|
||||||
|
receiptDocumentType: discordPostReceiptDocumentType,
|
||||||
|
receiptSchemaId: discordPostReceiptSchemaId,
|
||||||
|
storeRoute: ".bifrost/provider-store.cc",
|
||||||
|
cultMeshUri: `${locatedService}/commands/discord-post`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
root: {
|
root: {
|
||||||
id: "bifrost-root",
|
id: "bifrost-root",
|
||||||
kind: "dashboard",
|
kind: "dashboard",
|
||||||
|
|
@ -833,8 +947,7 @@ function buildInterfaceBinding(surface, stats) {
|
||||||
endpoint("operator-gui", `${locatedService}/eve/gui`, "gamecult.eve.surface.v1", ["gui", "browser", "eve-native"]),
|
endpoint("operator-gui", `${locatedService}/eve/gui`, "gamecult.eve.surface.v1", ["gui", "browser", "eve-native"]),
|
||||||
],
|
],
|
||||||
routes: [
|
routes: [
|
||||||
route("cultcache-witness", ".bifrost/provider-advertisement.cc", "local-cultcache", true),
|
route("cultmesh-store", ".bifrost/provider-store.cc", "local-cultmesh", true),
|
||||||
route("websocket-bridge", "ws://192.168.1.66:8797/eve/deck", "compatibility-eve-deck", true),
|
|
||||||
],
|
],
|
||||||
capabilities: [
|
capabilities: [
|
||||||
"operator-stats",
|
"operator-stats",
|
||||||
|
|
@ -1008,6 +1121,16 @@ function boundary(area, owner, commands, forbiddenAuthority) {
|
||||||
return { area, owner, commands, forbiddenAuthority };
|
return { area, owner, commands, forbiddenAuthority };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function command(id, mode, purpose, metadata = {}) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
command: id,
|
||||||
|
mode,
|
||||||
|
purpose,
|
||||||
|
...metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function style(area, capabilities) {
|
function style(area, capabilities) {
|
||||||
return { area, capabilities };
|
return { area, capabilities };
|
||||||
}
|
}
|
||||||
|
|
@ -1020,7 +1143,7 @@ function route(id, address, transport, demoted) {
|
||||||
return { id, address, transport, demoted };
|
return { id, address, transport, demoted };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAdvertisement(input) {
|
function parseAdvertisement(input, { requireCurrentContract = true } = {}) {
|
||||||
if (!input || typeof input !== "object") {
|
if (!input || typeof input !== "object") {
|
||||||
throw new Error("Provider advertisement must be an object.");
|
throw new Error("Provider advertisement must be an object.");
|
||||||
}
|
}
|
||||||
|
|
@ -1047,6 +1170,10 @@ function parseAdvertisement(input) {
|
||||||
demotions: requireStringArray(input.demotions, "demotions"),
|
demotions: requireStringArray(input.demotions, "demotions"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!requireCurrentContract) {
|
||||||
|
return advertisement;
|
||||||
|
}
|
||||||
|
|
||||||
const requiredSurfaces = new Set(["bifrost.work", "bifrost.motion", "bifrost.patron", "bifrost.project", "bifrost.account"]);
|
const requiredSurfaces = new Set(["bifrost.work", "bifrost.motion", "bifrost.patron", "bifrost.project", "bifrost.account"]);
|
||||||
const surfaceIds = new Set(advertisement.surfaces.map((surface) => surface.id));
|
const surfaceIds = new Set(advertisement.surfaces.map((surface) => surface.id));
|
||||||
for (const id of requiredSurfaces) {
|
for (const id of requiredSurfaces) {
|
||||||
|
|
@ -1055,7 +1182,7 @@ function parseAdvertisement(input) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requiredEndpoints = new Set(["bridge-action-ledger", "patron-support-intake", "github-webhooks"]);
|
const requiredEndpoints = new Set(["crossing-receipts", "discord-post-commands", "discord-post-receipts", "patron-support-intake", "github-webhooks"]);
|
||||||
const endpointIds = new Set(advertisement.endpoints.map((endpoint) => endpoint.id));
|
const endpointIds = new Set(advertisement.endpoints.map((endpoint) => endpoint.id));
|
||||||
for (const id of requiredEndpoints) {
|
for (const id of requiredEndpoints) {
|
||||||
if (!endpointIds.has(id)) {
|
if (!endpointIds.has(id)) {
|
||||||
|
|
@ -1063,7 +1190,7 @@ function parseAdvertisement(input) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requiredSchemas = new Set(["bifrost.bridge_action.v0", "bifrost.bridge_receipt.v0", "bifrost.patron_support_event.v0"]);
|
const requiredSchemas = new Set([crossingReceiptSchemaId, "bifrost.bridge_action.v0", "bifrost.bridge_receipt.v0", "bifrost.patron_support_event.v0"]);
|
||||||
const schemaIds = new Set(advertisement.schemas.map((schema) => schema.id));
|
const schemaIds = new Set(advertisement.schemas.map((schema) => schema.id));
|
||||||
for (const id of requiredSchemas) {
|
for (const id of requiredSchemas) {
|
||||||
if (!schemaIds.has(id)) {
|
if (!schemaIds.has(id)) {
|
||||||
|
|
@ -1148,14 +1275,6 @@ function resolveOptionPath(path) {
|
||||||
return resolve(process.cwd(), path);
|
return resolve(process.cwd(), path);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRudpEndpoint(endpoint) {
|
|
||||||
const text = String(endpoint || "").trim();
|
|
||||||
if (!text) {
|
|
||||||
throw new Error("Odin CultMesh/RUDP endpoint must be non-empty.");
|
|
||||||
}
|
|
||||||
return text.toLowerCase().startsWith("rudp://") ? text : `rudp://${text}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function printJson(value) {
|
function printJson(value) {
|
||||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||||
}
|
}
|
||||||
|
|
@ -1164,24 +1283,25 @@ function printHelp() {
|
||||||
process.stdout.write(`Bifrost Eve provider advertisement
|
process.stdout.write(`Bifrost Eve provider advertisement
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
export Write advertisement, operator surface, and interface binding to a CultCache .cc witness
|
export Write advertisement, operator surface, and interface binding to the Bifrost provider store
|
||||||
publish-odin Publish the provider advertisement once to Odin over CultMesh/RUDP
|
publish-odin Publish the provider advertisement once to Odin through its CultMesh rendezvous URI
|
||||||
print Print the advertisement as protocol-debug JSON without writing state
|
print Print the advertisement as protocol-debug JSON without writing state
|
||||||
print-surface Print the current operator Eve surface JSON without writing state
|
print-surface Print the current operator Eve surface JSON without writing state
|
||||||
print-binding Print the current Eve interface binding JSON without writing state
|
print-binding Print the current Eve interface binding JSON without writing state
|
||||||
schema Print document type metadata
|
schema Print document type metadata
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--out <path> Override export path; defaults to .bifrost/provider-advertisement.cc
|
--out <path> Override export path; defaults to .bifrost/provider-store.cc
|
||||||
--odin-cultmesh-rudp <host:port>
|
--odin-cultmesh-uri <cultmesh-uri>
|
||||||
Odin CultMesh/RUDP document catalog endpoint; also reads BIFROST_ODIN_CULTMESH_RUDP
|
Odin CultMesh rendezvous URI; also reads BIFROST_ODIN_CULTMESH_URI
|
||||||
--generated-at <iso> Pin generatedAt for deterministic fixture checks
|
--generated-at <iso> Pin generatedAt for deterministic fixture checks
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
node tools/provider-advertisement.mjs print
|
node tools/provider-advertisement.mjs print
|
||||||
node tools/provider-advertisement.mjs print-binding
|
node tools/provider-advertisement.mjs print-binding
|
||||||
node tools/provider-advertisement.mjs export --out .bifrost/provider-advertisement.cc
|
node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc
|
||||||
node tools/provider-advertisement.mjs publish-odin --odin-cultmesh-rudp 127.0.0.1:17871
|
node tools/provider-advertisement.mjs print-surface
|
||||||
|
$env:BIFROST_ODIN_CULTMESH_URI="cultmesh://odin/rendezvous/provider-catalog"; node tools/provider-advertisement.mjs publish-odin
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue