From e455f4fd391991d0eff6c82383380865e63532c2 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 7 Feb 2026 13:09:45 +0800 Subject: [PATCH] Cloudflare sandbox runtime --- deps/db-sync/README.md | 17 + .../docs/milestones/agents/00-index.md | 3 +- .../13-m13-cloudflare-sandbox-runtime.md | 92 ++++ .../src/logseq/db_sync/node/config.cljs | 10 + .../src/logseq/db_sync/node/server.cljs | 10 + .../src/logseq/db_sync/worker/agent/do.cljs | 97 +++- .../worker/agent/runtime_provider.cljs | 506 +++++++++++++++++- .../logseq/db_sync/worker/agent/sandbox.cljs | 50 ++ .../test/logseq/db_sync/agent_do_test.cljs | 151 ++++++ .../db_sync/agent_runtime_provider_test.cljs | 231 +++++++- .../logseq/db_sync/agent_sandbox_test.cljs | 2 + .../test/logseq/db_sync/test_runner.cljs | 1 + deps/db-sync/worker/Dockerfile.sandbox-agent | 10 +- deps/db-sync/worker/wrangler.toml | 3 +- 14 files changed, 1112 insertions(+), 71 deletions(-) create mode 100644 deps/db-sync/docs/milestones/agents/13-m13-cloudflare-sandbox-runtime.md create mode 100644 deps/db-sync/test/logseq/db_sync/agent_do_test.cljs diff --git a/deps/db-sync/README.md b/deps/db-sync/README.md index 8ed5e2d031..3ceb8f8aae 100644 --- a/deps/db-sync/README.md +++ b/deps/db-sync/README.md @@ -107,6 +107,13 @@ Agent runtime is selected by `AGENT_RUNTIME_PROVIDER`: For `cloudflare`, bind and export `Sandbox` in the Worker and configure the container image in `worker/wrangler.toml` (`[[containers]] class_name = "Sandbox"`). +Cloudflare runtime flow: +- resolve sandbox by deterministic name (session id + prefix) +- health probe `sandbox-agent` inside container +- set runtime env vars and start `sandbox-agent` when needed +- proxy create/message stream via `containerFetch` +- terminate session and cleanup sandbox on terminal states + ## Environment Variables | Variable | Purpose | @@ -140,6 +147,16 @@ For `cloudflare`, bind and export `Sandbox` in the Worker and configure the cont | SPRITES_SANDBOX_AGENT_PORT | sandbox-agent port inside sprite (default `2468`) | | SPRITES_HEALTH_RETRIES | Sprite health check retry count | | SPRITES_HEALTH_INTERVAL_MS | Sprite health check retry interval (ms) | +| CLOUDFLARE_SANDBOX_NAME_PREFIX | Prefix used when creating Cloudflare sandbox names | +| CLOUDFLARE_SANDBOX_AGENT_PORT | sandbox-agent port inside Cloudflare sandbox (default `2468`) | +| CLOUDFLARE_BOOTSTRAP_COMMAND | Optional command override to start sandbox-agent in Cloudflare sandbox | +| CLOUDFLARE_REPO_CLONE_COMMAND | Optional repo clone command template for Cloudflare sandbox | +| CLOUDFLARE_HEALTH_RETRIES | Cloudflare sandbox health check retry count | +| CLOUDFLARE_HEALTH_INTERVAL_MS | Cloudflare sandbox health check retry interval (ms) | +| OPENAI_API_KEY | Passed into Cloudflare sandbox runtime env (if set) | +| ANTHROPIC_API_KEY | Passed into Cloudflare sandbox runtime env (if set) | +| OPENAI_BASE_URL | Passed into Cloudflare sandbox runtime env (if set) | +| ANTHROPIC_BASE_URL | Passed into Cloudflare sandbox runtime env (if set) | ## Notes - Protocol definitions live in `docs/agent-guide/db-sync/protocol.md`. diff --git a/deps/db-sync/docs/milestones/agents/00-index.md b/deps/db-sync/docs/milestones/agents/00-index.md index 3a406eabf1..4f44ce4cc9 100644 --- a/deps/db-sync/docs/milestones/agents/00-index.md +++ b/deps/db-sync/docs/milestones/agents/00-index.md @@ -1,6 +1,6 @@ # Agent Service Milestones -Date: 2026-02-01 +Date: 2026-02-06 Status: Active Milestones are tracked as separate files in this folder: @@ -16,3 +16,4 @@ Milestones are tracked as separate files in this folder: - `10-m10-soft-interrupt-resume.md` - `11-m11-collab-access.md` - `12-m12-attachments.md` +- `13-m13-cloudflare-sandbox-runtime.md` diff --git a/deps/db-sync/docs/milestones/agents/13-m13-cloudflare-sandbox-runtime.md b/deps/db-sync/docs/milestones/agents/13-m13-cloudflare-sandbox-runtime.md new file mode 100644 index 0000000000..577cdead60 --- /dev/null +++ b/deps/db-sync/docs/milestones/agents/13-m13-cloudflare-sandbox-runtime.md @@ -0,0 +1,92 @@ +# M13: Cloudflare Sandbox Runtime Provider + +Status: Completed (2026-02-06) +Target: Add a first-class `cloudflare` runtime provider for agent sessions using Cloudflare Sandbox. + +## Goal +Support `AGENT_RUNTIME_PROVIDER=cloudflare` end-to-end so db-sync can provision a Cloudflare sandbox, start `sandbox-agent`, and stream runtime events through existing session APIs. + +## Why M13 +- Runtime provider selection still accepts `cloudflare`, but the runtime implementation is currently Sprites-only. +- The Worker already has `Sandbox` bindings and container image config in `worker/wrangler.toml`. +- We can follow the validated Cloudflare pattern from sandbox-agent: + - `examples/cloudflare/src/cloudflare.ts` + - `docs/deploy/cloudflare.mdx` + +## Inputs +- Cloudflare Sandbox reference flow: + - `getSandbox(...)` + - probe `/v1/health` + - `setEnvVars(...)` + - `startProcess("sandbox-agent server ...")` + - proxy via `containerFetch(...)` + +## Scope +1) Add `CloudflareProvider` in `src/logseq/db_sync/worker/agent/runtime_provider.cljs`. +2) Implement lifecycle methods: +- ` ( ( (runtime-provider/ value string/trim string/lower-case)] (when-not (string/blank? normalized) normalized)))) +(def ^:private supported-provider-kinds + #{"sprites" "local-dev" "cloudflare"}) + +(defn- known-provider-kind [value] + (let [provider (normalize-provider value)] + (when (contains? supported-provider-kinds provider) + provider))) + (defn provider-kind [^js env] - (or (normalize-provider (env-str env "AGENT_RUNTIME_PROVIDER")) + (or (known-provider-kind (env-str env "AGENT_RUNTIME_PROVIDER")) "sprites")) (defn runtime-provider-kind [^js env runtime] - (or (normalize-provider (:provider runtime)) + (or (known-provider-kind (:provider runtime)) (provider-kind env))) (defn fill-template [template sandbox-id] @@ -57,6 +66,10 @@ (let [prefix (or (env-str env "SPRITES_NAME_PREFIX") "logseq-task-")] (sanitize-name (str prefix session-id)))) +(defn cloudflare-sandbox-name [^js env session-id] + (let [prefix (or (env-str env "CLOUDFLARE_SANDBOX_NAME_PREFIX") "logseq-task-")] + (sanitize-name (str prefix session-id)))) + (defn- sprites-token [^js env] (or (env-str env "SPRITE_TOKEN") (env-str env "SPRITES_TOKEN"))) @@ -105,6 +118,21 @@ (str "http://127.0.0.1:" port path)) (def ^:private default-repo-base-dir "/workspace") +(def ^:private cloudflare-local-host "http://localhost") + +(defn- cloudflare-agent-port [^js env] + (parse-int (env-str env "CLOUDFLARE_SANDBOX_AGENT_PORT") 2468)) + +(defn- cloudflare-health-retries [^js env] + (parse-int (env-str env "CLOUDFLARE_HEALTH_RETRIES") 30)) + +(defn- cloudflare-health-interval-ms [^js env] + (parse-int (env-str env "CLOUDFLARE_HEALTH_INTERVAL_MS") 200)) + +(defn- js-method + [obj method] + (let [f (when obj (aget obj method))] + (when (fn? f) f))) (defn- ->promise [v] @@ -305,20 +333,26 @@ (string/replace "{repo_dir}" (or repo-dir "")))) (defn repo-clone-command - [^js env session-id task] - (let [repo-url (task-repo-url task) - session-id (some-> session-id str) - repo-dir (get-repo-dir session-id) - override (env-str env "SPRITES_REPO_CLONE_COMMAND")] - (when (and (string? repo-url) (string? session-id) (string? repo-dir)) - (if (string? override) - (fill-repo-template override {:repo-url repo-url - :session-id session-id - :repo-dir repo-dir}) - (str "mkdir -p " default-repo-base-dir - " && cd " default-repo-base-dir - " && git clone '" (escape-shell-single repo-url) "' '" (escape-shell-single repo-dir) "'" - " && chmod -R u+rw '" (escape-shell-single repo-dir) "'"))))) + ([^js env session-id task] + (repo-clone-command env session-id task "sprites")) + ([^js env session-id task provider] + (let [repo-url (task-repo-url task) + session-id (some-> session-id str) + repo-dir (get-repo-dir session-id) + override-key (if (= "cloudflare" provider) + "CLOUDFLARE_REPO_CLONE_COMMAND" + "SPRITES_REPO_CLONE_COMMAND") + override (env-str env override-key)] + (when (and (string? repo-url) (string? session-id) (string? repo-dir)) + (if (string? override) + (fill-repo-template override {:repo-url repo-url + :session-id session-id + :repo-dir repo-dir}) + (str "mkdir -p " default-repo-base-dir + " && cd " default-repo-base-dir + " && git clone --depth 1 --single-branch --no-tags '" + (escape-shell-single repo-url) "' '" (escape-shell-single repo-dir) "'" + " && chmod -R u+rw '" (escape-shell-single repo-dir) "'")))))) (defn session-payload [task] (let [agent (or (get-in task [:agent :provider]) @@ -336,6 +370,10 @@ "bypass" "default"))})) +(defn- message-payload [message] + (cond-> {:message (:message message)} + (string? (:kind message)) (assoc :kind (:kind message)))) + (defn- &2")] - (println :debug :script script) (p/let [result (sprites-exec-post! env sprite-name ["bash" "-lc" script]) stdout (or (:stdout result) "") stderr (or (:stderr result) "") @@ -369,12 +406,274 @@ (sprites-exec-post! env sprite-name ["bash" "-lc" cmd]))) ;; ============================================================ -;; RuntimeProvider + SpritesProvider +;; RuntimeProvider + Providers ;; ============================================================ +(defn- local-dev-base-url [^js env runtime] + (or (:base-url runtime) + (env-str env "SANDBOX_AGENT_URL") + "http://127.0.0.1:2468")) + +(defn- local-dev-token [^js env runtime] + (or (:agent-token runtime) + (env-str env "SANDBOX_AGENT_TOKEN"))) + +(defn- cloudflare-sandbox-namespace [^js env] + (let [sandbox-ns (aget env "Sandbox")] + (when-not sandbox-ns + (throw (ex-info "missing Sandbox binding for cloudflare runtime provider" {}))) + sandbox-ns)) + +(defn- cloudflare-sandbox [^js env sandbox-id] + (let [^js sandbox-ns (cloudflare-sandbox-namespace env) + id-from-name (js-method sandbox-ns "idFromName") + get-sandbox (js-method sandbox-ns "get")] + (when-not (and id-from-name get-sandbox) + (throw (ex-info "invalid Sandbox binding: missing idFromName/get" {}))) + (let [do-id (.idFromName sandbox-ns sandbox-id) + sandbox (.get sandbox-ns do-id)] + (when-not sandbox + (throw (ex-info "failed to get cloudflare sandbox stub" + {:sandbox-id sandbox-id}))) + sandbox))) + +(defn- cloudflare-health-command [port agent-token] + (str "curl -fsS " + (curl-auth-arg agent-token) + " " + cloudflare-local-host + ":" + port + "/v1/health >/dev/null")) + +(defn- promise (.exec sandbox cmd)) + (throw (ex-info "cloudflare sandbox missing exec method" {})))) + +(defn- (js/Promise.resolve ( (get-in task [:agent :api-token]) str string/trim not-empty)] + (cond-> (merge base task-env) + (and (string? api-token) (= "codex" agent-id)) (assoc "OPENAI_API_KEY" api-token) + (and (string? api-token) (= "claude" agent-id)) (assoc "ANTHROPIC_API_KEY" api-token)))) + +(defn- cloudflare-server-command [^js env task port agent-token] + (let [auth-json (get-in task [:agent :auth-json]) + write-auth (or (auth-json-write-command auth-json) "") + override (env-str env "CLOUDFLARE_BOOTSTRAP_COMMAND")] + (or override + (str write-auth + "sandbox-agent server " + (if (string? agent-token) + (str "--token '" (escape-shell-single agent-token) "'") + "--no-token") + " --host 0.0.0.0 --port " port + " --no-telemetry")))) + +(defn- promise (.setEnvVars sandbox (clj->js env-vars))) + + :else + (p/resolved nil))) + +(defn- promise (.startProcess sandbox command)) + (->promise (/tmp/sandbox-agent.log 2>&1 &")))))) + +(defn- cloudflare-fetch-port-candidates [port] + (->> [port 2468 8000] + (filter number?) + (distinct) + (vec))) + +(defn- error-message [error] + (let [msg (when error (aget error "message"))] + (if (string? msg) msg (str error)))) + +(defn- cloudflare-port-error? + [error] + (let [m (-> (error-message error) string/lower-case)] + (or (string/includes? m "container port not found") + (string/includes? m "connection refused") + (string/includes? m "error proxying request to container")))) + +(defn- = idx (count ports)) + (if last-error + (throw last-error) + (throw (ex-info "cloudflare containerFetch failed: no candidate ports" + {:requested-port port}))) + (let [candidate (nth ports idx)] + (p/catch + (->promise (.containerFetch sandbox request candidate)) + (fn [error] + (if (and (cloudflare-port-error? error) + (< (inc idx) (count ports))) + (step (inc idx) error) + (throw error)))))))] + (step 0 nil))))) + +(defn- js (cond-> {:method method :headers headers} + json-body + (assoc :body (js/JSON.stringify (clj->js json-body))))))] + (p/let [resp (promise (.text resp)) + parsed (parse-json-safe raw)] + (if (<= 200 status 299) + parsed + (throw (ex-info "cloudflare sandbox request failed" + {:status status + :path path + :body parsed + :raw-body raw})))))) + +(defn- promise (promise (.delete sandbox)) + + (js-method sandbox "remove") + (->promise (.remove sandbox)) + + (js-method sandbox "destroy") + (->promise (.destroy sandbox)) + + :else + (p/resolved nil))) + (defprotocol RuntimeProvider (sse-response! env name ["bash" "-lc" script])))) + + (sse-response! env name ["bash" "-lc" script])))) + (sprite-local-url port (str "/v1/sessions/" (:session-id runtime) "/messages")) + " >/dev/null")] + (p/let [_ (sprites-exec-post! env name ["bash" "-lc" script])] + true)))) (SpritesProvider env)) +(defrecord LocalDevProvider [env] + RuntimeProvider + + (LocalDevProvider env) + "cloudflare" (->CloudflareProvider env) + (->SpritesProvider env))) (defn resolve-provider - [^js env _runtime] - (create-provider env "sprites")) + [^js env runtime] + (create-provider env (runtime-provider-kind env runtime))) diff --git a/deps/db-sync/src/logseq/db_sync/worker/agent/sandbox.cljs b/deps/db-sync/src/logseq/db_sync/worker/agent/sandbox.cljs index 4d0807e5e8..3a9a7c6afa 100644 --- a/deps/db-sync/src/logseq/db_sync/worker/agent/sandbox.cljs +++ b/deps/db-sync/src/logseq/db_sync/worker/agent/sandbox.cljs @@ -18,6 +18,12 @@ (defn messages-stream-url [base session-id] (str (session-url base session-id) "/messages/stream")) +(defn events-sse-url [base session-id] + (str (session-url base session-id) "/events/sse")) + +(defn terminate-url [base session-id] + (str (session-url base session-id) "/terminate")) + (def ^:private agent-aliases {"claude-code" "claude" "claude_code" "claude" @@ -75,3 +81,47 @@ (throw (ex-info "sandbox open-message-stream failed" {:status status :session-id session-id})))))) + +(defn {:message (:message message)} + (string? (:kind message)) (assoc :kind (:kind message))) + req (json-request (messages-url base session-id) "POST" headers body)] + (p/let [resp (js/fetch req) + status (.-status resp)] + (if (<= 200 status 299) + true + (throw (ex-info "sandbox send-message failed" + {:status status + :session-id session-id})))))) + +(defn js (cond-> {:method method + :headers req-headers} + (some? body) (assoc :body (js/JSON.stringify (clj->js body)))))))) + +(defn- clj % :keywordize-keys true))) + +(deftest messages-use-single-events-stream-and-dont-duplicate-user-message-test + (testing "session messages post to /messages while keeping one /events/sse stream and no audit message payload" + (async done + (let [calls (atom {:create 0 + :messages 0 + :message-stream 0 + :events-sse 0}) + original-fetch js/fetch + env #js {"AGENT_RUNTIME_PROVIDER" "local-dev" + "SANDBOX_AGENT_URL" "http://sandbox.local"} + self (make-self env) + headers {"content-type" "application/json" + "x-user-id" "user-1"} + init-body {:id "sess-1" + :project {:id "project-1"} + :agent "codex"}] + (set! js/fetch + (fn [request] + (let [url (.-url request) + method (.-method request)] + (cond + (and (= "POST" method) + (string/includes? url "/v1/sessions/sess-1") + (not (string/includes? url "/messages"))) + (do + (swap! calls update :create inc) + (js/Promise.resolve + (js/Response. + (js/JSON.stringify #js {:ok true}) + #js {:status 200 + :headers #js {"content-type" "application/json"}}))) + + (and (= "GET" method) + (string/includes? url "/v1/sessions/sess-1/events/sse")) + (do + (swap! calls update :events-sse inc) + (let [stream (js/TransformStream.) + writer (.getWriter (.-writable stream)) + payload (.encode (js/TextEncoder.) + "data: {\"type\":\"item.delta\",\"delta\":\"ok\"}\n\n")] + ;; Keep the stream open to verify we don't reopen per user message. + (.write writer payload) + (js/Promise.resolve + (js/Response. + (.-readable stream) + #js {:status 200 + :headers #js {"content-type" "text/event-stream"}})))) + + (and (= "POST" method) + (string/includes? url "/v1/sessions/sess-1/messages/stream")) + (do + (swap! calls update :message-stream inc) + (js/Promise.resolve + (js/Response. + "data: {\"type\":\"item.delta\",\"delta\":\"ok\"}\n\n" + #js {:status 200 + :headers #js {"content-type" "text/event-stream"}}))) + + (and (= "POST" method) + (string/includes? url "/v1/sessions/sess-1/messages") + (not (string/includes? url "/messages/stream"))) + (do + (swap! calls update :messages inc) + (js/Promise.resolve + (js/Response. + (js/JSON.stringify #js {:ok true}) + #js {:status 200 + :headers #js {"content-type" "application/json"}}))) + + :else + (js/Promise.resolve + (js/Response. + (js/JSON.stringify #js {:error "unhandled request"}) + #js {:status 500 + :headers #js {"content-type" "application/json"}})))))) + + (let [promise (-> (agent-do/handle-fetch self + (json-request "http://db-sync.local/__session__/init" + "POST" + init-body + headers)) + (.then (fn [_] + (agent-do/handle-fetch self + (json-request "http://db-sync.local/__session__/messages" + "POST" + {:message "hello"} + headers)))) + (.then (fn [_] + (agent-do/handle-fetch self + (json-request "http://db-sync.local/__session__/messages" + "POST" + {:message "follow up"} + headers)))) + (.then (fn [_] + (agent-do/handle-fetch self + (json-request "http://db-sync.local/__session__/events" + "GET" + nil + {"x-user-id" "user-1"})))) + (.then (fn [events-resp] + (.then ( (runtime-provider/ (runtime-provider/ (runtime-provider/= (:health n) 2)})) + (js/Promise.resolve #js {:success true :stdout "" :stderr ""}))) + :setEnvVars + (fn [vars] + (swap! calls assoc :env (js->clj vars)) + (js/Promise.resolve nil)) + :startProcess + (fn [cmd] + (swap! calls assoc :start cmd) + (js/Promise.resolve nil)) + :containerFetch + (fn [req port] + (swap! calls assoc :create-url (.-url req) :create-port port :create-method (.-method req)) + (js/Promise.resolve + (js/Response. + (js/JSON.stringify #js {:ok true}) + #js {:status 200 :headers #js {"content-type" "application/json"}})))} + sandbox-ns + #js {:idFromName (fn [name] + (swap! calls assoc :sandbox-name name) + (str "id-" name)) + :get (fn [id] + (swap! calls assoc :sandbox-id id) + sandbox-stub)} + env #js {"Sandbox" sandbox-ns + "CLOUDFLARE_SANDBOX_AGENT_PORT" "8000" + "OPENAI_API_KEY" "sk-openai"} + provider (runtime-provider/create-provider env "cloudflare") + task {:agent {:provider "codex"}}] + (-> (runtime-provider/ (runtime-provider/ (runtime-provider/