diff --git a/deps/workers/README.md b/deps/workers/README.md index 9fbef17a10..a34af49c86 100644 --- a/deps/workers/README.md +++ b/deps/workers/README.md @@ -124,6 +124,7 @@ The control plane forwards each task message through sandbox-agent Agent runtime is selected by `AGENT_RUNTIME_PROVIDER`: - `sprites`: provisions a Sprite and runs `sandbox-agent` inside it. - `local-dev`: uses `SANDBOX_AGENT_URL` directly. +- `vercel`: provisions/manages Vercel sandboxes via `@vercel/sandbox`, then runs `sandbox-agent` inside the sandbox. - `cloudflare`: provisions a sandbox first, then connects to the sandbox-hosted `sandbox-agent`. - Agents worker default is `cloudflare` (set in `worker/wrangler.agents.toml`). @@ -151,7 +152,7 @@ Cloudflare runtime flow: | DB_SYNC_STATIC_USER_ID | Static user id for local dev | | DB_SYNC_STATIC_EMAIL | Static user email for local dev | | DB_SYNC_STATIC_USERNAME | Static username for local dev | -| AGENT_RUNTIME_PROVIDER | Runtime backend (`sprites`, `local-dev`, `cloudflare`) | +| AGENT_RUNTIME_PROVIDER | Runtime backend (`sprites`, `local-dev`, `vercel`, `cloudflare`) | | SENTRY_DSN | Sentry DSN | | SENTRY_RELEASE | Release identifier for Sentry events and sourcemaps | | SENTRY_ENVIRONMENT | Sentry environment name (prod, staging, etc.) | @@ -180,6 +181,16 @@ Cloudflare runtime flow: | 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) | +| VERCEL_TEAM_ID | Vercel team ID for Sandbox SDK (required with `VERCEL_TOKEN`) | +| VERCEL_PROJECT_ID | Vercel project ID for Sandbox SDK (required with `VERCEL_TOKEN`) | +| VERCEL_TOKEN | Vercel API token for Sandbox SDK | +| VERCEL_REPO_CLONE_COMMAND | Optional repo clone command template for Vercel runtime | +| VERCEL_SANDBOX_AGENT_PORT | sandbox-agent port inside Vercel sandbox (default `2468`) | +| VERCEL_SANDBOX_TIMEOUT_MS | Vercel sandbox timeout in milliseconds (default `1800000`) | +| VERCEL_SANDBOX_RUNTIME | Vercel sandbox runtime image (default `node24`) | +| VERCEL_SANDBOX_VCPUS | Optional Vercel sandbox vCPU count | +| VERCEL_HEALTH_RETRIES | Vercel sandbox health check retry count | +| VERCEL_HEALTH_INTERVAL_MS | Vercel sandbox health check retry interval (ms) | | GITHUB_APP_ID | GitHub App ID used to mint installation tokens | | GITHUB_APP_INSTALLATION_ID | Optional fixed installation ID (if omitted, resolved from repo) | | GITHUB_APP_PRIVATE_KEY | GitHub App private key PEM used for JWT signing | diff --git a/deps/workers/docs/milestones/agents/00-index.md b/deps/workers/docs/milestones/agents/00-index.md index ca46e391ba..a7ef527b5f 100644 --- a/deps/workers/docs/milestones/agents/00-index.md +++ b/deps/workers/docs/milestones/agents/00-index.md @@ -22,3 +22,4 @@ Milestones are tracked as separate files in this folder: - `16-m16-cloudflare-sandbox-backup-restore.md` - `17-m17-separate-agents-worker-from-db-sync.md` - `18-m18-github-app-installation-tokens.md` +- `19-m19-vercel-sandbox-runtime-and-snapshot.md` diff --git a/deps/workers/docs/milestones/agents/19-m19-vercel-sandbox-runtime-and-snapshot.md b/deps/workers/docs/milestones/agents/19-m19-vercel-sandbox-runtime-and-snapshot.md new file mode 100644 index 0000000000..892a288bdc --- /dev/null +++ b/deps/workers/docs/milestones/agents/19-m19-vercel-sandbox-runtime-and-snapshot.md @@ -0,0 +1,70 @@ +# M19: Vercel Sandbox Runtime and Snapshot Support + +Status: Proposed +Target: Add a first-class `vercel` runtime provider with snapshot create/restore support for agent sessions. + +## Goal +Support `AGENT_RUNTIME_PROVIDER=vercel` end-to-end so sessions can run in Vercel Sandbox and use snapshots for fast recovery and branch/task handoff. + +## Why M19 +- Runtime providers currently cover `sprites`, `cloudflare`, and `local-dev`, but not Vercel Sandbox. +- Snapshot behavior is becoming a core workflow primitive for long-running tasks and recovery from sandbox restarts. +- A Vercel provider reduces platform lock-in and gives teams another hosted runtime option with the same session API. + +## Scope +1) Add `VercelProvider` in `src/logseq/agents/runtime_provider.cljs`. +2) Implement runtime lifecycle methods for Vercel: +- ` ~/.git-credentials; " "chmod 600 ~/.git-credentials; ")))) -(defn- get-repo-dir [session-id] - (let [session-id (some-> session-id str)] - (when (string? session-id) - (str default-repo-base-dir "/" (sanitize-name session-id))))) +(declare task-repo-url) + +(defn- task-repo-name + [task] + (let [repo-url (task-repo-url task) + from-ref (some-> repo-url source-control/repo-ref :name sanitize-name) + from-url (some-> repo-url + (string/replace #"/+$" "") + (string/split #"/") + last + (string/replace #"\.git$" "") + sanitize-name)] + (or from-ref from-url "repo"))) + +(defn- repo-base-dir + [provider] + (if (= "vercel" (normalize-provider provider)) + vercel-repo-base-dir + default-repo-base-dir)) + +(defn- get-repo-dir + ([session-id] + (get-repo-dir session-id nil nil)) + ([session-id task provider] + (if (= "vercel" (normalize-provider provider)) + (str vercel-repo-base-dir "/" (task-repo-name task)) + (let [session-id (some-> session-id str)] + (when (string? session-id) + (str default-repo-base-dir "/" (sanitize-name session-id))))))) (defn- repo-cd-command - [session-id] - (when-let [dir (get-repo-dir session-id)] - (str "cd '" (escape-shell-single dir) "'"))) + ([session-id] + (repo-cd-command session-id nil nil)) + ([session-id task provider] + (when-let [dir (get-repo-dir session-id task provider)] + (str "cd '" (escape-shell-single dir) "'")))) ;; FIXME: sandbox-agent 2.x.x changes session routes to opencode/session (defn- sandbox-agent-version @@ -507,6 +541,45 @@ (when (string? backup-key) (swap! cloudflare-backup-cache dissoc backup-key))) +(defn- prune-vercel-snapshot-cache! + [] + (let [now (js/Date.now)] + (swap! vercel-snapshot-cache + (fn [entries] + (reduce-kv (fn [acc k v] + (let [expires-at-ms (:expires-at-ms v) + snapshot-id (:id v)] + (if (and (string? k) + (string? snapshot-id) + (number? expires-at-ms) + (> expires-at-ms now)) + (assoc acc k v) + acc))) + {} + entries))))) + +(defn- vercel-snapshot-entry + [backup-key] + (prune-vercel-snapshot-cache!) + (when (string? backup-key) + (get @vercel-snapshot-cache backup-key))) + +(defn- remember-vercel-snapshot! + [backup-key snapshot-id source-dir] + (when (and (string? backup-key) (string? snapshot-id)) + (let [now (js/Date.now) + ttl-ms (* cloudflare-snapshot-ttl-seconds 1000)] + (swap! vercel-snapshot-cache assoc backup-key {:id snapshot-id + :dir source-dir + :ttl-seconds cloudflare-snapshot-ttl-seconds + :expires-at-ms (+ now ttl-ms) + :updated-at-ms now})))) + +(defn- forget-vercel-snapshot! + [backup-key] + (when (string? backup-key) + (swap! vercel-snapshot-cache dissoc backup-key))) + (defn- sanitize-backup-name [value] (let [raw (or (some-> value str string/lower-case) "snapshot") @@ -542,9 +615,11 @@ ([^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" + repo-dir (get-repo-dir session-id task provider) + base-dir (repo-base-dir provider) + override-key (case provider + "cloudflare" "CLOUDFLARE_REPO_CLONE_COMMAND" + "vercel" "VERCEL_REPO_CLONE_COMMAND" "SPRITES_REPO_CLONE_COMMAND") override (env-str env override-key)] (when (and (string? repo-url) (string? session-id) (string? repo-dir)) @@ -552,19 +627,21 @@ (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 + (str "mkdir -p '" (escape-shell-single base-dir) "'" + " && cd '" (escape-shell-single 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- project-init-setup-command - [session-id task] - (let [setup-script (task-project-init-setup task) - session-id (some-> session-id str) - repo-dir (get-repo-dir session-id)] - (when (and (string? setup-script) (string? repo-dir)) - (str "set -e; cd '" (escape-shell-single repo-dir) "'; " setup-script)))) + ([session-id task] + (project-init-setup-command session-id task "sprites")) + ([session-id task provider] + (let [setup-script (task-project-init-setup task) + session-id (some-> session-id str) + repo-dir (get-repo-dir session-id task provider)] + (when (and (string? setup-script) (string? repo-dir)) + (str "set -e; cd '" (escape-shell-single repo-dir) "'; " setup-script))))) (defn- classify-push-error [error] @@ -766,6 +843,54 @@ (or (:agent-token runtime) (env-str env "SANDBOX_AGENT_TOKEN"))) +(defn- vercel-agent-token [^js env runtime] + (or (:agent-token runtime) + (env-str env "SANDBOX_AGENT_TOKEN"))) + +(defn- vercel-agent-port [^js env runtime] + (or (:sandbox-port runtime) + (parse-int (env-str env "VERCEL_SANDBOX_AGENT_PORT") 2468))) + +(defn- vercel-health-retries [^js env] + (parse-int (env-str env "VERCEL_HEALTH_RETRIES") 30)) + +(defn- vercel-health-interval-ms [^js env] + (parse-int (env-str env "VERCEL_HEALTH_INTERVAL_MS") 300)) + +(defn- vercel-sandbox-timeout-ms [^js env] + (parse-int (env-str env "VERCEL_SANDBOX_TIMEOUT_MS") (* 30 60 1000))) + +(defn- vercel-sandbox-runtime [^js env] + (or (env-str env "VERCEL_SANDBOX_RUNTIME") + "node24")) + +(defn- vercel-sandbox-vcpus [^js env] + (let [v (parse-int (env-str env "VERCEL_SANDBOX_VCPUS") 0)] + (when (pos? v) v))) + +(defn- vercel-sdk-credentials [^js env] + (let [team-id (env-str env "VERCEL_TEAM_ID") + project-id (env-str env "VERCEL_PROJECT_ID") + token (env-str env "VERCEL_TOKEN") + has-explicit? (or (string? team-id) (string? project-id) (string? token))] + (cond + (and (string? team-id) (string? project-id) (string? token)) + {:teamId team-id + :projectId project-id + :token token} + + has-explicit? + (throw (ex-info "missing VERCEL_TEAM_ID/VERCEL_PROJECT_ID/VERCEL_TOKEN for vercel runtime provider" + {:reason :missing-vercel-credentials + :team-id? (boolean (string? team-id)) + :project-id? (boolean (string? project-id)) + :token? (boolean (string? token))})) + + :else + (throw (ex-info "missing vercel sdk credentials" + {:reason :missing-vercel-credentials + :required ["VERCEL_TEAM_ID" "VERCEL_PROJECT_ID" "VERCEL_TOKEN"]}))))) + (defn- cloudflare-sandbox-namespace [^js env] (let [sandbox-ns (aget env "Sandbox")] (when-not sandbox-ns @@ -1190,6 +1315,299 @@ :session-id session-id}))) (->promise (.call terminal session request (clj->js opts)))))))) +(defn vercel-sandbox-class + [] + (let [sandbox-class (aget vercel-sandbox "Sandbox")] + (when-not sandbox-class + (throw (ex-info "missing @vercel/sandbox Sandbox export" + {:reason :missing-vercel-sdk}))) + sandbox-class)) + +(defn- vercel-create-params + [^js env source] + (let [port (vercel-agent-port env nil) + timeout-ms (vercel-sandbox-timeout-ms env) + runtime (vercel-sandbox-runtime env) + vcpus (vercel-sandbox-vcpus env) + creds (vercel-sdk-credentials env)] + (cond-> (merge creds + {:ports [port] + :timeout timeout-ms + :runtime runtime} + (when (map? source) + {:source source})) + (number? vcpus) (assoc :resources {:vcpus vcpus})))) + +(defn promise (.call create sandbox-class (clj->js (vercel-create-params env source)))))) + +(defn promise (.call get-sandbox sandbox-class + (clj->js (assoc (vercel-sdk-credentials env) + :sandboxId sandbox-id)))))) + +(defn vercel-sandbox-domain + [sandbox port] + (let [domain (js-method sandbox "domain")] + (when-not (fn? domain) + (throw (ex-info "vercel sandbox missing domain method" + {:reason :missing-vercel-domain}))) + (.call domain sandbox port))) + +(defn promise (.call stop sandbox #js {:blocking false})) + (p/resolved nil)))) + +(defn- promise (.call stdout-fn command)) + "") + stderr (if (fn? stderr-fn) + (->promise (.call stderr-fn command)) + "")] + {:stdout (or stdout "") + :stderr (or stderr "") + :exit-code (aget command "exitCode")}))) + +(defn {:cmd "bash" + :args ["-lc" command]} + (map? (:env opts)) (assoc :env (:env opts)) + (true? (:detached opts)) (assoc :detached true))] + (when-not (fn? run-command) + (throw (ex-info "vercel sandbox missing runCommand method" + {:reason :missing-vercel-run-command}))) + (if (true? (:detached opts)) + (->promise (.call run-command sandbox (clj->js params))) + (p/let [result (->promise (.call run-command sandbox (clj->js params))) + {:keys [stdout stderr exit-code]} (/dev/null; then echo __HEALTH_OK__; else echo __HEALTH_FAIL__; fi")) + +(defn- ( (get-in task [:agent :api-token]) str string/trim not-empty)] + (p/let [github-token ( (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) + (string? github-token) (assoc-github-installation-token github-token))))) + +(defn- vercel-server-command + [^js env task session-id port agent-token env-vars] + (let [auth-json (get-in task [:agent :auth-json]) + write-auth (or (auth-json-write-command auth-json) "") + repo-cd (or (repo-cd-command session-id task "vercel") "") + token-exports (shell-export-command env-vars) + github-auth-setup (github-auth-setup-command (get env-vars "GITHUB_TOKEN"))] + (str (sandbox-agent-install-command env) + token-exports + github-auth-setup + write-auth + (when (string? repo-cd) (str repo-cd "; ")) + "nohup 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 >/tmp/sandbox-agent.log 2>&1 &"))) + +(defn- setup-promise + (p/catch (fn [error] + (log/error :agent/vercel-project-init-setup-failed + {:session-id session-id + :sandbox-id sandbox-id + :error (str error)})))))) + 0) + nil) + +(defn- ( (->promise (.call snapshot-fn sandbox + (clj->js {:expiration (vercel-snapshot-expiration-ms)}))) + (p/then (fn [snapshot] + (let [snapshot-id (aget snapshot "snapshotId")] + (if (string? snapshot-id) + {:snapshot-id snapshot-id + :name snapshot-name + :dir source-dir} + (throw (ex-info "vercel snapshot create returned invalid id" + {:reason :invalid-snapshot-id + :snapshot snapshot})))))) + (p/catch (fn [error] + (p/rejected + (ex-info (or (some-> error ex-message str string/trim not-empty) + "vercel snapshot create failed") + {:reason :unsupported-snapshot + :provider "vercel" + :raw-error (str error)})))))))) + (defprotocol RuntimeProvider (LocalDevProvider env) + "vercel" (->VercelProvider env) "cloudflare" (->CloudflareProvider env) (->SpritesProvider env))) diff --git a/deps/workers/src/logseq/agents/sandbox.cljs b/deps/workers/src/logseq/agents/sandbox.cljs index bf4af63cc6..81067daaf9 100644 --- a/deps/workers/src/logseq/agents/sandbox.cljs +++ b/deps/workers/src/logseq/agents/sandbox.cljs @@ -24,6 +24,18 @@ (defn terminate-url [base session-id] (str (session-url base session-id) "/terminate")) +(defn exec-command-url [base] + (str (normalize-base-url base) "/v1/commands/exec")) + +(defn snapshots-base-url [base] + (str (normalize-base-url base) "/v1/snapshots")) + +(defn snapshot-url [base snapshot-id] + (str (snapshots-base-url base) "/" snapshot-id)) + +(defn snapshot-restore-url [base snapshot-id] + (str (snapshot-url base snapshot-id) "/restore")) + (def ^:private agent-aliases {"claude-code" "claude" "claude_code" "claude" @@ -125,3 +137,56 @@ {:status status :session-id session-id}))) true))) + +(defn {} + (string? dir) (assoc :dir dir) + (string? name) (assoc :name name) + (number? ttl) (assoc :ttl ttl)) + req (json-request (snapshots-base-url base) "POST" headers body)] + (p/let [resp (js/fetch req) + status (.-status resp) + json (parse-json-or-default resp {})] + (if (<= 200 status 299) + (if (map? json) json opts) + (throw (ex-info "sandbox create-snapshot failed" + {:status status + :response json})))))) + +(defn {} + (string? dir) (assoc :dir dir)) + req (json-request (snapshot-restore-url base snapshot-id) "POST" headers body)] + (p/let [resp (js/fetch req) + status (.-status resp) + json (parse-json-or-default resp {})] + (if (<= 200 status 299) + json + (throw (ex-info "sandbox restore-snapshot failed" + {:status status + :snapshot-id snapshot-id + :response json})))))) diff --git a/deps/workers/src/logseq/sync/node/config.cljs b/deps/workers/src/logseq/sync/node/config.cljs index d8e09caa99..90ce3a5615 100644 --- a/deps/workers/src/logseq/sync/node/config.cljs +++ b/deps/workers/src/logseq/sync/node/config.cljs @@ -44,6 +44,16 @@ :cloudflare-repo-clone-command (env-value env "CLOUDFLARE_REPO_CLONE_COMMAND") :cloudflare-health-retries (env-value env "CLOUDFLARE_HEALTH_RETRIES") :cloudflare-health-interval-ms (env-value env "CLOUDFLARE_HEALTH_INTERVAL_MS") + :vercel-team-id (env-value env "VERCEL_TEAM_ID") + :vercel-project-id (env-value env "VERCEL_PROJECT_ID") + :vercel-token (env-value env "VERCEL_TOKEN") + :vercel-repo-clone-command (env-value env "VERCEL_REPO_CLONE_COMMAND") + :vercel-sandbox-agent-port (env-value env "VERCEL_SANDBOX_AGENT_PORT") + :vercel-sandbox-timeout-ms (env-value env "VERCEL_SANDBOX_TIMEOUT_MS") + :vercel-sandbox-runtime (env-value env "VERCEL_SANDBOX_RUNTIME") + :vercel-sandbox-vcpus (env-value env "VERCEL_SANDBOX_VCPUS") + :vercel-health-retries (env-value env "VERCEL_HEALTH_RETRIES") + :vercel-health-interval-ms (env-value env "VERCEL_HEALTH_INTERVAL_MS") :github-app-id (env-value env "GITHUB_APP_ID") :github-app-installation-id (env-value env "GITHUB_APP_INSTALLATION_ID") :github-app-private-key (env-value env "GITHUB_APP_PRIVATE_KEY") @@ -69,6 +79,9 @@ :cloudflare-sandbox-name-prefix :cloudflare-sandbox-agent-port :cloudflare-bootstrap-command :cloudflare-repo-clone-command :cloudflare-health-retries :cloudflare-health-interval-ms + :vercel-team-id :vercel-project-id :vercel-token :vercel-repo-clone-command + :vercel-sandbox-agent-port :vercel-sandbox-timeout-ms :vercel-sandbox-runtime + :vercel-sandbox-vcpus :vercel-health-retries :vercel-health-interval-ms :github-app-id :github-app-installation-id :github-app-private-key :github-app-slug :github-api-base :openai-api-key :anthropic-api-key :openai-base-url :anthropic-base-url :log-level :cognito-issuer :cognito-client-id :cognito-jwks-url]) diff --git a/deps/workers/src/logseq/sync/node/server.cljs b/deps/workers/src/logseq/sync/node/server.cljs index 26eaf82309..5a850628c9 100644 --- a/deps/workers/src/logseq/sync/node/server.cljs +++ b/deps/workers/src/logseq/sync/node/server.cljs @@ -53,6 +53,16 @@ (aset "CLOUDFLARE_REPO_CLONE_COMMAND" (:cloudflare-repo-clone-command cfg)) (aset "CLOUDFLARE_HEALTH_RETRIES" (:cloudflare-health-retries cfg)) (aset "CLOUDFLARE_HEALTH_INTERVAL_MS" (:cloudflare-health-interval-ms cfg)) + (aset "VERCEL_TEAM_ID" (:vercel-team-id cfg)) + (aset "VERCEL_PROJECT_ID" (:vercel-project-id cfg)) + (aset "VERCEL_TOKEN" (:vercel-token cfg)) + (aset "VERCEL_REPO_CLONE_COMMAND" (:vercel-repo-clone-command cfg)) + (aset "VERCEL_SANDBOX_AGENT_PORT" (:vercel-sandbox-agent-port cfg)) + (aset "VERCEL_SANDBOX_TIMEOUT_MS" (:vercel-sandbox-timeout-ms cfg)) + (aset "VERCEL_SANDBOX_RUNTIME" (:vercel-sandbox-runtime cfg)) + (aset "VERCEL_SANDBOX_VCPUS" (:vercel-sandbox-vcpus cfg)) + (aset "VERCEL_HEALTH_RETRIES" (:vercel-health-retries cfg)) + (aset "VERCEL_HEALTH_INTERVAL_MS" (:vercel-health-interval-ms cfg)) (aset "GITHUB_APP_ID" (:github-app-id cfg)) (aset "GITHUB_APP_INSTALLATION_ID" (:github-app-installation-id cfg)) (aset "GITHUB_APP_PRIVATE_KEY" (:github-app-private-key cfg)) diff --git a/deps/workers/test/logseq/agents/runtime_provider_test.cljs b/deps/workers/test/logseq/agents/runtime_provider_test.cljs index 51c20b43ed..ebfbda9ed5 100644 --- a/deps/workers/test/logseq/agents/runtime_provider_test.cljs +++ b/deps/workers/test/logseq/agents/runtime_provider_test.cljs @@ -1,6 +1,7 @@ (ns logseq.agents.runtime-provider-test (:require [cljs.test :refer [async deftest is testing]] [clojure.string :as string] + [logseq.agents.sandbox :as sandbox] [logseq.agents.runtime-provider :as runtime-provider])) (defn- fetch-url [request] @@ -20,6 +21,7 @@ (is (= "sprites" (runtime-provider/provider-kind #js {}))) (is (= "sprites" (runtime-provider/provider-kind #js {"AGENT_RUNTIME_PROVIDER" "SPRITES"}))) (is (= "local-dev" (runtime-provider/provider-kind #js {"AGENT_RUNTIME_PROVIDER" "LOCAL-DEV"}))) + (is (= "vercel" (runtime-provider/provider-kind #js {"AGENT_RUNTIME_PROVIDER" "VERCEL"}))) (is (= "cloudflare" (runtime-provider/provider-kind #js {"AGENT_RUNTIME_PROVIDER" "CLOUDFLARE"}))) (is (= "sprites" (runtime-provider/provider-kind #js {"AGENT_RUNTIME_PROVIDER" "unknown"}))))) @@ -37,6 +39,8 @@ (runtime-provider/runtime-provider-kind env {:provider "local-dev"}))) (is (= "sprites" (runtime-provider/runtime-provider-kind env {:provider "sprites"}))) + (is (= "vercel" + (runtime-provider/runtime-provider-kind env {:provider "vercel"}))) (is (= "cloudflare" (runtime-provider/runtime-provider-kind env {:provider "cloudflare"}))) (is (= "cloudflare" @@ -49,6 +53,8 @@ (runtime-provider/provider-id (runtime-provider/create-provider env "sprites")))) (is (= "local-dev" (runtime-provider/provider-id (runtime-provider/create-provider env "local-dev")))) + (is (= "vercel" + (runtime-provider/provider-id (runtime-provider/create-provider env "vercel")))) (is (= "cloudflare" (runtime-provider/provider-id (runtime-provider/create-provider env "cloudflare")))) (is (= "sprites" @@ -72,7 +78,7 @@ (let [env #js {} task {:project {:repo-url "https://github.com/example/repo"}} session-id "sess-1"] - (is (= "mkdir -p /workspace && cd /workspace && git clone --depth 1 --single-branch --no-tags 'https://github.com/example/repo' '/workspace/sess-1' && chmod -R u+rw '/workspace/sess-1'" + (is (= "mkdir -p '/workspace' && cd '/workspace' && git clone --depth 1 --single-branch --no-tags 'https://github.com/example/repo' '/workspace/sess-1' && chmod -R u+rw '/workspace/sess-1'" (runtime-provider/repo-clone-command env session-id task))))) (testing "fills override repo clone command template" @@ -82,6 +88,20 @@ (is (= "echo https://github.com/example/repo sess-1 /workspace/sess-1" (runtime-provider/repo-clone-command env session-id task))))) + (testing "fills vercel override repo clone command template" + (let [env #js {"VERCEL_REPO_CLONE_COMMAND" "echo vercel {repo_url} {session_id} {repo_dir}"} + task {:project {:repo-url "https://github.com/example/repo"}} + session-id "sess-1"] + (is (= "echo vercel https://github.com/example/repo sess-1 /vercel/sandbox/repo" + (runtime-provider/repo-clone-command env session-id task "vercel"))))) + + (testing "uses /vercel/sandbox/ for vercel default clone path" + (let [env #js {} + task {:project {:repo-url "https://github.com/logseq/logseq.git"}} + session-id "sess-1"] + (is (= "mkdir -p '/vercel/sandbox' && cd '/vercel/sandbox' && git clone --depth 1 --single-branch --no-tags 'https://github.com/logseq/logseq.git' '/vercel/sandbox/logseq' && chmod -R u+rw '/vercel/sandbox/logseq'" + (runtime-provider/repo-clone-command env session-id task "vercel"))))) + (testing "returns nil when repo url missing" (is (nil? (runtime-provider/repo-clone-command #js {} "sess-1" {}))))) @@ -204,7 +224,93 @@ (.catch (fn [error] (let [data (ex-data error)] (is (= :unsupported (:reason data))) - (is (= "local-dev" (:provider data)))) + (is (= "local-dev" (:provider data)))) + (done))))))) + +(deftest vercel-provider-snapshot-restore-flow-test + (async done + (runtime-provider/clear-vercel-snapshot-cache!) + (let [calls (atom {:clone 0 + :sessions 0 + :snapshots 0 + :restores 0}) + sandboxes (atom {}) + next-id (atom 0) + make-sandbox (fn [sandbox-id] + #js {:sandboxId sandbox-id + :domain (fn [_port] "https://vercel-agent.local")}) + env #js {"VERCEL_TEAM_ID" "team-1" + "VERCEL_PROJECT_ID" "project-1" + "VERCEL_TOKEN" "token-vercel"} + provider (runtime-provider/create-provider env "vercel") + task {:agent {:provider "codex"} + :project {:repo-url "https://github.com/example/repo" + :base-branch "main"}}] + (with-redefs [runtime-provider/ (runtime-provider/ (runtime-provider/ (runtime-provider/ (runtime-provider/ (agent-handler/ (agent-handler/