Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 2 additions & 15 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ rust-version = "1.95"
# #255). Deliberately the only frontend dependency: this half of the arc
# stays permissive and never links the GPL `rash` JIT (that boundary
# lives in scratch-rs). See OGAR docs/BLOCK-EDITOR-PLAN.md.
ogar-blockly = { path = "../OGAR/crates/ogar-blockly" }
# The vocabulary-agnostic call ABI the palette rides on, and the owner of
# the node shapes themselves (`LocoConcept::{FunctionBody, Inventory}`).
# Post-flip, blockly-abi consumes these types directly; its own local
Expand Down
49 changes: 45 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,61 @@
# editor itself loads in the *browser* from a version-pinned CDN build, per
# the arc's standing rule (JS drags puzzle pieces; Rust owns semantics).
#
# The build fetches the `ogar-blockly` git dependency from the public OGAR
# repository; no token is required.
# ── Why the sibling clone ───────────────────────────────────────────────
#
# This repo depends on exactly ONE external crate: `ogar-loco`, the
# substrate (call encoding, lane carvings, node layout, shared core). It is
# a PATH dependency to a sibling checkout, because a git dependency writes a
# rev pin into Cargo.lock and pins are forbidden here.
#
# Railway's build context is this repo alone, so `../OGAR` does not exist
# and `cargo` fails at manifest load — before compiling anything. The
# builder therefore clones the sibling into the layout the manifest already
# expects. OGAR is public: no token, no build secret.
#
# `--depth 1` on the default branch is deliberate — cloning a moving branch
# rather than a fixed rev is what keeps this pin-free. The trade is real and
# accepted: an image is reproducible only against OGAR's HEAD at build time.
# A deploy that needs a fixed substrate rebuilds from a fixed checkout; it
# does not get there by adding a rev here.
#
# ── Cache-busting the clone (learned the hard way) ─────────────────────
#
# "OGAR's HEAD at build time" only holds if the clone actually reruns. A
# `RUN git clone <fixed url>` line is verbatim-identical on every build, so
# Docker's (and Railway's) layer cache treats it as immutable and reuses
# whatever checkout it first produced — silently, forever, with no signal
# that it happened. A build that ran before an OGAR crate was renamed or
# removed then keeps failing (or worse, keeps *succeeding* against a stale
# substrate) on every deploy after the fix has already landed upstream, and
# nothing in the Dockerfile text changed to explain why.
#
# The `ADD <url>` below is the standard bust: BuildKit re-fetches it on every
# build and conditionally invalidates the layer (and everything after it) the
# moment OGAR's `main` tip actually moves — a real signal instead of an
# accidental one. It costs one tiny JSON response, not a second clone.
#
# Railway: binds 0.0.0.0:$PORT — PORT is injected by the platform, and 8080
# below is only the local-run fallback, never a pin.

FROM rust:1.95-bookworm AS builder
WORKDIR /src
WORKDIR /build

# Cache-buster: re-fetched every build, invalidates the clone layer below
# exactly when OGAR's main tip has actually moved. See the note above.
ADD https://api.github.com/repos/AdaWorldAPI/OGAR/commits/main /tmp/ogar-head.json

# The sibling first, so an OGAR-only change does not invalidate the layer
# holding this repo's sources.
RUN git clone --depth 1 https://github.com/AdaWorldAPI/OGAR.git /build/OGAR

WORKDIR /build/blockly-rs
COPY . .
RUN cargo build --release -p blockly-web

FROM debian:bookworm-slim
RUN useradd --system --no-create-home blockly
COPY --from=builder /src/target/release/blockly-web /usr/local/bin/blockly-web
COPY --from=builder /build/blockly-rs/target/release/blockly-web /usr/local/bin/blockly-web
USER blockly
ENV PORT=8080
EXPOSE 8080
Expand Down
1 change: 0 additions & 1 deletion crates/blockly-abi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,4 @@ rust-version.workspace = true
description = "The cast between a Blockly workspace block record and the V3 ABI-shaped SoA function body — semantics only, presentation held separately, zero serialization. Post-order lowering (a block's nested inputs are its operands) and the inverse raise."

