diff --git a/deps/workers/src/logseq/agents/do.cljs b/deps/workers/src/logseq/agents/do.cljs index c537537701..47521d6b9e 100644 --- a/deps/workers/src/logseq/agents/do.cljs +++ b/deps/workers/src/logseq/agents/do.cljs @@ -925,6 +925,53 @@ create-pr? push-branch-fn))))))))))) +(defn- handle-snapshot [^js self request] + (let [user-id (user-id-from-request request)] + (if-not (string? user-id) + (http/unauthorized) + (p/let [current-session ( (p/let [_ ( {:by user-id} + (string? snapshot-id) (assoc :snapshot-id snapshot-id)) + :ts (common/now-ms)})] + (http/json-response :sessions/snapshot + (cond-> {:status "snapshot-created" + :message "Sandbox snapshot created."} + (string? snapshot-id) (assoc :snapshot-id snapshot-id)))) + (p/catch (fn [error] + (let [reason (error-reason error) + unsupported? (= reason "unsupported-snapshot")] + (p/let [_ ( error ex-message str string/trim not-empty) + "session runtime snapshot unavailable")) + (http/error-response (str error) 500))))))))))))) + (defn- handle-cancel [^js self request] (let [user-id (user-id-from-request request)] (if-not (string? user-id) @@ -1165,6 +1212,9 @@ (= path "/__session__/pr") (handle-pr self request) + (= path "/__session__/snapshot") + (handle-snapshot self request) + (= path "/__session__/terminal") (handle-terminal self request) diff --git a/deps/workers/src/logseq/agents/handler.cljs b/deps/workers/src/logseq/agents/handler.cljs index 4696b19831..7a66bbdc38 100644 --- a/deps/workers/src/logseq/agents/handler.cljs +++ b/deps/workers/src/logseq/agents/handler.cljs @@ -193,6 +193,16 @@ (forward-request stub do-url "POST" headers body-json)) (http/error-response "server error" 500))))))))) +(defn- handle-snapshot [{:keys [env request url claims route]}] + (let [session-id (get-in route [:path-params :session-id])] + (if-not (string? session-id) + (http/bad-request "invalid session id") + (if-let [^js stub (session-stub env session-id)] + (let [headers (base-headers request claims) + do-url (str (.-origin url) "/__session__/snapshot")] + (forward-request stub do-url "POST" headers nil)) + (http/error-response "server error" 500))))) + (defn handle [{:keys [route] :as ctx}] (case (:handler route) :sessions/create (handle-create ctx) @@ -203,6 +213,7 @@ :sessions/interrupt (handle-control ctx "/__session__/interrupt") :sessions/cancel (handle-cancel ctx) :sessions/pr (handle-pr ctx) + :sessions/snapshot (handle-snapshot ctx) :sessions/terminal (handle-terminal ctx) :sessions/events (handle-events ctx) :sessions/branches (handle-branches ctx) diff --git a/deps/workers/src/logseq/agents/routes.cljs b/deps/workers/src/logseq/agents/routes.cljs index 9e6f869e78..3480107e81 100644 --- a/deps/workers/src/logseq/agents/routes.cljs +++ b/deps/workers/src/logseq/agents/routes.cljs @@ -12,6 +12,7 @@ ["/interrupt" {:methods {"POST" :sessions/interrupt}}] ["/cancel" {:methods {"POST" :sessions/cancel}}] ["/pr" {:methods {"POST" :sessions/pr}}] + ["/snapshot" {:methods {"POST" :sessions/snapshot}}] ["/events" {:methods {"GET" :sessions/events}}] ["/branches" {:methods {"GET" :sessions/branches}}] ["/terminal" {:methods {"GET" :sessions/terminal}}] diff --git a/deps/workers/src/logseq/agents/runtime_provider.cljs b/deps/workers/src/logseq/agents/runtime_provider.cljs index 8498976b58..c3883340fe 100644 --- a/deps/workers/src/logseq/agents/runtime_provider.cljs +++ b/deps/workers/src/logseq/agents/runtime_provider.cljs @@ -121,7 +121,7 @@ (def ^:private default-repo-base-dir "/workspace") (def ^:private cloudflare-local-host "http://localhost") -(def ^:private cloudflare-backup-ttl-seconds (* 7 24 60 60)) +(def ^:private cloudflare-snapshot-ttl-seconds (* 7 24 60 60)) (defonce ^:private cloudflare-backup-cache (atom {})) (defn clear-cloudflare-backup-cache! @@ -496,9 +496,9 @@ [backup-key backup-id] (when (and (string? backup-key) (string? backup-id)) (let [now (js/Date.now) - ttl-ms (* cloudflare-backup-ttl-seconds 1000)] + ttl-ms (* cloudflare-snapshot-ttl-seconds 1000)] (swap! cloudflare-backup-cache assoc backup-key {:id backup-id - :ttl-seconds cloudflare-backup-ttl-seconds + :ttl-seconds cloudflare-snapshot-ttl-seconds :expires-at-ms (+ now ttl-ms) :updated-at-ms now})))) @@ -507,6 +507,28 @@ (when (string? backup-key) (swap! cloudflare-backup-cache dissoc backup-key))) +(defn- sanitize-backup-name + [value] + (let [raw (or (some-> value str string/lower-case) "snapshot") + sanitized (-> raw + ;; Keep snapshot names flat for provider compatibility. + (string/replace #"[^a-z0-9._-]+" "-") + (string/replace #"-+" "-") + (string/replace #"^-+" "") + (string/replace #"-+$" ""))] + (if (string/blank? sanitized) + "snapshot" + sanitized))) + +(defn- snapshot-backup-name + [runtime task] + (let [base (or (repo-backup-key task) + (some-> (:session-id runtime) str not-empty) + "snapshot")] + (str (sanitize-backup-name base) + "-" + (js/Date.now)))) + (defn- fill-repo-template [template {:keys [repo-url session-id repo-dir]}] (-> (or template "") @@ -882,6 +904,43 @@ (let [msg (when error (aget error "message"))] (if (string? msg) msg (str error)))) +(defn- cloudflare-backup-config-error? + [error] + (let [message (-> (str (or (ex-message error) "") + "\n" + (error-message error)) + string/lower-case)] + (or (string/includes? message "invalidbackupconfigerror") + (string/includes? message "backup not configured") + (string/includes? message "backup_bucket") + (string/includes? message "presigned url credentials") + (string/includes? message "missing env vars") + (string/includes? message "cf-r2-error header") + (string/includes? message "response.statuscode = 500")))) + +(defn- cloudflare-backup-config-message + [error] + (let [raw (or (some-> error ex-message str string/trim not-empty) + (some-> (error-message error) str string/trim not-empty) + (str error)) + lower-raw (string/lower-case raw) + sanitized (-> raw + (string/replace #"(?i)^error:\s*" "") + (string/replace #"(?i)^invalidbackupconfigerror:\s*" "") + string/trim)] + (cond + (or (string/includes? lower-raw "cf-r2-error header") + (string/includes? lower-raw "response.statuscode = 500")) + (str "snapshot upload succeeded but backup verification through BACKUP_BUCKET failed in local dev. " + "Ensure the R2 binding uses remote=true and run both workers with remote mode " + "(e.g. `wrangler dev --remote` for sync and agents).") + + (string/blank? sanitized) + "snapshot backup is not configured. Configure BACKUP_BUCKET and backup credentials in wrangler.agents.toml." + + :else + sanitized))) + (defn- cloudflare-port-error? [error] (let [m (-> (error-message error) string/lower-case)] @@ -975,7 +1034,7 @@ (let [backup-id (:id entry) backup #js {:id backup-id :dir target-dir}] - (-> (->promise (.call restore-backup sandbox backup)) + (-> (->promise (.restoreBackup sandbox backup)) (p/then (fn [_] (log/debug :agent/cloudflare-backup-restored {:backup-key backup-key @@ -992,37 +1051,52 @@ false))))))) (defn- (->promise (.call create-backup sandbox (clj->js {:dir source-dir - :name backup-key - :ttl cloudflare-backup-ttl-seconds}))) - (p/then (fn [backup] - (let [backup-id (aget backup "id")] - (when (string? backup-id) - (remember-cloudflare-backup! backup-key backup-id) - (log/debug :agent/cloudflare-backup-created - {:backup-key backup-key - :backup-id backup-id - :dir source-dir - :ttl-seconds cloudflare-backup-ttl-seconds}) - {:id backup-id - :dir source-dir})))) - (p/catch (fn [error] - (log/error :agent/cloudflare-backup-create-failed - {:backup-key backup-key - :dir source-dir - :error (str error)}) - nil)))))) + (p/catch + ;; Use direct method invocation instead of Function#call to avoid + ;; serializing the sandbox proxy receiver across RPC boundaries. + (p/let [backup (->promise (.createBackup sandbox (clj->js {:dir source-dir + :name backup-name + :ttl cloudflare-snapshot-ttl-seconds}))) + backup-id (aget backup "id")] + (if (string? backup-id) + (do + (log/debug :agent/cloudflare-snapshot-created + {:snapshot-id backup-id + :name backup-name + :dir source-dir + :ttl-seconds cloudflare-snapshot-ttl-seconds}) + {:snapshot-id backup-id + :name backup-name + :dir source-dir}) + (throw (ex-info "cloudflare snapshot create returned invalid id" + {:reason :invalid-snapshot-id + :backup backup})))) + (fn [error] + (log/error :agent/cloudflare-snapshot-create-failed + {:name backup-name + :dir source-dir + :error (str error)}) + (if (cloudflare-backup-config-error? error) + (p/rejected + (ex-info (cloudflare-backup-config-message error) + {:reason :unsupported-snapshot + :provider "cloudflare" + :raw-error (str error)})) + (p/rejected error))))))) (defn- (.put (.-storage self) + "session" + (clj->js {:id "sess-snapshot" + :status "running" + :task {:project {:repo-url "https://github.com/example/repo"}} + :runtime {:provider "cloudflare" + :sandbox-id "sbx-snapshot" + :session-id "sess-snapshot"} + :audit {} + :created-at 0 + :updated-at 0})) + (.then (fn [_] + (with-redefs [runtime-provider/clj opts :keywordize-keys true) - :id backup-id}) - (js/Promise.resolve #js {:id backup-id :dir (aget opts "dir")}))) + (fn [_opts] + (swap! calls update :backup inc) + (js/Promise.resolve #js {:id (str sandbox-id "-backup") + :dir "/workspace"})) :restoreBackup - (fn [backup] - (swap! calls update :restore conj {:sandbox-id sandbox-id - :backup (js->clj backup :keywordize-keys true)}) + (fn [_backup] + (swap! calls update :restore inc) (js/Promise.resolve #js {:success true})) :delete (fn [] (js/Promise.resolve nil))}) sandbox-ns @@ -899,25 +895,15 @@ task {:agent {:provider "codex"} :project {:repo-url "https://github.com/example/repo" :base-branch "main"}}] - (-> (runtime-provider/ (runtime-provider/ (runtime-provider/ (runtime-provider/ (runtime-provider/clj backup :keywordize-keys true)) (js/Promise.resolve #js {:success true})) :delete (fn [] (js/Promise.resolve nil))}) sandbox-ns @@ -973,34 +963,56 @@ provider (runtime-provider/create-provider env "cloudflare") task {:agent {:provider "codex"} :project {:repo-url "https://github.com/example/repo" - :base-branch "main"}}] - (-> (runtime-provider/ (runtime-provider/ (runtime-provider/ (runtime-provider/ (runtime-provider/ (runtime-provider/ (runtime-provider/clj opts :keywordize-keys true)) + (js/Promise.resolve #js {:id "backup-42"}))} + sandbox-ns + #js {:idFromName (fn [name] name) + :get (fn [_id] sandbox-stub)} + env #js {"Sandbox" sandbox-ns} + provider (runtime-provider/create-provider env "cloudflare") + runtime {:provider "cloudflare" + :sandbox-id "sbx-1" + :session-id "sess-snapshot"}] + (-> (runtime-provider/ error ex-message) "Publish failed.")) +(defn- snapshot-status-message + [resp] + (or (:message resp) + "Sandbox snapshot created.")) + +(defn- snapshot-error-message + [error] + (or (some-> (ex-data error) :body :message) + (some-> (ex-data error) :body :error) + (some-> error ex-message) + "Snapshot failed.")) + (defn block-uuid str))] + (cond + (not base) + (do + (notification/show! "DB sync is not configured." :error false) + (p/resolved nil)) + + (not (task-ready? block)) + (do + (notification/show! "Task needs Project (with Git Repo) and Agent." :warning) + (p/resolved nil)) + + (not (:session-id session)) + (do + (notification/show! "Start the agent session before snapshotting." :warning) + (p/resolved nil)) + + :else + (p/let [_ (js/Promise. user-handler/task--ensure-id&access-token) + body (coerce-http-request :sessions/snapshot {})] + (if (nil? body) + (do + (notification/show! "Invalid snapshot payload." :error false) + nil) + (-> (db-sync/fetch-json (str base "/sessions/" session-id "/snapshot") + {:method "POST" + :headers {"content-type" "application/json"} + :body (js/JSON.stringify (clj->js body))} + {:response-schema :sessions/snapshot}) + (p/then (fn [resp] + (update-session-state! block-uuid {:last-snapshot resp + :last-snapshot-at (util/time-ms)}) + (notification/show! (snapshot-status-message resp) :success false) + (