diff --git a/DoodleBUGS/.gitignore b/DoodleBUGS/.gitignore index 4aa60c007..438771e2d 100644 --- a/DoodleBUGS/.gitignore +++ b/DoodleBUGS/.gitignore @@ -10,6 +10,7 @@ digest.txt root.txt src.txt public.txt +runtime.txt node_modules .DS_Store @@ -18,6 +19,7 @@ dist-ssr coverage *.local ztest +tmp /cypress/videos/ /cypress/screenshots/ diff --git a/DoodleBUGS/README.md b/DoodleBUGS/README.md index ceb144105..013d91282 100644 --- a/DoodleBUGS/README.md +++ b/DoodleBUGS/README.md @@ -43,3 +43,35 @@ npm run preview ``` For more information, questions, or to get involved, please contact [@shravanngoswamii](https://github.com/shravanngoswamii) (Ping me on [Julia Slack](https://julialang.slack.com/archives/CCYDC34A0)). + +> [!TIP] +> You can generate a standalone Julia script directly from the app: open the navbar → `Connection` → `Generate Standalone Julia Script`. +> The script opens in the right sidebar's Execution panel under the Files tab, where you can copy or download it. + +## Backend (Julia) Quick Start + +The DoodleBUGS app can connect to a local Julia backend for running models. + +1. Clone this repository and open a terminal at the repo root. +2. Instantiate backend dependencies (first time only): + +```bash +julia --project=DoodleBUGS/runtime -e "using Pkg; Pkg.instantiate()" +``` + +3. Start the backend server (defaults to http://localhost:8081): + +```bash +julia --project=DoodleBUGS/runtime DoodleBUGS/runtime/server.jl +``` + +4. In the DoodleBUGS app, open the navbar → `Connection` → set URL to `http://localhost:8081` → `Connect`. + +Notes: +- Keep the backend terminal open while using the app. +- If the port is in use or blocked by a firewall, change the port in `DoodleBUGS/runtime/server.jl` and reconnect (the port is currently set to 8081 at the end of the file). + +#### Troubleshooting + +- To verify connectivity, open your browser at `http://localhost:8081/api/health` (replace 8081 if you changed the port). A healthy server returns `{ "status": "ok" }`. +- If the health check fails, ensure the server is running, the URL/port are correct, and no firewall or VPN is blocking the port. Check the backend terminal output for errors. diff --git a/DoodleBUGS/public/examples/rats/data.json b/DoodleBUGS/public/examples/rats/data.json index c91f4cac4..268812441 100644 --- a/DoodleBUGS/public/examples/rats/data.json +++ b/DoodleBUGS/public/examples/rats/data.json @@ -1,227 +1,57 @@ { - "data": { - "N": 30, - "T": 5, - "x": [ - 8.0, - 15.0, - 22.0, - 29.0, - 36.0 - ], - "xbar": 22, - "Y": [ - [ - 151, - 199, - 246, - 283, - 320 - ], - [ - 145, - 199, - 249, - 293, - 354 - ], - [ - 147, - 214, - 263, - 312, - 328 - ], - [ - 155, - 200, - 237, - 272, - 297 - ], - [ - 135, - 188, - 230, - 280, - 323 - ], - [ - 159, - 210, - 252, - 298, - 331 - ], - [ - 141, - 189, - 231, - 275, - 305 - ], - [ - 159, - 201, - 248, - 297, - 338 - ], - [ - 177, - 220, - 260, - 309, - 331 - ], - [ - 134, - 182, - 220, - 260, - 295 - ], - [ - 160, - 208, - 261, - 313, - 352 - ], - [ - 143, - 188, - 220, - 273, - 314 - ], - [ - 154, - 200, - 244, - 289, - 325 - ], - [ - 161, - 218, - 266, - 304, - 340 - ], - [ - 142, - 184, - 223, - 262, - 298 - ], - [ - 156, - 203, - 243, - 283, - 317 - ], - [ - 157, - 212, - 259, - 307, - 336 - ], - [ - 152, - 203, - 245, - 286, - 324 - ], - [ - 154, - 205, - 253, - 298, - 334 - ], - [ - 139, - 190, - 225, - 267, - 302 - ], - [ - 146, - 196, - 247, - 289, - 321 - ], - [ - 161, - 215, - 254, - 293, - 332 - ], - [ - 153, - 207, - 256, - 303, - 345 - ], - [ - 149, - 198, - 245, - 287, - 324 - ], - [ - 144, - 192, - 242, - 289, - 326 - ], - [ - 162, - 211, - 262, - 315, - 357 - ], - [ - 157, - 202, - 242, - 281, - 316 - ], - [ - 150, - 204, - 256, - 298, - 335 - ], - [ - 151, - 202, - 248, - 290, - 325 - ], - [ - 163, - 216, - 265, - 307, - 342 - ] - ] - }, - "inits": {} + "data": { + "N": 30, + "T": 5, + "x": [8.0, 15.0, 22.0, 29.0, 36.0], + "xbar": 22, + "Y": [ + [151, 199, 246, 283, 320], + [145, 199, 249, 293, 354], + [147, 214, 263, 312, 328], + [155, 200, 237, 272, 297], + [135, 188, 230, 280, 323], + [159, 210, 252, 298, 331], + [141, 189, 231, 275, 305], + [159, 201, 248, 297, 338], + [177, 236, 285, 350, 376], + [134, 182, 220, 260, 296], + [160, 208, 261, 313, 352], + [143, 188, 220, 273, 314], + [154, 200, 244, 289, 325], + [171, 221, 270, 326, 358], + [163, 216, 242, 281, 312], + [160, 207, 248, 288, 324], + [142, 187, 234, 280, 316], + [156, 203, 243, 283, 317], + [157, 212, 259, 307, 336], + [152, 203, 246, 286, 321], + [154, 205, 253, 298, 334], + [139, 190, 225, 267, 302], + [146, 191, 229, 272, 302], + [157, 211, 250, 285, 323], + [132, 185, 237, 286, 331], + [160, 207, 257, 303, 345], + [169, 216, 261, 295, 333], + [157, 205, 248, 289, 316], + [137, 180, 219, 258, 291], + [153, 200, 244, 286, 324] + ] + }, + "inits": { + "alpha": [ + 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, + 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, + 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0, 250.0 + ], + "beta": [ + 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, + 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, + 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0 + ], + "alpha.c": 150.0, + "beta.c": 10.0, + "tau.c": 1.0, + "alpha.tau": 1.0, + "beta.tau": 1.0 + } } \ No newline at end of file diff --git a/DoodleBUGS/public/examples/rats/model.json b/DoodleBUGS/public/examples/rats/model.json index 7dd76f50d..199a3afb3 100644 --- a/DoodleBUGS/public/examples/rats/model.json +++ b/DoodleBUGS/public/examples/rats/model.json @@ -2,79 +2,281 @@ "name": "Rats: A Normal Hierarchical Model", "graphJSON": [ { - "id": "plate_i", "name": "Plate.i", "type": "node", "nodeType": "plate", "position": { "x": 450, "y": 400 }, - "loopVariable": "i", "loopRange": "1:N" + "id": "plate_i", + "name": "Plate.i", + "type": "node", + "nodeType": "plate", + "position": { + "x": 342.5, + "y": 350 + }, + "loopVariable": "i", + "loopRange": "1:N" }, { - "id": "plate_j", "name": "Plate.j", "type": "node", "nodeType": "plate", "position": { "x": 450, "y": 350 }, - "parent": "plate_i", "loopVariable": "j", "loopRange": "1:T" + "id": "plate_j", + "name": "Plate.j", + "type": "node", + "nodeType": "plate", + "position": { + "x": 514, + "y": 355 + }, + "parent": "plate_i", + "loopVariable": "j", + "loopRange": "1:T" }, { - "id": "node_Y", "name": "Y", "type": "node", "nodeType": "observed", "position": { "x": 450, "y": 250 }, - "parent": "plate_j", "distribution": "dnorm", "indices": "i,j", "observed": true, - "param1": "mu", "param2": "tau.c" + "id": "node_Y", + "name": "Y", + "type": "node", + "nodeType": "observed", + "position": { + "x": 513, + "y": 487 + }, + "parent": "plate_j", + "distribution": "dnorm", + "indices": "i,j", + "observed": true, + "param1": "mu", + "param2": "tau.c" }, { - "id": "node_mu", "name": "mu", "type": "node", "nodeType": "deterministic", "position": { "x": 450, "y": 350 }, - "parent": "plate_j", "equation": "alpha[i] + beta[i] * (x[j] - xbar)", "indices": "i,j" + "id": "node_mu", + "name": "mu", + "type": "node", + "nodeType": "deterministic", + "position": { + "x": 513, + "y": 355 + }, + "parent": "plate_j", + "equation": "alpha[i] + beta[i] * (x[j] - xbar)", + "indices": "i,j" }, { - "id": "node_alpha", "name": "alpha", "type": "node", "nodeType": "stochastic", "position": { "x": 350, "y": 450 }, - "parent": "plate_i", "distribution": "dnorm", "indices": "i", - "param1": "alpha.c", "param2": "alpha.tau" + "id": "node_alpha", + "name": "alpha", + "type": "node", + "nodeType": "stochastic", + "position": { + "x": 153, + "y": 223 + }, + "parent": "plate_i", + "distribution": "dnorm", + "indices": "i", + "param1": "alpha.c", + "param2": "alpha.tau" }, { - "id": "node_beta", "name": "beta", "type": "node", "nodeType": "stochastic", "position": { "x": 550, "y": 450 }, - "parent": "plate_i", "distribution": "dnorm", "indices": "i", - "param1": "beta.c", "param2": "beta.tau" + "id": "node_beta", + "name": "beta", + "type": "node", + "nodeType": "stochastic", + "position": { + "x": 343, + "y": 223 + }, + "parent": "plate_i", + "distribution": "dnorm", + "indices": "i", + "param1": "beta.c", + "param2": "beta.tau" }, { - "id": "node_tau.c", "name": "tau.c", "type": "node", "nodeType": "stochastic", "position": { "x": 200, "y": 200 }, - "distribution": "dgamma", "param1": "0.001", "param2": "0.001" + "id": "node_tau.c", + "name": "tau.c", + "type": "node", + "nodeType": "stochastic", + "position": { + "x": 735, + "y": 355 + }, + "distribution": "dgamma", + "param1": "0.001", + "param2": "0.001" }, { - "id": "node_sigma", "name": "sigma", "type": "node", "nodeType": "deterministic", "position": { "x": 200, "y": 100 }, + "id": "node_sigma", + "name": "sigma", + "type": "node", + "nodeType": "deterministic", + "position": { + "x": 735, + "y": 487 + }, "equation": "1 / sqrt(tau.c)" }, { - "id": "node_alpha.c", "name": "alpha.c", "type": "node", "nodeType": "stochastic", "position": { "x": 250, "y": 550 }, - "distribution": "dnorm", "param1": "0.0", "param2": "1.0E-6" + "id": "node_alpha.c", + "name": "alpha.c", + "type": "node", + "nodeType": "stochastic", + "position": { + "x": 317, + "y": 41 + }, + "distribution": "dnorm", + "param1": "0.0", + "param2": "1.0E-6" }, { - "id": "node_alpha.tau", "name": "alpha.tau", "type": "node", "nodeType": "stochastic", "position": { "x": 150, "y": 550 }, - "distribution": "dgamma", "param1": "0.001", "param2": "0.001" + "id": "node_alpha.tau", + "name": "alpha.tau", + "type": "node", + "nodeType": "stochastic", + "position": { + "x": 41, + "y": 41 + }, + "distribution": "dgamma", + "param1": "0.001", + "param2": "0.001" }, { - "id": "node_beta.c", "name": "beta.c", "type": "node", "nodeType": "stochastic", "position": { "x": 650, "y": 550 }, - "distribution": "dnorm", "param1": "0.0", "param2": "1.0E-6" + "id": "node_beta.c", + "name": "beta.c", + "type": "node", + "nodeType": "stochastic", + "position": { + "x": 581, + "y": 41 + }, + "distribution": "dnorm", + "param1": "0.0", + "param2": "1.0E-6" }, { - "id": "node_beta.tau", "name": "beta.tau", "type": "node", "nodeType": "stochastic", "position": { "x": 750, "y": 550 }, - "distribution": "dgamma", "param1": "0.001", "param2": "0.001" + "id": "node_beta.tau", + "name": "beta.tau", + "type": "node", + "nodeType": "stochastic", + "position": { + "x": 449, + "y": 41 + }, + "distribution": "dgamma", + "param1": "0.001", + "param2": "0.001" }, { - "id": "node_alpha0", "name": "alpha0", "type": "node", "nodeType": "deterministic", "position": { "x": 450, "y": 650 }, + "id": "node_alpha0", + "name": "alpha0", + "type": "node", + "nodeType": "deterministic", + "position": { + "x": 735, + "y": 223 + }, "equation": "alpha.c - xbar * beta.c" }, { - "id": "node_x", "name": "x", "type": "node", "nodeType": "constant", "position": { "x": 650, "y": 350 }, "indices": "j" + "id": "node_x", + "name": "x", + "type": "node", + "nodeType": "constant", + "position": { + "x": 515, + "y": 223 + }, + "indices": "j", + "parent": "plate_j" }, { - "id": "node_xbar", "name": "xbar", "type": "node", "nodeType": "constant", "position": { "x": 550, "y": 700 } + "id": "node_xbar", + "name": "xbar", + "type": "node", + "nodeType": "constant", + "position": { + "x": 821, + "y": 41 + } }, - { "id": "edge_mu_to_y", "type": "edge", "source": "node_mu", "target": "node_Y" }, - { "id": "edge_tauc_to_y", "type": "edge", "source": "node_tau.c", "target": "node_Y" }, - { "id": "edge_alphai_to_mu", "type": "edge", "source": "node_alpha", "target": "node_mu" }, - { "id": "edge_betai_to_mu", "type": "edge", "source": "node_beta", "target": "node_mu" }, - { "id": "edge_x_to_mu", "type": "edge", "source": "node_x", "target": "node_mu" }, - { "id": "edge_xbar_to_mu", "type": "edge", "source": "node_xbar", "target": "node_mu" }, - { "id": "edge_alphac_to_alphai", "type": "edge", "source": "node_alpha.c", "target": "node_alpha" }, - { "id": "edge_alphatau_to_alphai", "type": "edge", "source": "node_alpha.tau", "target": "node_alpha" }, - { "id": "edge_betac_to_betai", "type": "edge", "source": "node_beta.c", "target": "node_beta" }, - { "id": "edge_betatau_to_betai", "type": "edge", "source": "node_beta.tau", "target": "node_beta" }, - { "id": "edge_tauc_to_sigma", "type": "edge", "source": "node_tau.c", "target": "node_sigma" }, - { "id": "edge_alphac_to_alpha0", "type": "edge", "source": "node_alpha.c", "target": "node_alpha0" }, - { "id": "edge_xbar_to_alpha0", "type": "edge", "source": "node_xbar", "target": "node_alpha0" }, - { "id": "edge_betac_to_alpha0", "type": "edge", "source": "node_beta.c", "target": "node_alpha0" } + { + "id": "edge_mu_to_y", + "type": "edge", + "source": "node_mu", + "target": "node_Y" + }, + { + "id": "edge_tauc_to_y", + "type": "edge", + "source": "node_tau.c", + "target": "node_Y" + }, + { + "id": "edge_alphai_to_mu", + "type": "edge", + "source": "node_alpha", + "target": "node_mu" + }, + { + "id": "edge_betai_to_mu", + "type": "edge", + "source": "node_beta", + "target": "node_mu" + }, + { + "id": "edge_x_to_mu", + "type": "edge", + "source": "node_x", + "target": "node_mu" + }, + { + "id": "edge_xbar_to_mu", + "type": "edge", + "source": "node_xbar", + "target": "node_mu" + }, + { + "id": "edge_alphac_to_alphai", + "type": "edge", + "source": "node_alpha.c", + "target": "node_alpha" + }, + { + "id": "edge_alphatau_to_alphai", + "type": "edge", + "source": "node_alpha.tau", + "target": "node_alpha" + }, + { + "id": "edge_betac_to_betai", + "type": "edge", + "source": "node_beta.c", + "target": "node_beta" + }, + { + "id": "edge_betatau_to_betai", + "type": "edge", + "source": "node_beta.tau", + "target": "node_beta" + }, + { + "id": "edge_tauc_to_sigma", + "type": "edge", + "source": "node_tau.c", + "target": "node_sigma" + }, + { + "id": "edge_alphac_to_alpha0", + "type": "edge", + "source": "node_alpha.c", + "target": "node_alpha0" + }, + { + "id": "edge_xbar_to_alpha0", + "type": "edge", + "source": "node_xbar", + "target": "node_alpha0" + }, + { + "id": "edge_betac_to_alpha0", + "type": "edge", + "source": "node_beta.c", + "target": "node_alpha0" + } ] -} +} \ No newline at end of file diff --git a/DoodleBUGS/runtime/Project.toml b/DoodleBUGS/runtime/Project.toml new file mode 100644 index 000000000..8dbd2c8c7 --- /dev/null +++ b/DoodleBUGS/runtime/Project.toml @@ -0,0 +1,14 @@ +[deps] +AbstractMCMC = "80f14c24-f653-4e6a-9b94-39d6b0f70001" +AdvancedHMC = "0bf59076-c3b1-5ca4-86bd-e02cd72cde3d" +DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" +JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" +JuliaBUGS = "ba9fb4c0-828e-4473-b6a1-cd2560fee5bf" +LogDensityProblems = "6fdf6af0-433a-55f7-b3ed-c6c6e0b8df7c" +LogDensityProblemsAD = "996a588d-648d-4e1f-a8f0-a84b347e47b1" +MCMCChains = "c7f686f2-ff18-58e9-bc7b-31028e88f75d" +Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +ReverseDiff = "37e2e3b7-166d-5795-8a7a-e32c996b4267" +StatsBase = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91" diff --git a/DoodleBUGS/runtime/server.jl b/DoodleBUGS/runtime/server.jl new file mode 100644 index 000000000..cd3cf5e41 --- /dev/null +++ b/DoodleBUGS/runtime/server.jl @@ -0,0 +1,357 @@ +# This server is designed to be a generic executor. +# It creates a temporary, sandboxed Julia environment for each request, + +using HTTP +using JSON3 +using Logging + +const CORS_HEADERS = [ + "Access-Control-Allow-Origin" => "*", + "Access-Control-Allow-Headers" => "Content-Type", + "Access-Control-Allow-Methods" => "POST, GET, OPTIONS", +] + +function cors_handler(handler) + return function(req::HTTP.Request) + if HTTP.method(req) == "OPTIONS" + return HTTP.Response(200, CORS_HEADERS) + else + response = handler(req) + append!(response.headers, CORS_HEADERS) + return response + end + end +end + +function health_check_handler(req::HTTP.Request) + @info "Health check ping (backend reachable)" + return HTTP.Response(200, ["Content-Type" => "application/json"], JSON3.write(Dict("status" => "ok"))) +end + + +function run_model_handler(req::HTTP.Request) + logs = String[] + log!(logs, "Received /api/run request") + log!(logs, "Backend processing started.") + + tmp_dir = mktempdir() + log!(logs, "Created temporary working directory at: $(tmp_dir)") + + try + body = JSON3.read(String(req.body)) + model_code = get(body, :model_code, "") + data_json = haskey(body, :data) ? body[:data] : JSON3.Object() + inits_json = haskey(body, :inits) ? body[:inits] : JSON3.Object() + data_string = get(body, :data_string, "") + inits_string = get(body, :inits_string, "") + settings = get(body, :settings, JSON3.Object()) + + log!(logs, "Request body parsed successfully.") + + model_path = joinpath(tmp_dir, "model.bugs") + write(model_path, model_code) + log!(logs, "Wrote BUGS model to: $(model_path)") + + model_literal = repr(String(model_code)) + + results_path = joinpath(tmp_dir, "results.json") + + payload_path = joinpath(tmp_dir, "payload.json") + payload_obj = Dict( + "model_path" => model_path, + "data" => data_json, + "inits" => inits_json, + "data_string" => data_string, + "inits_string" => inits_string, + "settings" => Dict( + "n_samples" => get(settings, :n_samples, 1000), + "n_adapts" => get(settings, :n_adapts, 1000), + "n_chains" => get(settings, :n_chains, 1), + "seed" => get(settings, :seed, nothing), + "timeout_s" => get(settings, :timeout_s, nothing), + ), + ) + open(payload_path, "w") do f + JSON3.write(f, payload_obj) + end + log!(logs, "Wrote payload to: $(payload_path)") + + script_path = joinpath(tmp_dir, "run_script.jl") + run_script_content = """ + using JuliaBUGS, AbstractMCMC, AdvancedHMC, LogDensityProblems, LogDensityProblemsAD, MCMCChains, ReverseDiff, Random, JSON3, DataFrames, StatsBase, Statistics + + try + # Read payload + payload = JSON3.read(read($(repr(String(payload_path))), String)) + settings = payload.settings + + # Robust integer parsing for settings that may arrive as numbers or strings + to_int(x, default) = begin + xv = x + if xv isa Integer + return Int(xv) + else + p = tryparse(Int, string(xv)) + return p === nothing ? default : p + end + end + + # Build data/inits as NamedTuples; prefer strings when valid, else JSON fallback that supports keys like "alpha.c" + to_nt(obj) = (; (Symbol(String(k)) => v for (k, v) in pairs(obj))...) + data_nt = begin + if !isempty(payload.data_string) + try + eval(Meta.parse(payload.data_string)) + catch + to_nt(payload.data) + end + else + to_nt(payload.data) + end + end + inits_nt = begin + if !isempty(payload.inits_string) + try + eval(Meta.parse(payload.inits_string)) + catch + to_nt(payload.inits) + end + else + to_nt(payload.inits) + end + end + + # Define and compile model + model_def = JuliaBUGS.@bugs($(model_literal), true, false) + model = JuliaBUGS.compile(model_def, data_nt, inits_nt) + + # Wrap for AD (ReverseDiff by default) + ad_model = ADgradient(:ReverseDiff, model) + ld_model = AbstractMCMC.LogDensityModel(ad_model) + + # Settings + n_samples = to_int(get(settings, :n_samples, 1000), 1000) + n_adapts = to_int(get(settings, :n_adapts, 1000), 1000) + n_chains = to_int(get(settings, :n_chains, 1), 1) + seed = get(settings, :seed, nothing) + + # RNG + seed_val = seed isa Integer ? Int(seed) : tryparse(Int, string(seed)) + rng = seed_val === nothing ? Random.MersenneTwister() : Random.MersenneTwister(seed_val) + + # Initial params + D = LogDensityProblems.dimension(ad_model) + initial_theta = rand(rng, D) + + # Sample + if n_chains > 1 && Threads.nthreads() > 1 + samples = AbstractMCMC.sample( + rng, + ld_model, + NUTS(0.8), + AbstractMCMC.MCMCThreads(), + n_samples, + n_chains; + n_adapts=n_adapts, + chain_type=Chains, + init_params=initial_theta, + discard_initial=n_adapts, + progress=false, + ) + else + if n_chains > 1 + samples = AbstractMCMC.sample( + rng, + ld_model, + NUTS(0.8), + AbstractMCMC.MCMCSerial(), + n_samples, + n_chains; + n_adapts=n_adapts, + chain_type=Chains, + init_params=initial_theta, + discard_initial=n_adapts, + progress=false, + ) + else + samples = AbstractMCMC.sample( + rng, + ld_model, + NUTS(0.8), + n_samples; + n_adapts=n_adapts, + chain_type=Chains, + init_params=initial_theta, + discard_initial=n_adapts, + progress=false, + ) + end + end + + # Summaries + summary_df = DataFrame(MCMCChains.summarystats(samples)) + summary_json = [Dict(pairs(row)) for row in eachrow(summary_df)] + + q = [0.025, 0.25, 0.5, 0.75, 0.975] + quant_df = DataFrame(MCMCChains.quantile(samples; q=q)) + quant_json = [Dict(pairs(row)) for row in eachrow(quant_df)] + + open($(repr(String(results_path))), "w") do f + JSON3.write(f, Dict( + "success" => true, + "summary" => summary_json, + "quantiles" => quant_json, + )) + end + catch e + open($(repr(String(results_path))), "w") do f + JSON3.write(f, Dict("success" => false, "error" => sprint(showerror, e))) + end + end + """ + write(script_path, run_script_content) + log!(logs, "Generated execution script: $(script_path)") + + julia_executable = joinpath(Sys.BINDIR, "julia") + project_dir = abspath(@__DIR__) + cmd = `$(julia_executable) --project=$(project_dir) --threads=auto $(script_path)` + + log!(logs, "Executing script in worker process...") + timeout_s = try Int(get(settings, :timeout_s, 0)) catch; 0 end + if timeout_s <= 0 + run(cmd) + log!(logs, "Script execution finished.") + else + proc = run(cmd; wait=false) + log!(logs, "Worker process started; enforcing timeout of $(timeout_s)s") + deadline = time() + timeout_s + while process_running(proc) && time() < deadline + sleep(0.1) + end + if process_running(proc) + log!(logs, "Timeout reached; killing worker process...") + try + kill(proc) + log!(logs, "Worker process killed due to timeout.") + catch e + log!(logs, "Failed to kill worker process: $(sprint(showerror, e)))") + end + throw(ErrorException("Execution timed out after $(timeout_s) seconds")) + else + wait(proc) + log!(logs, "Script execution finished within timeout.") + end + end + + log!(logs, "Reading results from: $(results_path)") + results_content = JSON3.read(read(results_path, String)) + + log!(logs, "Preparing response...") + + if !results_content.success + throw(ErrorException(results_content.error)) + end + + files_arr = Any[ + Dict("name" => "model.bugs", "content" => model_code), + Dict("name" => "run_script.jl", "content" => run_script_content), + Dict("name" => "payload.json", "content" => read(payload_path, String)), + ] + if isfile(results_path) + push!(files_arr, Dict("name" => "results.json", "content" => read(results_path, String))) + end + + # Diagnostics: log attachment sizes + sizes = String[] + for f in files_arr + push!(sizes, string(f["name"], "=", sizeof(f["content"])) ) + end + log!(logs, "Attaching $(length(files_arr)) files; sizes(bytes): $(join(sizes, ", "))") + + response_body = Dict( + "success" => true, + "results" => (haskey(results_content, :summary) ? results_content[:summary] : (haskey(results_content, :results) ? results_content[:results] : Any[])), + "summary" => (haskey(results_content, :summary) ? results_content[:summary] : Any[]), + "quantiles" => (haskey(results_content, :quantiles) ? results_content[:quantiles] : Any[]), + "logs" => logs, + "files" => files_arr, + ) + + log!(logs, "Serializing response...") + buf = IOBuffer() + JSON3.write(buf, response_body) + resp_json = String(take!(buf)) + log!(logs, "Serialization complete. Response bytes=$(sizeof(resp_json))") + log!(logs, "Completed backend execution.") + return HTTP.Response(200, ["Content-Type" => "application/json"], resp_json) + catch e + log!(logs, "An error occurred: $(sprint(showerror, e))") + @error "Error during model execution" exception=(e, catch_backtrace()) + + files_arr = Any[] + if @isdefined(model_code) + push!(files_arr, Dict("name" => "model.bugs", "content" => model_code)) + end + if @isdefined(run_script_content) + push!(files_arr, Dict("name" => "run_script.jl", "content" => run_script_content)) + end + if @isdefined(payload_path) && isfile(payload_path) + push!(files_arr, Dict("name" => "payload.json", "content" => read(payload_path, String))) + end + if @isdefined(results_path) && isfile(results_path) + push!(files_arr, Dict("name" => "results.json", "content" => read(results_path, String))) + end + error_response = Dict( + "success" => false, + "error" => sprint(showerror, e), + "logs" => logs, + "files" => files_arr, + ) + return HTTP.Response(500, ["Content-Type" => "application/json"], JSON3.write(error_response)) + finally + # Clean up temp directory in background with retries to avoid EBUSY on Windows + @async safe_rmdir(tmp_dir) + end +end + +""" +Log a message to both the in-memory logs (returned to the client) and the terminal. +UI logs are kept clean (no timestamps); terminal logging can include timestamps via the logger formatter. +""" +function log!(logs::Vector{String}, msg::AbstractString) + push!(logs, msg) + @info msg +end + +""" +Remove directory tree with retries and backoff. Resilient to transient EBUSY on Windows. +Intended to be called in a background task. +""" +function safe_rmdir(path::AbstractString; retries::Int=6, sleep_s::Float64=0.25) + for _ in 1:retries + try + GC.gc() + rm(path; recursive=true, force=true) + return + catch e + msg = sprint(showerror, e) + if occursin("EBUSY", msg) || e isa IOError + sleep(sleep_s) + continue + else + @warn "Unexpected error removing temp dir" path error=e + return + end + end + end + @warn "Failed to remove temp dir after retries; leaving it on disk" path +end + +const ROUTER = HTTP.Router() +HTTP.register!(ROUTER, "GET", "/api/health", health_check_handler) +HTTP.register!(ROUTER, "POST", "/api/run", run_model_handler) +HTTP.register!(ROUTER, "POST", "/api/run_model", run_model_handler) + +port = 8081 +println("Starting JuliaBUGS backend server on http://localhost:$(port)...") +HTTP.serve(cors_handler(ROUTER), "0.0.0.0", port) diff --git a/DoodleBUGS/src/components/layouts/MainLayout.vue b/DoodleBUGS/src/components/layouts/MainLayout.vue index 02fb4c5c0..c192d4927 100644 --- a/DoodleBUGS/src/components/layouts/MainLayout.vue +++ b/DoodleBUGS/src/components/layouts/MainLayout.vue @@ -1,14 +1,18 @@ +