[dependencies]
ogar-blockly = { workspace = true }
ogar-loco = { workspace = true }
8 changes: 4 additions & 4 deletions crates/blockly-abi/src/codebook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
//! `PROC_ARG` has no dedicated block: argument reads go through an ordinary
//! `variables_get` bound to the argument's variable model.

use ogar_blockly::FnIndex;
use ogar_loco::FnIndex;

/// What a block's dropdown code does during resolution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -679,16 +679,16 @@ mod tests {
// no Blockly-core counterpart.
for (ty, _, byte) in pinned {
assert!(
*byte < ogar_blockly::DEVICE_FAMILY_FLOOR,
*byte < crate::palette::DEVICE_FAMILY_FLOOR,
"{ty} reached the reserved device family"
);
}
// …and the floor is a real boundary, not a vacuous one: it must sit
// ABOVE every pin (or the loop above would be impossible) and BELOW
// the top of the byte range (or "below the floor" would exclude
// nothing and the loop would be free).
const { assert!(ogar_blockly::DEVICE_FAMILY_FLOOR < u8::MAX) };
const { assert!(crate::palette::DEVICE_FAMILY_FLOOR < u8::MAX) };
let highest_pin = pinned.iter().map(|(_, _, b)| *b).max().unwrap();
assert!(highest_pin < ogar_blockly::DEVICE_FAMILY_FLOOR);
assert!(highest_pin < crate::palette::DEVICE_FAMILY_FLOOR);
}
}
11 changes: 6 additions & 5 deletions crates/blockly-abi/src/klickweg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
//!
//! The call index is the one number that is already the program: it is where
//! [`lower_script`](crate::lower_script) put that block's
//! [`Call`](ogar_blockly::Call) in the body, so
//! [`Call`](ogar_loco::Call) in the body, so
//! `raise_calls(body)[ordinal]` IS the clicked block's function, by
//! construction rather than by convention. That makes the address checkable
//! against the ABI instead of merely consistent with itself — and it is
Expand All @@ -43,7 +43,8 @@
//! this crate depends on `a2ui-server`: the block editor produces addresses,
//! the desktop consumes them, and neither imports the other.

use ogar_blockly::{BlockConcept, LaneShape};
use crate::palette;
use ogar_loco::LaneShape;

use crate::{BlockRecord, codebook};

