add snapshot api

Disabled snapshot button though since cloudflare sandbox backup
is slow for large repos.
This commit is contained in:
Tienson Qin
2026-03-01 20:51:21 +08:00
parent 180449c275
commit 1f4f0e8a5e
13 changed files with 432 additions and 110 deletions

View File

@@ -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 (<get-session self)]
(cond
(nil? current-session)
(http/not-found)
(terminal-status? (:status current-session))
(session-conflict "session is not writable")
(not (map? (:runtime current-session)))
(session-conflict "session runtime unavailable")
:else
(let [runtime (:runtime current-session)
provider (runtime-provider/resolve-provider (.-env self) runtime)]
(-> (p/let [_ (<append-event! self {:type "sandbox.snapshot.started"
:data {:by user-id}
:ts (common/now-ms)})
result (runtime-provider/<snapshot-runtime! provider
runtime
{:task (:task current-session)})
snapshot-id (or (:snapshot-id result)
(:id result))
_ (<append-event! self {:type "sandbox.snapshot.succeeded"
:data (cond-> {: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 [_ (<append-event! self {:type "sandbox.snapshot.failed"
:data {:by user-id
:reason reason
:error (str error)}
:ts (common/now-ms)})]
(if unsupported?
(session-conflict (or (some-> 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)

View File

@@ -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)

View File

@@ -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}}]

View File

@@ -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- <cloudflare-create-backup!
[^js sandbox backup-key source-dir]
[^js sandbox source-dir backup-name]
(let [create-backup (js-method sandbox "createBackup")]
(cond
(or (not (string? backup-key))
(not (string? source-dir)))
(p/resolved nil)
(not (string? source-dir))
(p/rejected (ex-info "invalid snapshot source dir"
{:reason :invalid-snapshot-source-dir
:source-dir source-dir}))
(not (fn? create-backup))
(p/resolved nil)
(p/rejected (ex-info "cloudflare runtime does not support snapshots"
{:reason :unsupported-snapshot
:provider "cloudflare"}))
:else
(-> (->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- <cloudflare-clone-repo! [^js env sandbox session-id task]
(when-let [cmd (repo-clone-command env session-id task "cloudflare")]
@@ -1121,6 +1195,7 @@
(<open-events-stream! [this runtime])
(<send-message! [this runtime message])
(<open-terminal! [this runtime request opts])
(<snapshot-runtime! [this runtime opts])
(<push-branch! [this runtime opts])
(<terminate-runtime! [this runtime]))
@@ -1202,6 +1277,12 @@
{:reason :unsupported-terminal
:provider "sprites"})))
(<snapshot-runtime! [_ _runtime _opts]
(p/rejected
(ex-info "sprites runtime provider does not support snapshots"
{:reason :unsupported-snapshot
:provider "sprites"})))
(<push-branch! [_ runtime opts]
(let [name (:sprite-name runtime)
session-id (:session-id opts)
@@ -1289,6 +1370,12 @@
{:reason :unsupported-terminal
:provider "local-dev"})))
(<snapshot-runtime! [_ _runtime _opts]
(p/rejected
(ex-info "local-dev runtime provider does not support snapshots"
{:reason :unsupported-snapshot
:provider "local-dev"})))
(<push-branch! [_ _runtime _opts]
(p/rejected
(ex-info "local-dev runtime provider does not support managed git push"
@@ -1382,6 +1469,25 @@
(<open-terminal! [_ runtime request opts]
(<cloudflare-open-terminal! env runtime request opts))
(<snapshot-runtime! [_ runtime opts]
(let [sandbox-id (:sandbox-id runtime)
session-id (:session-id runtime)
backup-dir (or (:backup-dir runtime)
(get-repo-dir session-id))
task (:task opts)
backup-key (repo-backup-key task)
backup-name (snapshot-backup-name runtime task)]
(when-not (string? sandbox-id)
(throw (ex-info "missing sandbox-id on runtime"
{:reason :missing-sandbox-id
:runtime runtime})))
(let [sandbox (cloudflare-sandbox env sandbox-id)]
(p/let [result (<cloudflare-create-backup! sandbox backup-dir backup-name)
snapshot-id (:snapshot-id result)]
(when (and (string? backup-key) (string? snapshot-id))
(remember-cloudflare-backup! backup-key snapshot-id))
result))))
(<push-branch! [_ runtime opts]
(let [sandbox-id (:sandbox-id runtime)
session-id (:session-id opts)
@@ -1440,15 +1546,10 @@
(<terminate-runtime! [_ runtime]
(let [sandbox-id (:sandbox-id runtime)
session-id (:session-id runtime)
backup-key (:backup-key runtime)
backup-dir (or (:backup-dir runtime)
(get-repo-dir session-id))]
session-id (:session-id runtime)]
(log/debug :agent/cloudflare-terminate
{:session-id session-id
:sandbox-id sandbox-id
:backup-key backup-key
:backup-dir backup-dir})
:sandbox-id sandbox-id})
(if-not (string? sandbox-id)
(p/resolved nil)
(let [sandbox (cloudflare-sandbox env sandbox-id)
@@ -1456,10 +1557,7 @@
agent-token (or (:agent-token runtime)
(env-str env "SANDBOX_AGENT_TOKEN"))]
(p/catch
(p/let [_ (p/catch
(<cloudflare-create-backup! sandbox backup-key backup-dir)
(fn [_] nil))
_ (when (string? session-id)
(p/let [_ (when (string? session-id)
(p/catch
(<cloudflare-terminate-session! sandbox port agent-token session-id)
(fn [_] nil)))

View File

@@ -260,6 +260,9 @@
[:create-pr {:optional true} :boolean]
[:force {:optional true} :boolean]])
(def sessions-snapshot-request-schema
[:map])
(def sessions-create-response-schema
[:map
[:session-id :string]
@@ -306,6 +309,12 @@
[:github-raw-body {:optional true} :string]
[:github-response-headers {:optional true} :any]])
(def sessions-snapshot-response-schema
[:map
[:status :string]
[:snapshot-id {:optional true} [:maybe :string]]
[:message {:optional true} [:maybe :string]]])
(def http-request-schemas
{:graphs/create graph-create-request-schema
:graph-members/create graph-member-create-request-schema
@@ -316,7 +325,8 @@
:e2ee/grant-access e2ee-grant-access-request-schema
:sessions/create sessions-create-request-schema
:sessions/message sessions-message-request-schema
:sessions/pr sessions-pr-request-schema})
:sessions/pr sessions-pr-request-schema
:sessions/snapshot sessions-snapshot-request-schema})
(def http-response-schemas
{:graphs/list graphs-list-response-schema
@@ -349,6 +359,7 @@
:sessions/interrupt http-ok-response-schema
:sessions/cancel http-ok-response-schema
:sessions/pr sessions-pr-response-schema
:sessions/snapshot sessions-snapshot-response-schema
:sessions/events sessions-events-response-schema
:sessions/branches sessions-branches-response-schema
:error http-error-response-schema})

View File

@@ -719,6 +719,47 @@
(is false (str "unexpected error: " error))
(done))))))))
(deftest snapshot-endpoint-creates-sandbox-snapshot-test
(testing "session snapshot endpoint creates a manual sandbox snapshot"
(async done
(let [snapshot-calls (atom [])
env #js {"AGENT_RUNTIME_PROVIDER" "local-dev"}
self (make-self env)
headers {"content-type" "application/json"
"x-user-id" "user-1"}]
(-> (.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/<snapshot-runtime!
(fn [_provider runtime _opts]
(swap! snapshot-calls conj runtime)
(js/Promise.resolve {:snapshot-id "backup-1"}))]
(agent-do/handle-fetch self
(json-request "http://db-sync.local/__session__/snapshot"
"POST"
{}
headers)))))
(.then (fn [resp]
(is (= 200 (.-status resp)))
(.then (<json resp)
(fn [body]
(is (= "snapshot-created" (:status body)))
(is (= "backup-1" (:snapshot-id body)))
(is (= 1 (count @snapshot-calls)))
(done)))))
(.catch (fn [error]
(is false (str "unexpected error: " error))
(done))))))))
(deftest pr-endpoint-push-only-success-test
(testing "session publish endpoint supports push-only flow"
(async done

View File

@@ -67,3 +67,9 @@
(is (nil? (http/coerce-http-request :sessions/pr {:force "no"})))
(is (nil? (http/coerce-http-request :sessions/pr {:commit-message 100})))
(is (nil? (http/coerce-http-request :sessions/pr {:head-branch 42})))))
(deftest sessions-snapshot-coerce-test
(testing "accepts empty sessions/snapshot request payload"
(is (= {} (http/coerce-http-request :sessions/snapshot {}))))
(testing "rejects invalid sessions/snapshot payload"
(is (nil? (http/coerce-http-request :sessions/snapshot {:force true})))))

View File

@@ -44,4 +44,7 @@
(is (= "session-12" (get-in match [:path-params :session-id]))))
(let [match (routes/match-route "POST" "/sessions/session-13/pr")]
(is (= :sessions/pr (:handler match)))
(is (= "session-13" (get-in match [:path-params :session-id]))))))
(is (= "session-13" (get-in match [:path-params :session-id]))))
(let [match (routes/match-route "POST" "/sessions/session-14/snapshot")]
(is (= :sessions/snapshot (:handler match)))
(is (= "session-14" (get-in match [:path-params :session-id]))))))

View File

@@ -842,13 +842,12 @@
(is (= "sess-3" (:session-id data))))
(done)))))))
(deftest cloudflare-provider-backup-restore-test
(deftest cloudflare-provider-does-not-auto-backup-or-restore-test
(async done
(runtime-provider/clear-cloudflare-backup-cache!)
(let [calls (atom {:clone 0
:restore []
:backups []})
backup-counter (atom 0)
:restore 0
:backup 0})
sandboxes (atom {})
make-sandbox
(fn [sandbox-id]
@@ -874,16 +873,13 @@
(js/JSON.stringify #js {:ok true})
#js {:status 200 :headers #js {"content-type" "application/json"}})))
:createBackup
(fn [opts]
(let [backup-id (str "backup-" (swap! backup-counter inc))]
(swap! calls update :backups conj {:sandbox-id sandbox-id
:opts (js->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/<provision-runtime! provider "sess-bk-1" task)
(.then (fn [runtime-1]
(-> (runtime-provider/<provision-runtime! provider "sess-no-auto-bk-1" task)
(.then (fn [runtime]
(is (= 1 (:clone @calls)))
(-> (runtime-provider/<terminate-runtime! provider runtime-1)
(is (= 0 (:restore @calls)))
(-> (runtime-provider/<terminate-runtime! provider runtime)
(.then (fn [_]
(is (= 1 (count (:backups @calls))))
(is (= 604800 (get-in @calls [:backups 0 :opts :ttl])))
(-> (runtime-provider/<provision-runtime! provider "sess-bk-2" task)
(.then (fn [_runtime-2]
(is (= 1 (:clone @calls)))
(is (= 1 (count (:restore @calls))))
(is (= "backup-1"
(get-in @calls [:restore 0 :backup :id])))
(is (= "/workspace/sess-bk-2"
(get-in @calls [:restore 0 :backup :dir])))
(done)))
(.catch (fn [error]
(is false (str "unexpected reprovision error: " error))
(done))))))
(is (= 0 (:backup @calls)))
(is (= 0 (:restore @calls)))
(done)))
(.catch (fn [error]
(is false (str "unexpected terminate error: " error))
(done))))))
@@ -925,22 +911,26 @@
(is false (str "unexpected provision error: " error))
(done)))))))
(deftest cloudflare-provider-backup-single-pointer-per-repo-branch-test
(deftest cloudflare-provider-restores-from-cached-snapshot-test
(async done
(runtime-provider/clear-cloudflare-backup-cache!)
(let [calls (atom {:restore-ids []
:backup-ids []})
backup-counter (atom 0)
(let [calls (atom {:clone 0
:restore []
:backup 0})
sandboxes (atom {})
make-sandbox
(fn [_sandbox-id]
(fn [sandbox-id]
#js {:exec
(fn [cmd]
(cond
(string/includes? cmd "/v1/health")
(js/Promise.resolve #js {:success true})
(string/includes? cmd "git clone --depth 1 --single-branch --no-tags")
(js/Promise.resolve #js {:success true})
(do
(swap! calls update :clone inc)
(js/Promise.resolve #js {:success true}))
:else
(js/Promise.resolve #js {:success true :stdout "" :stderr ""})))
:setEnvVars (fn [_] (js/Promise.resolve nil))
@@ -953,12 +943,12 @@
#js {:status 200 :headers #js {"content-type" "application/json"}})))
:createBackup
(fn [_opts]
(let [backup-id (str "backup-" (swap! backup-counter inc))]
(swap! calls update :backup-ids conj backup-id)
(js/Promise.resolve #js {:id backup-id :dir "/workspace"})))
(swap! calls update :backup inc)
(js/Promise.resolve #js {:id "backup-restore-1"
:dir "/workspace"}))
:restoreBackup
(fn [backup]
(swap! calls update :restore-ids conj (aget backup "id"))
(swap! calls update :restore conj (js->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/<provision-runtime! provider "sess-bk-a" task)
(.then (fn [runtime-a]
(-> (runtime-provider/<terminate-runtime! provider runtime-a)
(.then (fn [_]
(-> (runtime-provider/<provision-runtime! provider "sess-bk-b" task)
(.then (fn [runtime-b]
(-> (runtime-provider/<terminate-runtime! provider runtime-b)
(.then (fn [_]
(-> (runtime-provider/<provision-runtime! provider "sess-bk-c" task)
(.then (fn [_runtime-c]
(is (= ["backup-1" "backup-2"]
(:restore-ids @calls)))
(is (= ["backup-1" "backup-2"]
(:backup-ids @calls)))
(done)))
(.catch (fn [error]
(is false (str "unexpected third provision error: " error))
(done))))))
(.catch (fn [error]
(is false (str "unexpected second terminate error: " error))
(done))))))
(.catch (fn [error]
(is false (str "unexpected second provision error: " error))
(done))))))
:base-branch "main"}}
runtime {:provider "cloudflare"
:sandbox-id "sbx-1"
:session-id "sess-cache"}]
(-> (runtime-provider/<snapshot-runtime! provider runtime {:task task})
(.then (fn [_]
(is (= 1 (:backup @calls)))
(-> (runtime-provider/<provision-runtime! provider "sess-cache-next" task)
(.then (fn [_next-runtime]
(is (= 0 (:clone @calls)))
(is (= 1 (count (:restore @calls))))
(is (= "backup-restore-1"
(get-in @calls [:restore 0 :id])))
(is (= "/workspace/sess-cache-next"
(get-in @calls [:restore 0 :dir])))
(done)))
(.catch (fn [error]
(is false (str "unexpected first terminate error: " error))
(is false (str "unexpected reprovision error: " error))
(done))))))
(.catch (fn [error]
(is false (str "unexpected first provision error: " error))
(is false (str "unexpected snapshot error: " error))
(done)))))))
(deftest cloudflare-provider-snapshot-runtime-test
(async done
(runtime-provider/clear-cloudflare-backup-cache!)
(let [calls (atom {})
sandbox-stub
#js {:createBackup
(fn [opts]
(swap! calls assoc :snapshot-opts (js->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/<snapshot-runtime! provider
runtime
{:task {:project {:repo-url "https://github.com/example/repo"
:base-branch "main"}}})
(.then (fn [result]
(is (= "backup-42" (:snapshot-id result)))
(is (= "/workspace/sess-snapshot" (get-in @calls [:snapshot-opts :dir])))
(is (= 604800 (get-in @calls [:snapshot-opts :ttl])))
(is (string? (get-in @calls [:snapshot-opts :name])))
(done)))
(.catch (fn [error]
(is false (str "unexpected snapshot error: " error))
(done)))))))

View File

@@ -26,6 +26,12 @@ class_name = "AgentSessionDO"
name = "Sandbox"
class_name = "Sandbox"
[[r2_buckets]]
binding = "BACKUP_BUCKET"
bucket_name = "logseq-sync-assets-prod"
preview_bucket_name = "logseq-sync-assets-prod"
remote = true
[[migrations]]
tag = "v1"
new_sqlite_classes = [ "AgentSessionDO" ]
@@ -39,6 +45,8 @@ COGNITO_JWKS_URL = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLn
COGNITO_ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8"
COGNITO_CLIENT_ID = "69cs1lgme7p8kbgld8n5kseii6"
AGENT_RUNTIME_PROVIDER = "cloudflare"
CLOUDFLARE_ACCOUNT_ID = "2553ea8236c11ea0f88de28fce1cbfee"
BACKUP_BUCKET_NAME = "logseq-sync-assets-prod"
[env.staging]
name = "logseq-agents-staging"
@@ -54,6 +62,8 @@ COGNITO_JWKS_URL = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLn
COGNITO_ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8"
COGNITO_CLIENT_ID = "69cs1lgme7p8kbgld8n5kseii6"
AGENT_RUNTIME_PROVIDER = "cloudflare"
CLOUDFLARE_ACCOUNT_ID = "2553ea8236c11ea0f88de28fce1cbfee"
BACKUP_BUCKET_NAME = "logseq-sync-assets-dev"
SENTRY_DSN = "https://dc09d27243b9492bbe15e0dd279ad7de@o416451.ingest.us.sentry.io/5311485"
SENTRY_ENVIRONMENT = "staging"
SENTRY_TRACES_SAMPLE_RATE = "0.1"
@@ -66,6 +76,12 @@ class_name = "AgentSessionDO"
name = "Sandbox"
class_name = "Sandbox"
[[env.staging.r2_buckets]]
binding = "BACKUP_BUCKET"
bucket_name = "logseq-sync-assets-dev"
preview_bucket_name = "logseq-sync-assets-dev"
remote = true
[[env.staging.migrations]]
tag = "v1"
new_sqlite_classes = [ "AgentSessionDO" ]
@@ -85,6 +101,8 @@ COGNITO_JWKS_URL = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLn
COGNITO_ISSUER = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_dtagLnju8"
COGNITO_CLIENT_ID = "69cs1lgme7p8kbgld8n5kseii6"
AGENT_RUNTIME_PROVIDER = "cloudflare"
CLOUDFLARE_ACCOUNT_ID = "2553ea8236c11ea0f88de28fce1cbfee"
BACKUP_BUCKET_NAME = "logseq-sync-assets-prod"
SENTRY_DSN = "https://dc09d27243b9492bbe15e0dd279ad7de@o416451.ingest.us.sentry.io/5311485"
SENTRY_ENVIRONMENT = "prod"
SENTRY_TRACES_SAMPLE_RATE = "0.1"
@@ -103,6 +121,12 @@ class_name = "AgentSessionDO"
name = "Sandbox"
class_name = "Sandbox"
[[env.prod.r2_buckets]]
binding = "BACKUP_BUCKET"
bucket_name = "logseq-sync-assets-prod"
preview_bucket_name = "logseq-sync-assets-prod"
remote = true
[[env.prod.migrations]]
tag = "v1"
new_sqlite_classes = [ "AgentSessionDO" ]

View File

@@ -23,6 +23,8 @@ service = "logseq-agents"
[[r2_buckets]]
binding = "LOGSEQ_SYNC_ASSETS"
bucket_name = "logseq-sync-assets-prod"
preview_bucket_name = "logseq-sync-assets-prod"
remote = true
[[d1_databases]]
binding = "DB"
@@ -64,6 +66,8 @@ new_sqlite_classes = [ "SyncDO" ]
[[env.staging.r2_buckets]]
binding = "LOGSEQ_SYNC_ASSETS"
bucket_name = "logseq-sync-assets-dev"
preview_bucket_name = "logseq-sync-assets-dev"
remote = true
[[env.staging.d1_databases]]
binding = "DB"
@@ -99,6 +103,8 @@ new_sqlite_classes = [ "SyncDO" ]
[[env.prod.r2_buckets]]
binding = "LOGSEQ_SYNC_ASSETS"
bucket_name = "logseq-sync-assets-prod"
preview_bucket_name = "logseq-sync-assets-prod"
remote = true
[[env.prod.d1_databases]]
binding = "DB"

View File

@@ -1043,8 +1043,10 @@
(when-not terminal-tab-active?
[:div.flex.items-center.justify-between.gap-2
[:div.text-xs.opacity-60
(if publish-busy?
(cond
publish-busy?
"Publishing changes..."
:else
"Publish session changes")]
[:div.flex.items-center.gap-2
(shui/button

View File

@@ -692,6 +692,18 @@
(some-> 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 <publish-session!
[block opts]
(let [base (db-sync/http-base)
@@ -746,3 +758,48 @@
(p/catch (fn [error]
(notification/show! (publish-error-message error) :error false)
(p/rejected error)))))))))
(defn <snapshot-session!
[block]
(let [base (db-sync/http-base)
block-uuid (:block/uuid block)
session (session-state block-uuid)
session-id (or (:session-id session)
(some-> 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)
(<fetch-events! block)
resp))
(p/catch (fn [error]
(notification/show! (snapshot-error-message error) :error false)
(p/rejected error)))))))))