Expand Down Expand Up @@ -91,7 +92,7 @@ pub fn addresses(top: &BlockRecord, app_prefix: u16) -> Vec<(String, BlockAddres
// The PALETTE concept (0x1717) — which vocabulary reads the call bytes.
// Not the node's shape: that is ogar-loco's `LocoConcept::FunctionBody`
// (0x1701), and a block editor does not own it (OGAR #255).
let class_id = BlockConcept::Palette.render_classid(app_prefix);
let class_id = palette::render_classid(app_prefix);
let mut out = Vec::new();
walk_chain(top, class_id, &mut out);
out
Expand Down Expand Up @@ -193,8 +194,8 @@ mod tests {
for (id, addr) in addresses(&script, APP) {
let call = &calls[addr.ordinal as usize];
let expected = match id.as_str() {
"a" | "b" => ogar_blockly::FnIndex::NUMBER,
"root" => ogar_blockly::FnIndex::ADD,
"a" | "b" => ogar_loco::FnIndex::NUMBER,
"root" => ogar_loco::FnIndex::ADD,
other => panic!("unexpected block {other}"),
};
assert_eq!(
Expand Down
28 changes: 16 additions & 12 deletions crates/blockly-abi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! # What this is
//!
//! Not a parser, not a serializer, not a renderer. One thing: a Blockly block
//! record goes in, [`ogar_blockly::FunctionBody`] comes out, and back again.
//! record goes in, [`ogar_loco::FunctionBody`] comes out, and back again.
//! `to_le_bytes` / `from_le_bytes` on the far side ARE the wire format — there
//! is no JSON, no intermediate DTO, no serde anywhere in this crate.
//!
Expand Down Expand Up @@ -79,16 +79,20 @@
#![warn(missing_docs)]
#![forbid(unsafe_code)]

use ogar_blockly::{BodyError, Call, FnIndex, FunctionBody, LaneShape};
use ogar_loco::{BodyError, Call, FnIndex, FunctionBody, LaneShape};

pub mod codebook;
pub mod klickweg;
pub mod palette;
pub mod program;
pub mod projection;
pub mod registry;

pub use codebook::{OpcodeMapping, resolve_opcode};
pub use klickweg::{BlockAddress, address_of};
pub use palette::{
BlocklyPalette, DEVICE_FAMILY_FLOOR, PALETTE_CONCEPT, plug_into, render_classid,
};
pub use program::lower_program;
pub use projection::{ProjectionError, parse_text, render_text};
pub use registry::registry;
Expand All @@ -110,15 +114,15 @@ pub use ogar_loco::{
/// [`Program::references_are_resolvable`] and [`branches_of`] require.
///
/// Validation is a 256-byte sweep done once (lazily) per process;
/// `BlocklyVocabulary`'s conformance is additionally pinned by its own
/// crate's tests, so the `expect` here is a compile-time-adjacent fact,
/// [`BlocklyPalette`]'s conformance is additionally pinned by
/// [`palette`]'s own tests, so the `expect` here is a near-static fact,
/// not a runtime hope.
#[must_use]
pub fn checked_vocabulary() -> &'static CheckedVocabulary<ogar_blockly::BlocklyVocabulary> {
static CHECKED: std::sync::LazyLock<CheckedVocabulary<ogar_blockly::BlocklyVocabulary>> =
pub fn checked_vocabulary() -> &'static CheckedVocabulary<crate::palette::BlocklyPalette> {
static CHECKED: std::sync::LazyLock<CheckedVocabulary<crate::palette::BlocklyPalette>> =
std::sync::LazyLock::new(|| {
ogar_loco::vocabulary::conformance::validate(ogar_blockly::BlocklyVocabulary)
.expect("BlocklyVocabulary conforms; pinned by ogar-blockly's own tests")
ogar_loco::vocabulary::conformance::validate(crate::palette::BlocklyPalette)
.expect("BlocklyPalette conforms; pinned by palette::tests")
});
&CHECKED
}
Expand Down Expand Up @@ -608,12 +612,12 @@ pub(crate) fn call_for(
// The immediates, in the block's own field order. A Selector dropdown
// chose WHICH function and is already spent; a ValueParam dropdown is an
// ARGUMENT and becomes a byte, or the cast refuses.
let mut values = [0u8; ogar_blockly::MAX_VALUES_PER_CALL];
let mut values = [0u8; ogar_loco::MAX_VALUES_PER_CALL];
let mut n = 0usize;
let mut push = |b: u8| -> Result<(), CastError> {
// Silently dropping the overflow would emit a call that looks complete
// and is not — the same failure the mutator guard above exists to stop.
if n >= ogar_blockly::MAX_VALUES_PER_CALL {
if n >= ogar_loco::MAX_VALUES_PER_CALL {
return Err(CastError::TooManyValues {
ty: block.ty.clone(),
offered: block.fields.len(),
Expand Down Expand Up @@ -1169,7 +1173,7 @@ mod tests {
assert_eq!(pi.len(), 1);
// Same function — a ValueParam must NOT fan the palette out…
assert_eq!(pi[0].function, e[0].function);
assert_eq!(pi[0].function, ogar_blockly::FnIndex::CONSTANT);
assert_eq!(pi[0].function, ogar_loco::FnIndex::CONSTANT);
// …and the two programs stay distinguishable anyway, which is the
// whole point. Drop the code and these become equal.
assert_ne!(pi[0].values[0], e[0].values[0]);
Expand All @@ -1190,7 +1194,7 @@ mod tests {
let lt =
BlockRecord::leaf("logic_compare", "c").with_field("OP", FieldValue::Code("LT".into()));
let calls = raise_calls(&lower_script(LaneShape::Pairs, &lt).unwrap());
assert_eq!(calls[0].function, ogar_blockly::FnIndex::LT);
assert_eq!(calls[0].function, ogar_loco::FnIndex::LT);
assert_eq!(calls[0].values[0], 0);
}

Expand Down
Loading
Loading