mirror of
https://github.com/logseq/logseq.git
synced 2026-05-29 15:09:41 +00:00
feat(cli): agent bridge
This commit is contained in:
@@ -126,7 +126,8 @@
|
||||
:properties [:logseq.property.comments/blocks]}]
|
||||
["65.28" {:classes [:logseq.class/Comment]
|
||||
:fix tag-comment-blocks}]
|
||||
["65.29" {:fix add-single-block-comment-targets}]])
|
||||
["65.29" {:fix add-single-block-comment-targets}]
|
||||
["65.30" {:properties [:logseq.property/assignee]}]])
|
||||
|
||||
(let [[major minor] (last (sort (map (comp (juxt :major :minor) db-schema/parse-schema-version first)
|
||||
schema-version->updates)))]
|
||||
|
||||
@@ -609,6 +609,8 @@
|
||||
(install-file-logger! {:root-dir root-dir
|
||||
:repo repo
|
||||
:log-level (keyword (or log-level "info"))})
|
||||
(log/info :db-worker-node-version {:build-time (build-version/build-time)
|
||||
:revision (build-version/revision)})
|
||||
(reset! *ready? false)
|
||||
(reset! *lock-info nil)
|
||||
(reset! *server-list-file server-list-file)
|
||||
|
||||
825
src/main/logseq/cli/command/agent.cljs
Normal file
825
src/main/logseq/cli/command/agent.cljs
Normal file
@@ -0,0 +1,825 @@
|
||||
(ns logseq.cli.command.agent
|
||||
"Agent bridge command helpers."
|
||||
(:require ["child_process" :as child-process]
|
||||
["fs" :as fs]
|
||||
["os" :as os]
|
||||
["path" :as node-path]
|
||||
[cljs.reader :as reader]
|
||||
[clojure.string :as string]
|
||||
[lambdaisland.glogi :as log]
|
||||
[logseq.cli.command.core :as core]
|
||||
[logseq.cli.command.show :as show-command]
|
||||
[logseq.cli.server :as cli-server]
|
||||
[logseq.cli.transport :as transport]
|
||||
[logseq.common.util :as common-util]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(def ^:private bridge-spec
|
||||
{:dry-run {:desc "Print Codex commands without starting Codex or writing agent-session-id"
|
||||
:coerce :boolean}})
|
||||
|
||||
(def ^:private bridge-list-spec
|
||||
{:all {:desc "Include completed sessions"
|
||||
:coerce :boolean}})
|
||||
|
||||
(def entries
|
||||
[(core/command-entry ["agent" "bridge"]
|
||||
:agent-bridge
|
||||
"Run task agent bridge"
|
||||
bridge-spec
|
||||
{:examples ["logseq agent bridge --graph my-graph"
|
||||
"logseq agent bridge --graph my-graph --dry-run"]})
|
||||
(core/command-entry ["agent" "bridge" "list"]
|
||||
:agent-bridge-list
|
||||
"List agent bridge sessions"
|
||||
bridge-list-spec
|
||||
{:examples ["logseq agent bridge list"
|
||||
"logseq agent bridge list --all"]})])
|
||||
|
||||
(defn- trim-non-empty
|
||||
[value]
|
||||
(some-> value str string/trim not-empty))
|
||||
|
||||
(defn resolve-agent-name
|
||||
([config]
|
||||
(resolve-agent-name config {:hostname (.hostname os)}))
|
||||
([config {:keys [hostname]}]
|
||||
(let [configured? (contains? config :agent-name)
|
||||
configured (trim-non-empty (:agent-name config))
|
||||
hostname (trim-non-empty hostname)
|
||||
agent-name (if configured?
|
||||
configured
|
||||
hostname)]
|
||||
(if (seq agent-name)
|
||||
{:ok? true
|
||||
:agent-name agent-name}
|
||||
{:ok? false
|
||||
:error {:code :agent-name-invalid
|
||||
:message (if configured?
|
||||
"agent-name in cli.edn must be a non-empty string"
|
||||
"agent-name cannot be resolved from cli.edn or hostname")}}))))
|
||||
|
||||
(defn build-action
|
||||
[command options repo graph]
|
||||
(case command
|
||||
:agent-bridge
|
||||
(if-not (seq repo)
|
||||
{:ok? false
|
||||
:error {:code :missing-repo
|
||||
:message "repo is required for agent bridge"}}
|
||||
{:ok? true
|
||||
:action {:type :agent-bridge
|
||||
:repo repo
|
||||
:graph graph
|
||||
:dry-run? (boolean (:dry-run options))}})
|
||||
|
||||
:agent-bridge-list
|
||||
{:ok? true
|
||||
:action {:type :agent-bridge-list
|
||||
:all? (boolean (:all options))}}
|
||||
|
||||
{:ok? false
|
||||
:error {:code :unknown-command
|
||||
:message (str "unknown agent command: " command)}}))
|
||||
|
||||
(defn- value-ident
|
||||
[value]
|
||||
(cond
|
||||
(keyword? value) value
|
||||
(map? value) (:db/ident value)
|
||||
:else nil))
|
||||
|
||||
(defn- value-title
|
||||
[value]
|
||||
(cond
|
||||
(nil? value) nil
|
||||
(string? value) value
|
||||
(keyword? value) (name value)
|
||||
(map? value) (or (:block/title value)
|
||||
(:name value)
|
||||
(some-> (:db/ident value) name))
|
||||
:else (str value)))
|
||||
|
||||
(defn- value-titles
|
||||
[value]
|
||||
(if (and (sequential? value)
|
||||
(not (string? value)))
|
||||
(keep value-title value)
|
||||
(keep value-title [value])))
|
||||
|
||||
(defn- task-tag?
|
||||
[tag]
|
||||
(or (= :logseq.class/Task (value-ident tag))
|
||||
(= "Task" (value-title tag))))
|
||||
|
||||
(defn- property-key-name
|
||||
[k]
|
||||
(cond
|
||||
(keyword? k) (name k)
|
||||
(string? k) k
|
||||
:else nil))
|
||||
|
||||
(defn- property-value-by-name
|
||||
[block property-name]
|
||||
(some (fn [[k v]]
|
||||
(when (= property-name (property-key-name k))
|
||||
v))
|
||||
block))
|
||||
|
||||
(defn- assignee-values
|
||||
[block]
|
||||
(->> (concat (mapcat (fn [k]
|
||||
(value-titles (get block k)))
|
||||
["Assignee" :Assignee :assignee :logseq.property/assignee])
|
||||
(value-titles (property-value-by-name block "assignee"))
|
||||
(value-titles (property-value-by-name block "Assignee")))
|
||||
(keep trim-non-empty)
|
||||
set))
|
||||
|
||||
(defn- agent-session-id
|
||||
[block]
|
||||
(or (some (fn [k]
|
||||
(trim-non-empty (get block k)))
|
||||
["agent-session-id" :agent-session-id :logseq.property/agent-session-id])
|
||||
(some-> (property-value-by-name block "agent-session-id") trim-non-empty)))
|
||||
|
||||
(defn routable-task-decision
|
||||
[block agent-name]
|
||||
(let [uuid (:block/uuid block)
|
||||
status-ident (value-ident (:logseq.property/status block))
|
||||
assignees (assignee-values block)]
|
||||
(cond
|
||||
(nil? uuid)
|
||||
{:routable? false :reason :missing-stable-uuid}
|
||||
|
||||
(not-any? task-tag? (:block/tags block))
|
||||
{:routable? false :reason :missing-task-tag}
|
||||
|
||||
(not= :logseq.property/status.todo status-ident)
|
||||
{:routable? false :reason :not-todo}
|
||||
|
||||
(not (contains? assignees agent-name))
|
||||
{:routable? false :reason :assignee-mismatch}
|
||||
|
||||
(seq (agent-session-id block))
|
||||
{:routable? false :reason :already-routed}
|
||||
|
||||
:else
|
||||
{:routable? true})))
|
||||
|
||||
(defn routable-task?
|
||||
[block agent-name]
|
||||
(true? (:routable? (routable-task-decision block agent-name))))
|
||||
|
||||
(defn- block-uuid-str
|
||||
[block]
|
||||
(some-> (:block/uuid block) str))
|
||||
|
||||
(defn build-codex-prompt
|
||||
[{:keys [graph agent-name block tree-text]}]
|
||||
(string/join
|
||||
"\n"
|
||||
["You are handling a Logseq AgentBridge task."
|
||||
""
|
||||
(str "Graph: " graph)
|
||||
(str "Block UUID: " (block-uuid-str block))
|
||||
(str "AgentBridge name: " agent-name)
|
||||
""
|
||||
"Do not operate outside the target graph."
|
||||
"Write task results back into the graph."
|
||||
"Report the final status, files changed, commands run, verification, and any blockers."
|
||||
""
|
||||
"Task block tree:"
|
||||
(or tree-text (:block/title block) "")]))
|
||||
|
||||
(defn build-codex-command
|
||||
[prompt {:keys [codex-bin]}]
|
||||
[(or (trim-non-empty codex-bin) "codex") "exec" "--json" prompt])
|
||||
|
||||
(defn- shell-quote
|
||||
[value]
|
||||
(let [text (str value)]
|
||||
(if (re-matches #"[A-Za-z0-9._:/=-]+" text)
|
||||
text
|
||||
(str "'" (string/replace text #"'" "'\"'\"'") "'"))))
|
||||
|
||||
(defn command-preview
|
||||
[command]
|
||||
(string/join " " (map shell-quote command)))
|
||||
|
||||
(defn codex-available?
|
||||
[codex-bin]
|
||||
(let [result (.spawnSync child-process
|
||||
(or (trim-non-empty codex-bin) "codex")
|
||||
#js ["--version"]
|
||||
#js {:encoding "utf8"})]
|
||||
(zero? (or (.-status result) 1))))
|
||||
|
||||
(defn parse-codex-session-id-line
|
||||
[line]
|
||||
(try
|
||||
(let [payload (js->clj (js/JSON.parse line) :keywordize-keys true)]
|
||||
(or (:session-id payload)
|
||||
(:session_id payload)
|
||||
(:thread-id payload)
|
||||
(:thread_id payload)
|
||||
(get-in payload [:session :id])
|
||||
(get-in payload [:session :session-id])
|
||||
(get-in payload [:session :session_id])
|
||||
(get-in payload [:thread :id])
|
||||
(get-in payload [:thread :thread-id])
|
||||
(get-in payload [:thread :thread_id])))
|
||||
(catch :default _
|
||||
nil)))
|
||||
|
||||
(defn start-codex!
|
||||
[command {:keys [on-exit]}]
|
||||
(p/create
|
||||
(fn [resolve reject]
|
||||
(let [bin (first command)
|
||||
args (clj->js (vec (rest command)))
|
||||
child (.spawn child-process bin args #js {:stdio #js ["ignore" "pipe" "pipe"]})
|
||||
settled? (atom false)
|
||||
session-id* (atom nil)
|
||||
child-closed? (atom false)
|
||||
stdout-closed? (atom false)
|
||||
exit-code* (atom nil)
|
||||
stdout-buffer (atom "")
|
||||
settle! (fn [f value]
|
||||
(when-not @settled?
|
||||
(reset! settled? true)
|
||||
(f value)))
|
||||
handle-line! (fn [line]
|
||||
(when-let [session-id (parse-codex-session-id-line line)]
|
||||
(reset! session-id* session-id)
|
||||
(settle! resolve {:session session-id
|
||||
:status :running
|
||||
:process child})))
|
||||
flush-stdout-buffer! (fn []
|
||||
(when (seq @stdout-buffer)
|
||||
(handle-line! @stdout-buffer)
|
||||
(reset! stdout-buffer "")))
|
||||
finalize! (fn []
|
||||
(when (and @child-closed? @stdout-closed?)
|
||||
(flush-stdout-buffer!)
|
||||
(when (fn? on-exit)
|
||||
(on-exit @exit-code* @session-id*))
|
||||
(when-not @settled?
|
||||
(if (zero? (or @exit-code* 1))
|
||||
(settle! reject (ex-info "codex exited before reporting a session id"
|
||||
{:code :codex-session-id-missing}))
|
||||
(settle! reject (ex-info "codex exited before startup completed"
|
||||
{:code :codex-start-failed
|
||||
:exit-code @exit-code*}))))))]
|
||||
(.on child "error"
|
||||
(fn [error]
|
||||
(settle! reject (ex-info "failed to start codex"
|
||||
{:code :codex-start-failed
|
||||
:cause error}))))
|
||||
(.on (.-stdout child) "data"
|
||||
(fn [chunk]
|
||||
(let [text (str @stdout-buffer (.toString chunk "utf8"))
|
||||
lines (vec (.split text #"\r?\n"))
|
||||
complete-lines (butlast lines)
|
||||
trailing (last lines)]
|
||||
(reset! stdout-buffer trailing)
|
||||
(doseq [line complete-lines]
|
||||
(handle-line! line)))))
|
||||
(.on (.-stdout child) "close"
|
||||
(fn []
|
||||
(reset! stdout-closed? true)
|
||||
(finalize!)))
|
||||
(.on (.-stderr child) "data" (fn [_chunk] nil))
|
||||
(.on child "close"
|
||||
(fn [code _signal]
|
||||
(reset! exit-code* code)
|
||||
(reset! child-closed? true)
|
||||
(finalize!)))))))
|
||||
|
||||
(defn session-store-path
|
||||
[{:keys [root-dir]}]
|
||||
(node-path/join root-dir "agent-bridge-sessions.edn"))
|
||||
|
||||
(defn- read-session-store
|
||||
[config]
|
||||
(let [path (session-store-path config)]
|
||||
(if (fs/existsSync path)
|
||||
(reader/read-string (fs/readFileSync path "utf8"))
|
||||
{:sessions []})))
|
||||
|
||||
(defn- write-session-store!
|
||||
[config store]
|
||||
(let [path (session-store-path config)
|
||||
dir (node-path/dirname path)]
|
||||
(fs/mkdirSync dir #js {:recursive true})
|
||||
(fs/writeFileSync path (pr-str store) "utf8")
|
||||
store))
|
||||
|
||||
(def ^:private terminal-session-statuses #{:completed :failed})
|
||||
|
||||
(defn- merge-session-record
|
||||
[existing session]
|
||||
(let [merged (merge existing session)]
|
||||
(if (and (contains? terminal-session-statuses (:status existing))
|
||||
(= :running (:status session)))
|
||||
(assoc merged :status (:status existing))
|
||||
merged)))
|
||||
|
||||
(defn record-session!
|
||||
[config session]
|
||||
(let [store (read-session-store config)
|
||||
sessions (vec (:sessions store))
|
||||
session-id (:session session)
|
||||
sessions' (if (some #(= session-id (:session %)) sessions)
|
||||
(mapv (fn [existing]
|
||||
(if (= session-id (:session existing))
|
||||
(merge-session-record existing session)
|
||||
existing))
|
||||
sessions)
|
||||
(conj sessions session))]
|
||||
(write-session-store! config (assoc store :sessions sessions'))
|
||||
session))
|
||||
|
||||
(defn update-session-status!
|
||||
[config session-id status]
|
||||
(record-session! config {:session session-id
|
||||
:status status
|
||||
:updated-at (js/Date.now)}))
|
||||
|
||||
(defn list-sessions
|
||||
[config {:keys [all?]}]
|
||||
(let [sessions (vec (:sessions (read-session-store config)))]
|
||||
(if all?
|
||||
sessions
|
||||
(vec (remove #(= :completed (:status %)) sessions)))))
|
||||
|
||||
(defn execute-list
|
||||
[action config]
|
||||
{:status :ok
|
||||
:command :agent-bridge-list
|
||||
:data {:sessions (list-sessions config {:all? (:all? action)})}})
|
||||
|
||||
(defn- now-iso
|
||||
[]
|
||||
(.toISOString (js/Date.)))
|
||||
|
||||
(defn- log-line
|
||||
[message]
|
||||
(str (now-iso) " " message))
|
||||
|
||||
(defn- bridge-error
|
||||
[code message]
|
||||
{:status :error
|
||||
:command :agent-bridge
|
||||
:error {:code code
|
||||
:message message}})
|
||||
|
||||
(defn- log-bridge-exit!
|
||||
[{:keys [repo graph agent-name reason exit-code error]}]
|
||||
(log/info :agent-bridge-exit
|
||||
(cond-> {:reason reason
|
||||
:exit-code exit-code
|
||||
:repo repo
|
||||
:graph graph
|
||||
:agent-name agent-name
|
||||
:message (or (some-> error ex-message)
|
||||
(some-> error str))}
|
||||
(some-> error ex-data :code)
|
||||
(assoc :error-code (-> error ex-data :code)))))
|
||||
|
||||
(def agent-bridge-registry-page "AgentBridge")
|
||||
|
||||
(def agent-bridge-registry-page-query
|
||||
'[:find [(pull ?p [:db/id :block/uuid :block/name :block/title]) ...]
|
||||
:in $ ?page-name
|
||||
:where
|
||||
[?p :block/name ?page-name]])
|
||||
|
||||
(def registered-agent-query
|
||||
'[:find [(pull ?b [:db/id :block/uuid :block/title]) ...]
|
||||
:in $ ?page-id ?agent-name
|
||||
:where
|
||||
[?b :block/parent ?page-id]
|
||||
[?b :block/title ?agent-name]])
|
||||
|
||||
(defn random-bridge-block-uuid
|
||||
[]
|
||||
(random-uuid))
|
||||
|
||||
(defn- first-entity
|
||||
[entities]
|
||||
(first (filter :db/id entities)))
|
||||
|
||||
(defn- registry-page-name
|
||||
[]
|
||||
(common-util/page-name-sanity-lc agent-bridge-registry-page))
|
||||
|
||||
(defn- pull-registry-page
|
||||
[cfg repo]
|
||||
(p/let [pages (transport/invoke cfg :thread-api/q
|
||||
[repo [agent-bridge-registry-page-query
|
||||
(registry-page-name)]])]
|
||||
(first-entity pages)))
|
||||
|
||||
(defn- ensure-registry-page!
|
||||
[cfg repo]
|
||||
(p/let [existing (pull-registry-page cfg repo)]
|
||||
(if (:db/id existing)
|
||||
existing
|
||||
(p/let [result (transport/invoke cfg :thread-api/apply-outliner-ops
|
||||
[repo [[:create-page [agent-bridge-registry-page {}]]] {}])
|
||||
page-uuid (second result)]
|
||||
(if page-uuid
|
||||
(transport/invoke cfg :thread-api/pull
|
||||
[repo [:db/id :block/uuid :block/name :block/title] [:block/uuid page-uuid]])
|
||||
(pull-registry-page cfg repo))))))
|
||||
|
||||
(defn register-agent-bridge!
|
||||
[cfg repo agent-name]
|
||||
(p/let [page (ensure-registry-page! cfg repo)
|
||||
page-id (:db/id page)
|
||||
page-uuid (:block/uuid page)
|
||||
_ (when-not page-id
|
||||
(throw (ex-info "agent bridge registry page not found"
|
||||
{:code :agent-registration-failed})))
|
||||
_ (when-not page-uuid
|
||||
(throw (ex-info "agent bridge registry page uuid not found"
|
||||
{:code :agent-registration-failed})))
|
||||
existing (transport/invoke cfg :thread-api/q
|
||||
[repo [registered-agent-query page-id agent-name]])]
|
||||
(if (first-entity existing)
|
||||
true
|
||||
(p/let [_ (transport/invoke cfg :thread-api/apply-outliner-ops
|
||||
[repo [[:insert-blocks [[{:block/title agent-name
|
||||
:block/uuid (random-bridge-block-uuid)}]
|
||||
page-uuid
|
||||
{:outliner-op :insert-blocks
|
||||
:sibling? false
|
||||
:bottom? true
|
||||
:keep-uuid? true}]]]
|
||||
{}])]
|
||||
true))))
|
||||
|
||||
(def agent-session-id-property-name "agent-session-id")
|
||||
|
||||
(def agent-session-id-property-schema
|
||||
{:logseq.property/type :default
|
||||
:db/cardinality :db.cardinality/one})
|
||||
|
||||
(def agent-session-id-property-query
|
||||
'[:find [(pull ?p [:db/id :db/ident :block/name :block/title]) ...]
|
||||
:in $ ?property-name
|
||||
:where
|
||||
[?p :block/name ?property-name]
|
||||
[?p :db/ident]])
|
||||
|
||||
(defn- pull-agent-session-id-property
|
||||
[cfg repo]
|
||||
(p/let [properties (transport/invoke cfg :thread-api/q
|
||||
[repo [agent-session-id-property-query
|
||||
(common-util/page-name-sanity-lc agent-session-id-property-name)]])]
|
||||
(first-entity properties)))
|
||||
|
||||
(defn- ensure-agent-session-id-property!
|
||||
[cfg repo]
|
||||
(p/let [existing (pull-agent-session-id-property cfg repo)]
|
||||
(if (:db/ident existing)
|
||||
existing
|
||||
(p/let [_ (transport/invoke cfg :thread-api/apply-outliner-ops
|
||||
[repo [[:upsert-property [nil
|
||||
agent-session-id-property-schema
|
||||
{:property-name agent-session-id-property-name}]]]
|
||||
{}])
|
||||
created (pull-agent-session-id-property cfg repo)]
|
||||
(if (:db/ident created)
|
||||
created
|
||||
(throw (ex-info "agent-session-id property not found after upsert"
|
||||
{:code :agent-session-id-write-failed})))))))
|
||||
|
||||
(defn write-agent-session-id!
|
||||
[cfg repo block-uuid session-id]
|
||||
(p/let [property (ensure-agent-session-id-property! cfg repo)
|
||||
property-ident (:db/ident property)
|
||||
_ (when-not property-ident
|
||||
(throw (ex-info "agent-session-id property ident missing"
|
||||
{:code :agent-session-id-write-failed})))
|
||||
_ (transport/invoke cfg :thread-api/apply-outliner-ops
|
||||
[repo [[:batch-set-property [[block-uuid] property-ident session-id {}]]] {}])]
|
||||
true))
|
||||
|
||||
(def ^:private routable-task-query
|
||||
'[:find [(pull ?e [:db/id
|
||||
:block/uuid
|
||||
:block/title
|
||||
{:block/tags [:db/ident :block/title]}
|
||||
{:logseq.property/status [:db/ident :block/title]}
|
||||
*]) ...]
|
||||
:in $ ?agent-name
|
||||
:where
|
||||
[?e :block/tags :logseq.class/Task]
|
||||
[?e :logseq.property/status ?status]
|
||||
[?status :db/ident :logseq.property/status.todo]
|
||||
[?assignee-property :block/name "assignee"]
|
||||
[?assignee-property :db/ident ?assignee-attr]
|
||||
[?e ?assignee-attr ?assignee-ref]
|
||||
[?assignee-ref :block/title ?agent-name]])
|
||||
|
||||
(defn list-routable-tasks
|
||||
[cfg repo agent-name]
|
||||
(p/let [blocks (transport/invoke cfg :thread-api/q [repo [routable-task-query agent-name]])]
|
||||
(p/all
|
||||
(mapv (fn [block]
|
||||
(p/let [show-result (show-command/execute-show {:type :show
|
||||
:repo repo
|
||||
:uuid (block-uuid-str block)
|
||||
:level 100
|
||||
:linked-references? false
|
||||
:ref-id-footer? false}
|
||||
cfg)]
|
||||
{:block block
|
||||
:tree-text (or (get-in show-result [:data :message])
|
||||
(:block/title block))}))
|
||||
(filter #(routable-task? % agent-name)
|
||||
(map #(assoc % "Assignee" agent-name) blocks))))))
|
||||
|
||||
(defn- dry-run-commands
|
||||
[graph agent-name tasks]
|
||||
(mapv (fn [{:keys [block tree-text]}]
|
||||
(let [prompt (build-codex-prompt {:graph graph
|
||||
:agent-name agent-name
|
||||
:block block
|
||||
:tree-text tree-text})
|
||||
command (build-codex-command prompt {})]
|
||||
{:block (block-uuid-str block)
|
||||
:backend :codex
|
||||
:command command
|
||||
:preview (command-preview command)}))
|
||||
tasks))
|
||||
|
||||
(defn- emit-log!
|
||||
[config line]
|
||||
(if-let [f (:log-fn config)]
|
||||
(f line)
|
||||
(.write (.-stdout js/process) (str line "\n"))))
|
||||
|
||||
(defn- session-record
|
||||
[graph agent-name block session-id status]
|
||||
{:session session-id
|
||||
:status status
|
||||
:backend :codex
|
||||
:graph graph
|
||||
:block (block-uuid-str block)
|
||||
:agent agent-name
|
||||
:started-at (js/Date.now)
|
||||
:updated-at (js/Date.now)})
|
||||
|
||||
(defn- route-task!
|
||||
[cfg {:keys [repo graph agent-name]} {:keys [block tree-text]}]
|
||||
(let [prompt (build-codex-prompt {:graph graph
|
||||
:agent-name agent-name
|
||||
:block block
|
||||
:tree-text tree-text})
|
||||
command (build-codex-command prompt {})
|
||||
preview (command-preview command)]
|
||||
(emit-log! cfg (log-line (str "Codex command prepared for " (block-uuid-str block) ": " preview)))
|
||||
(p/let [{:keys [session]} (start-codex! command
|
||||
{:on-exit (fn [code session-id]
|
||||
(when session-id
|
||||
(update-session-status! cfg session-id
|
||||
(if (zero? (or code 1))
|
||||
:completed
|
||||
:failed))))})
|
||||
_ (when-not (seq session)
|
||||
(throw (ex-info "codex session id missing"
|
||||
{:code :codex-session-id-missing})))
|
||||
cfg* (cli-server/ensure-server! cfg repo)
|
||||
_ (record-session! cfg* (session-record graph agent-name block session :running))
|
||||
_ (write-agent-session-id! cfg* repo (:block/uuid block) session)]
|
||||
(emit-log! cfg (log-line (str "agent-session-id written for " (block-uuid-str block))))
|
||||
{:block (block-uuid-str block)
|
||||
:session session
|
||||
:backend :codex
|
||||
:preview preview})))
|
||||
|
||||
(defn- process-tasks!
|
||||
[cfg {:keys [repo graph agent-name]}]
|
||||
(p/let [tasks (list-routable-tasks cfg repo agent-name)]
|
||||
(p/all (mapv #(route-task! cfg {:repo repo
|
||||
:graph graph
|
||||
:agent-name agent-name}
|
||||
%)
|
||||
tasks))))
|
||||
|
||||
(def ^:private assignee-property-ident :logseq.property/assignee)
|
||||
|
||||
(def ^:private assignee-value-selector
|
||||
[:db/id :block/title :block/name])
|
||||
|
||||
(def ^:private assignee-property-selector
|
||||
[:db/id :db/ident])
|
||||
|
||||
(def ^:private task-block-selector
|
||||
[:db/id
|
||||
:block/uuid
|
||||
:block/title
|
||||
{:block/tags [:db/ident :block/title]}
|
||||
{:logseq.property/status [:db/ident :block/title]}
|
||||
{:logseq.property/assignee [:db/id :block/title :block/name :db/ident]}
|
||||
'*])
|
||||
|
||||
(defn- datom-added?
|
||||
[datom]
|
||||
(cond
|
||||
(map? datom)
|
||||
(not (false? (if (contains? datom :added)
|
||||
(:added datom)
|
||||
(:added? datom))))
|
||||
|
||||
(and (sequential? datom) (<= 5 (count datom)))
|
||||
(not (false? (nth datom 4)))
|
||||
|
||||
:else
|
||||
(not (false? (or (unchecked-get datom "added")
|
||||
(unchecked-get datom "added?")
|
||||
true)))))
|
||||
|
||||
(defn- datom-e
|
||||
[datom]
|
||||
(cond
|
||||
(map? datom) (:e datom)
|
||||
(sequential? datom) (first datom)
|
||||
:else (unchecked-get datom "e")))
|
||||
|
||||
(defn- datom-attr
|
||||
[datom]
|
||||
(cond
|
||||
(map? datom) (:a datom)
|
||||
(sequential? datom) (second datom)
|
||||
:else (unchecked-get datom "a")))
|
||||
|
||||
(defn- datom-value
|
||||
[datom]
|
||||
(cond
|
||||
(map? datom) (:v datom)
|
||||
(sequential? datom) (nth datom 2 nil)
|
||||
:else (unchecked-get datom "v")))
|
||||
|
||||
(defn- unknown-attr-datom?
|
||||
[datom]
|
||||
(let [attr (datom-attr datom)]
|
||||
(and (datom-added? datom)
|
||||
(some? attr)
|
||||
(not (keyword? attr)))))
|
||||
|
||||
(defn- direct-assignee-datom?
|
||||
[datom]
|
||||
(and (datom-added? datom)
|
||||
(= assignee-property-ident (datom-attr datom))))
|
||||
|
||||
(defn- pull-assignee-property
|
||||
[cfg repo]
|
||||
(transport/invoke cfg :thread-api/pull [repo assignee-property-selector assignee-property-ident]))
|
||||
|
||||
(defn- resolve-assignee-datoms
|
||||
[cfg repo tx-data]
|
||||
(let [direct-datoms (filter direct-assignee-datom? tx-data)
|
||||
unknown-attr-datoms (filter unknown-attr-datom? tx-data)]
|
||||
(if (seq unknown-attr-datoms)
|
||||
(p/let [property (pull-assignee-property cfg repo)
|
||||
property-id (:db/id property)]
|
||||
(concat direct-datoms
|
||||
(filter #(= property-id (datom-attr %)) unknown-attr-datoms)))
|
||||
(p/resolved direct-datoms))))
|
||||
|
||||
(defn- direct-assignee-title
|
||||
[value]
|
||||
(when (or (string? value)
|
||||
(keyword? value)
|
||||
(map? value))
|
||||
(trim-non-empty (value-title value))))
|
||||
|
||||
(defn- assignee-value-matches?
|
||||
[cfg repo value agent-name]
|
||||
(if-let [title (direct-assignee-title value)]
|
||||
(p/resolved (= agent-name title))
|
||||
(p/let [entity (transport/invoke cfg :thread-api/pull [repo assignee-value-selector value])]
|
||||
(= agent-name (trim-non-empty (value-title entity))))))
|
||||
|
||||
(defn- pull-task-block
|
||||
[cfg repo block-id]
|
||||
(transport/invoke cfg :thread-api/pull [repo task-block-selector block-id]))
|
||||
|
||||
(defn- show-task-tree
|
||||
[cfg repo block]
|
||||
(p/let [show-result (show-command/execute-show {:type :show
|
||||
:repo repo
|
||||
:uuid (block-uuid-str block)
|
||||
:level 100
|
||||
:linked-references? false
|
||||
:ref-id-footer? false}
|
||||
cfg)]
|
||||
(or (get-in show-result [:data :message])
|
||||
(:block/title block))))
|
||||
|
||||
(defn- route-assignee-datom!
|
||||
[cfg {:keys [repo agent-name] :as opts} datom]
|
||||
(let [block-id (datom-e datom)
|
||||
assignee-value (datom-value datom)]
|
||||
(when block-id
|
||||
(p/let [matches? (assignee-value-matches? cfg repo assignee-value agent-name)]
|
||||
(when matches?
|
||||
(p/let [block (pull-task-block cfg repo block-id)]
|
||||
(when (routable-task? block agent-name)
|
||||
(p/let [tree-text (show-task-tree cfg repo block)]
|
||||
(route-task! cfg opts {:block block
|
||||
:tree-text tree-text})))))))))
|
||||
|
||||
(defn- process-sync-db-changes-event!
|
||||
[cfg {:keys [repo] :as opts} {:keys [tx-data]}]
|
||||
(p/let [assignee-datoms (resolve-assignee-datoms cfg repo tx-data)]
|
||||
(when (seq assignee-datoms)
|
||||
(p/all (mapv #(route-assignee-datom! cfg opts %) assignee-datoms)))))
|
||||
|
||||
(defn- listen-forever!
|
||||
[cfg {:keys [repo graph agent-name]}]
|
||||
(let [processing* (atom (p/resolved nil))
|
||||
process! (fn [payload]
|
||||
(swap! processing*
|
||||
(fn [previous]
|
||||
(-> previous
|
||||
(p/catch (fn [_] nil))
|
||||
(p/then (fn [_]
|
||||
(process-sync-db-changes-event! cfg
|
||||
{:repo repo
|
||||
:graph graph
|
||||
:agent-name agent-name}
|
||||
payload)))
|
||||
(p/catch (fn [e]
|
||||
(emit-log! cfg (log-line (str "Codex invocation failed: "
|
||||
(or (ex-message e) (str e)))))
|
||||
(log-bridge-exit! {:repo repo
|
||||
:graph graph
|
||||
:agent-name agent-name
|
||||
:reason :task-processing-failed
|
||||
:exit-code 1
|
||||
:error e})
|
||||
(.exit js/process 1)))))))]
|
||||
(transport/connect-events! cfg
|
||||
(fn [event-type payload]
|
||||
(when (= :sync-db-changes event-type)
|
||||
(emit-log! cfg (log-line "got graph changes: sync-db-changes"))
|
||||
(process! payload))))
|
||||
(p/create (fn [_resolve _reject] nil))))
|
||||
|
||||
(defn execute-bridge
|
||||
[action config]
|
||||
(let [repo (:repo action)
|
||||
graph (:graph action)
|
||||
logs [(log-line "checking the environment ...")
|
||||
(log-line (str "using graph: " graph))]]
|
||||
(p/let [agent-name-result (resolve-agent-name config)]
|
||||
(if-not (:ok? agent-name-result)
|
||||
(bridge-error (get-in agent-name-result [:error :code])
|
||||
(get-in agent-name-result [:error :message]))
|
||||
(let [agent-name (:agent-name agent-name-result)
|
||||
logs (conj logs
|
||||
(log-line (str "using agent name: " agent-name))
|
||||
(log-line "checking codex cli ..."))]
|
||||
(if-not (codex-available? nil)
|
||||
(bridge-error :codex-not-found "codex executable is not available")
|
||||
(p/let [cfg (cli-server/ensure-server! config repo)
|
||||
logs (conj logs (log-line "registering agent bridge ..."))
|
||||
_ (register-agent-bridge! cfg repo agent-name)]
|
||||
(if (:dry-run? action)
|
||||
(p/let [tasks (list-routable-tasks cfg repo agent-name)
|
||||
commands (dry-run-commands graph agent-name tasks)
|
||||
logs (into (conj logs (log-line "listening graph changes ..."))
|
||||
(map (fn [{:keys [block preview]}]
|
||||
(log-line (str "would run Codex command for " block ": " preview)))
|
||||
commands))]
|
||||
{:status :ok
|
||||
:command :agent-bridge
|
||||
:data {:mode :dry-run
|
||||
:graph graph
|
||||
:agent-name agent-name
|
||||
:logs logs
|
||||
:commands commands}})
|
||||
(do
|
||||
(doseq [line (conj logs (log-line "listening graph changes ..."))]
|
||||
(emit-log! cfg line))
|
||||
(if (:process-once? action)
|
||||
(p/let [routed (process-tasks! cfg {:repo repo
|
||||
:graph graph
|
||||
:agent-name agent-name})]
|
||||
{:status :ok
|
||||
:command :agent-bridge
|
||||
:data {:mode :processed-once
|
||||
:graph graph
|
||||
:agent-name agent-name
|
||||
:routed routed}})
|
||||
(p/let [_ (process-tasks! cfg {:repo repo
|
||||
:graph graph
|
||||
:agent-name agent-name})]
|
||||
(listen-forever! cfg {:repo repo
|
||||
:graph graph
|
||||
:agent-name agent-name}))))))))))))
|
||||
@@ -122,9 +122,10 @@
|
||||
{:title "Authentication"
|
||||
:commands #{"login" "logout"}}
|
||||
{:title "Utilities"
|
||||
:commands #{"completion" "debug" "example" "qmd" "skill"}
|
||||
:commands #{"agent" "completion" "debug" "example" "qmd" "skill"}
|
||||
:top-level-only? true
|
||||
:desc-overrides {"debug" "Pull raw entity data for debugging"
|
||||
:desc-overrides {"agent" "Run task agent bridge"
|
||||
"debug" "Pull raw entity data for debugging"
|
||||
"example" "Show command examples"
|
||||
"qmd" "Initialize and manage QMD search"
|
||||
"skill" "Show/install built-in logseq-cli skill"}}]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"Command parsing and action building for the Logseq CLI."
|
||||
(:require [babashka.cli :as cli]
|
||||
[clojure.string :as string]
|
||||
[logseq.cli.command.agent :as agent-command]
|
||||
[logseq.cli.command.auth :as auth-command]
|
||||
[logseq.cli.command.completion :as completion-command]
|
||||
[logseq.cli.command.core :as command-core]
|
||||
@@ -93,6 +94,7 @@
|
||||
|
||||
(def ^:private base-table
|
||||
(vec (concat graph-command/entries
|
||||
agent-command/entries
|
||||
server-command/entries
|
||||
list-command/entries
|
||||
upsert-command/entries
|
||||
@@ -636,6 +638,9 @@
|
||||
(:graph-list :graph-create :graph-switch :graph-remove :graph-validate :graph-info)
|
||||
(graph-command/build-graph-action command graph repo options)
|
||||
|
||||
(:agent-bridge :agent-bridge-list)
|
||||
(agent-command/build-action command options repo graph)
|
||||
|
||||
:graph-backup-list
|
||||
(graph-command/build-backup-list-action repo)
|
||||
|
||||
@@ -750,6 +755,8 @@
|
||||
:else
|
||||
(case (:type action)
|
||||
:graph-list (graph-command/execute-graph-list action config)
|
||||
:agent-bridge (agent-command/execute-bridge action config)
|
||||
:agent-bridge-list (agent-command/execute-list action config)
|
||||
:graph-backup-list (graph-command/execute-graph-backup-list action config)
|
||||
:graph-backup-create (graph-command/execute-graph-backup-create action config)
|
||||
:graph-backup-restore (graph-command/execute-graph-backup-restore action config)
|
||||
|
||||
@@ -214,6 +214,9 @@
|
||||
:server-revision-mismatch "Logseq will restart revision-mismatched db-worker-node servers automatically; retry after stopping any lingering server manually"
|
||||
:server-revision-mismatch-restart-failed "Logseq tried to restart a revision-mismatched db-worker-node server and failed. Stop the server manually, then retry"
|
||||
:server-revision-mismatch-after-restart "Logseq restarted db-worker-node, but the replacement still reports a different revision. Check the installed Logseq build and retry"
|
||||
:agent-name-invalid "Set :agent-name in cli.edn to a non-empty string or ensure hostname is available"
|
||||
:codex-not-found "Install Codex CLI and ensure `codex` is on PATH"
|
||||
:bridge-listener-failed "Retry with --dry-run or check db-worker-node event support"
|
||||
nil))
|
||||
|
||||
(defn- format-candidates
|
||||
@@ -720,6 +723,32 @@
|
||||
(str table "\n\n" warning)
|
||||
table)))
|
||||
|
||||
(defn- format-agent-bridge-list
|
||||
[sessions now-ms]
|
||||
(let [session-field (fn [value]
|
||||
(cond
|
||||
(keyword? value) (name value)
|
||||
(nil? value) nil
|
||||
:else (str value)))]
|
||||
(format-counted-table
|
||||
["SESSION" "STATUS" "BACKEND" "GRAPH" "BLOCK" "AGENT" "STARTED" "UPDATED"]
|
||||
(mapv (fn [session]
|
||||
[(session-field (:session session))
|
||||
(session-field (:status session))
|
||||
(session-field (:backend session))
|
||||
(session-field (:graph session))
|
||||
(session-field (:block session))
|
||||
(session-field (:agent session))
|
||||
(human-ago (:started-at session) now-ms)
|
||||
(human-ago (:updated-at session) now-ms)])
|
||||
(or sessions [])))))
|
||||
|
||||
(defn- format-agent-bridge
|
||||
[data]
|
||||
(if (seq (:logs data))
|
||||
(string/join "\n" (:logs data))
|
||||
(pr-str data)))
|
||||
|
||||
(defn- format-query-results
|
||||
[result]
|
||||
(let [edn-str (pr-str result)
|
||||
@@ -1168,6 +1197,8 @@
|
||||
(format-graph-action command context data)
|
||||
:server-list (format-server-list (:servers data)
|
||||
(get-in human [:server-list :revision-mismatch]))
|
||||
:agent-bridge (format-agent-bridge data)
|
||||
:agent-bridge-list (format-agent-bridge-list (:sessions data) now-ms)
|
||||
:server-cleanup (format-server-cleanup data)
|
||||
(:server-start :server-stop :server-restart)
|
||||
(format-server-action command data)
|
||||
|
||||
@@ -340,6 +340,16 @@
|
||||
(p/let [stop-result (profile/time! (:profile-session config)
|
||||
"server.restart-version-mismatch"
|
||||
(fn []
|
||||
(log/info :cli-server-restart-version-mismatch
|
||||
{:repo repo
|
||||
:expected-revision expected
|
||||
:current-revision (:revision server)
|
||||
:owner-source (:owner-source server)
|
||||
:pid (:pid server)
|
||||
:host (:host server)
|
||||
:port (:port server)
|
||||
:root-dir (:root-dir server)
|
||||
:status (:status server)})
|
||||
(stop-version-mismatched-server! config repo server)))]
|
||||
(when-not (:ok? stop-result)
|
||||
(throw (ex-info "db-worker-node revision mismatch and restart failed"
|
||||
|
||||
@@ -1403,6 +1403,7 @@
|
||||
:property.built-in/asset-size "File Size"
|
||||
:property.built-in/asset-type "File Type"
|
||||
:property.built-in/asset-width "Image width"
|
||||
:property.built-in/assignee "Assignee"
|
||||
:property.built-in/background-color "Background color"
|
||||
:property.built-in/built-in "Built in?"
|
||||
:property.built-in/checkbox-display-properties "Properties displayed as checkbox"
|
||||
|
||||
@@ -1392,6 +1392,7 @@
|
||||
:property.built-in/asset-size "文件大小"
|
||||
:property.built-in/asset-type "文件类型"
|
||||
:property.built-in/asset-width "图片宽度"
|
||||
:property.built-in/assignee "负责人"
|
||||
:property.built-in/background-color "背景颜色"
|
||||
:property.built-in/built-in "是否内置?"
|
||||
:property.built-in/checkbox-display-properties "显示为复选框的属性"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
[logseq.cli.style :as style]
|
||||
[logseq.cli.test-helper :as test-helper]
|
||||
[logseq.common.config :as common-config]
|
||||
[logseq.common.version :as build-version]
|
||||
[logseq.db :as ldb]
|
||||
[promesa.core :as p]))
|
||||
|
||||
@@ -256,6 +257,29 @@
|
||||
(-> (stop!) (p/finally (fn [] (done))))
|
||||
(done))))))))
|
||||
|
||||
(deftest db-worker-node-logs-version-on-startup
|
||||
(async done
|
||||
(let [daemon (atom nil)
|
||||
data-dir (node-helper/create-tmp-dir "db-worker-log-version")
|
||||
repo (str "logseq_db_log_version_" (subs (str (random-uuid)) 0 8))
|
||||
log-file (log-path data-dir repo)]
|
||||
(-> (p/let [{:keys [stop!]}
|
||||
(start-daemon! {:root-dir data-dir
|
||||
:repo repo
|
||||
:log-level "info"})
|
||||
_ (reset! daemon {:stop! stop!})
|
||||
_ (p/delay 50)
|
||||
contents (.toString (fs/readFileSync log-file) "utf8")]
|
||||
(is (string/includes? contents ":db-worker-node-version"))
|
||||
(is (string/includes? contents (str ":build-time " (pr-str (build-version/build-time)))))
|
||||
(is (string/includes? contents (str ":revision " (pr-str (build-version/revision))))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn []
|
||||
(if-let [stop! (:stop! @daemon)]
|
||||
(-> (stop!) (p/finally (fn [] (done))))
|
||||
(done))))))))
|
||||
|
||||
(deftest db-worker-node-log-retention
|
||||
(let [enforce-log-retention! #'db-worker-node/enforce-log-retention!
|
||||
data-dir (node-helper/create-tmp-dir "db-worker-log-retention")
|
||||
|
||||
703
src/test/logseq/cli/command/agent_test.cljs
Normal file
703
src/test/logseq/cli/command/agent_test.cljs
Normal file
@@ -0,0 +1,703 @@
|
||||
(ns logseq.cli.command.agent-test
|
||||
(:require ["child_process" :as child-process]
|
||||
["events" :as events]
|
||||
["fs" :as fs]
|
||||
["os" :as os]
|
||||
["path" :as node-path]
|
||||
[cljs.reader :as reader]
|
||||
[cljs.test :refer [async deftest is testing]]
|
||||
[clojure.string :as string]
|
||||
[lambdaisland.glogi :as log]
|
||||
[logseq.cli.command.agent :as agent-command]
|
||||
[logseq.cli.command.show :as show-command]
|
||||
[logseq.cli.commands :as commands]
|
||||
[logseq.cli.format :as cli-format]
|
||||
[logseq.cli.server :as cli-server]
|
||||
[logseq.cli.transport :as transport]
|
||||
[logseq.db.frontend.property :as db-property]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(defn- temp-root
|
||||
[]
|
||||
(.mkdtempSync fs (node-path/join (.tmpdir os) "logseq-agent-bridge-test-")))
|
||||
|
||||
(defn- task-block
|
||||
[overrides]
|
||||
(merge {:db/id 42
|
||||
:block/uuid #uuid "11111111-1111-1111-1111-111111111111"
|
||||
:block/title "Ship the CLI bridge"
|
||||
:block/tags [{:block/title "Task"}]
|
||||
:logseq.property/status {:db/ident :logseq.property/status.todo}
|
||||
"Assignee" "build-host"}
|
||||
overrides))
|
||||
|
||||
(deftest test-assignee-built-in-property
|
||||
(let [property (get db-property/built-in-properties :logseq.property/assignee)]
|
||||
(is (= "Assignee" (:title property)))
|
||||
(is (= :node (get-in property [:schema :type])))
|
||||
(is (= :many (get-in property [:schema :cardinality])))
|
||||
(is (= true (get-in property [:schema :public?])))
|
||||
(is (= true (:queryable? property)))
|
||||
(is (contains? db-property/public-built-in-properties :logseq.property/assignee))))
|
||||
|
||||
(deftest test-agent-command-entries
|
||||
(testing "parse agent bridge command surface"
|
||||
(let [bridge (commands/parse-args ["agent" "bridge" "--graph" "demo" "--dry-run"])
|
||||
list-result (commands/parse-args ["agent" "bridge" "list"])]
|
||||
(is (true? (:ok? bridge)))
|
||||
(is (= :agent-bridge (:command bridge)))
|
||||
(is (= "demo" (get-in bridge [:options :graph])))
|
||||
(is (true? (get-in bridge [:options :dry-run])))
|
||||
(is (true? (:ok? list-result)))
|
||||
(is (= :agent-bridge-list (:command list-result)))))
|
||||
|
||||
(testing "top-level help exposes the agent utility group"
|
||||
(let [summary (:summary (commands/parse-args ["--help"]))]
|
||||
(is (string/includes? summary "agent"))
|
||||
(is (string/includes? summary "Run task agent bridge")))))
|
||||
|
||||
(deftest test-build-action
|
||||
(testing "agent bridge requires a resolved graph"
|
||||
(let [result (commands/build-action {:ok? true
|
||||
:command :agent-bridge
|
||||
:options {}
|
||||
:args []}
|
||||
{})]
|
||||
(is (false? (:ok? result)))
|
||||
(is (= :missing-repo (get-in result [:error :code])))))
|
||||
|
||||
(testing "agent bridge uses normal graph config precedence"
|
||||
(let [parsed {:ok? true
|
||||
:command :agent-bridge
|
||||
:options {:dry-run true}
|
||||
:args []}
|
||||
result (commands/build-action parsed {:graph "demo"})]
|
||||
(is (true? (:ok? result)))
|
||||
(is (= :agent-bridge (get-in result [:action :type])))
|
||||
(is (= "demo" (get-in result [:action :graph])))
|
||||
(is (= "logseq_db_demo" (get-in result [:action :repo])))
|
||||
(is (true? (get-in result [:action :dry-run?])))))
|
||||
|
||||
(testing "agent bridge list is root-dir scoped and does not require graph"
|
||||
(let [result (commands/build-action {:ok? true
|
||||
:command :agent-bridge-list
|
||||
:options {}
|
||||
:args []}
|
||||
{})]
|
||||
(is (true? (:ok? result)))
|
||||
(is (= :agent-bridge-list (get-in result [:action :type]))))))
|
||||
|
||||
(deftest test-resolve-agent-name
|
||||
(testing "config overrides hostname"
|
||||
(is (= {:ok? true :agent-name "bridge-a"}
|
||||
(agent-command/resolve-agent-name {:agent-name " bridge-a "}
|
||||
{:hostname "fallback-host"}))))
|
||||
|
||||
(testing "hostname is the default when config omits agent-name"
|
||||
(is (= {:ok? true :agent-name "fallback-host"}
|
||||
(agent-command/resolve-agent-name {}
|
||||
{:hostname "fallback-host"}))))
|
||||
|
||||
(testing "blank config value fails instead of falling back"
|
||||
(let [result (agent-command/resolve-agent-name {:agent-name " "}
|
||||
{:hostname "fallback-host"})]
|
||||
(is (false? (:ok? result)))
|
||||
(is (= :agent-name-invalid (get-in result [:error :code])))))
|
||||
|
||||
(testing "missing hostname fails when no config value exists"
|
||||
(let [result (agent-command/resolve-agent-name {}
|
||||
{:hostname ""})]
|
||||
(is (false? (:ok? result)))
|
||||
(is (= :agent-name-invalid (get-in result [:error :code]))))))
|
||||
|
||||
(deftest test-routable-task
|
||||
(testing "routes opted-in TODO Task assigned to current AgentBridge"
|
||||
(is (true? (agent-command/routable-task? (task-block {}) "build-host"))))
|
||||
|
||||
(testing "routes built-in Assignee node values with many cardinality"
|
||||
(is (true? (agent-command/routable-task?
|
||||
(task-block {"Assignee" nil
|
||||
:logseq.property/assignee [{:db/id 101
|
||||
:block/title "build-host"}]})
|
||||
"build-host"))))
|
||||
|
||||
(testing "rejects non-routable blocks with explicit reasons"
|
||||
(is (= :missing-stable-uuid
|
||||
(:reason (agent-command/routable-task-decision (task-block {:block/uuid nil}) "build-host"))))
|
||||
(is (= :missing-task-tag
|
||||
(:reason (agent-command/routable-task-decision (task-block {:block/tags []}) "build-host"))))
|
||||
(is (= :not-todo
|
||||
(:reason (agent-command/routable-task-decision (task-block {:logseq.property/status {:db/ident :logseq.property/status.done}})
|
||||
"build-host"))))
|
||||
(is (= :assignee-mismatch
|
||||
(:reason (agent-command/routable-task-decision (task-block {"Assignee" "other-host"})
|
||||
"build-host"))))
|
||||
(is (= :already-routed
|
||||
(:reason (agent-command/routable-task-decision (task-block {"agent-session-id" "codex-1"})
|
||||
"build-host"))))))
|
||||
|
||||
(deftest test-prompt-and-command
|
||||
(let [prompt (agent-command/build-codex-prompt
|
||||
{:graph "demo"
|
||||
:agent-name "build-host"
|
||||
:block (task-block {})
|
||||
:tree-text "- Ship the CLI bridge\n - Add tests"})
|
||||
command (agent-command/build-codex-command prompt {:codex-bin "codex"})
|
||||
preview (agent-command/command-preview command)]
|
||||
(testing "prompt carries graph, block, tree, identity, and write-back instructions"
|
||||
(is (string/includes? prompt "Graph: demo"))
|
||||
(is (string/includes? prompt "Block UUID: 11111111-1111-1111-1111-111111111111"))
|
||||
(is (string/includes? prompt "AgentBridge name: build-host"))
|
||||
(is (string/includes? prompt "Do not operate outside the target graph."))
|
||||
(is (string/includes? prompt "Write task results back into the graph."))
|
||||
(is (string/includes? prompt "- Ship the CLI bridge")))
|
||||
|
||||
(testing "codex command uses exec and shell-safe preview"
|
||||
(is (= ["codex" "exec" "--json" prompt] command))
|
||||
(is (string/starts-with? preview "codex exec --json '"))
|
||||
(is (string/includes? preview "Ship the CLI bridge")))))
|
||||
|
||||
(deftest test-codex-session-id-capture
|
||||
(testing "captures the first Codex JSONL session id"
|
||||
(is (= "session-123"
|
||||
(agent-command/parse-codex-session-id-line
|
||||
"{\"type\":\"session_configured\",\"session_id\":\"session-123\"}"))))
|
||||
|
||||
(testing "captures the current Codex JSONL thread id"
|
||||
(is (= "thread-123"
|
||||
(agent-command/parse-codex-session-id-line
|
||||
"{\"type\":\"thread.started\",\"thread_id\":\"thread-123\"}"))))
|
||||
|
||||
(testing "ignores non-session JSONL events"
|
||||
(is (nil? (agent-command/parse-codex-session-id-line
|
||||
"{\"type\":\"agent_message\",\"message\":\"hello\"}")))))
|
||||
|
||||
(deftest test-start-codex-waits-for-stdout-after-exit
|
||||
(async done
|
||||
(let [original-spawn (.-spawn child-process)]
|
||||
(set! (.-spawn child-process)
|
||||
(fn [_bin _args _opts]
|
||||
(let [child (events/EventEmitter.)
|
||||
stdout (events/EventEmitter.)
|
||||
stderr (events/EventEmitter.)]
|
||||
(set! (.-stdout child) stdout)
|
||||
(set! (.-stderr child) stderr)
|
||||
(js/setTimeout (fn []
|
||||
(.emit child "exit" 0 nil)
|
||||
(.emit stdout "data" "{\"type\":\"thread.started\",\"thread_id\":\"thread-late\"}\n")
|
||||
(.emit stdout "close")
|
||||
(.emit child "close" 0 nil))
|
||||
0)
|
||||
child)))
|
||||
(-> (agent-command/start-codex! ["codex" "exec" "--json" "prompt"] {})
|
||||
(p/then (fn [result]
|
||||
(is (= {:session "thread-late"
|
||||
:status :running}
|
||||
(select-keys result [:session :status])))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn []
|
||||
(set! (.-spawn child-process) original-spawn)
|
||||
(done)))))))
|
||||
|
||||
(deftest test-start-codex-parses-complete-jsonl-before-later-output
|
||||
(async done
|
||||
(let [original-spawn (.-spawn child-process)]
|
||||
(set! (.-spawn child-process)
|
||||
(fn [_bin _args _opts]
|
||||
(let [child (events/EventEmitter.)
|
||||
stdout (events/EventEmitter.)
|
||||
stderr (events/EventEmitter.)]
|
||||
(set! (.-stdout child) stdout)
|
||||
(set! (.-stderr child) stderr)
|
||||
(js/setTimeout (fn []
|
||||
(.emit stdout "data" "{\"type\":\"thread.started\",\"thread_id\":\"thread-first\"}\n")
|
||||
(.emit stdout "data" "{\"type\":\"turn.started\"}\n")
|
||||
(.emit stdout "close")
|
||||
(.emit child "close" 0 nil))
|
||||
0)
|
||||
child)))
|
||||
(-> (agent-command/start-codex! ["codex" "exec" "--json" "prompt"] {})
|
||||
(p/then (fn [result]
|
||||
(is (= "thread-first" (:session result)))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn []
|
||||
(set! (.-spawn child-process) original-spawn)
|
||||
(done)))))))
|
||||
|
||||
(deftest test-start-codex-waits-for-stdout-close-after-child-close
|
||||
(async done
|
||||
(let [original-spawn (.-spawn child-process)]
|
||||
(set! (.-spawn child-process)
|
||||
(fn [_bin _args _opts]
|
||||
(let [child (events/EventEmitter.)
|
||||
stdout (events/EventEmitter.)
|
||||
stderr (events/EventEmitter.)]
|
||||
(set! (.-stdout child) stdout)
|
||||
(set! (.-stderr child) stderr)
|
||||
(js/setTimeout (fn []
|
||||
(.emit child "close" 0 nil)
|
||||
(.emit stdout "data" "{\"type\":\"thread.started\",\"thread_id\":\"thread-after-close\"}\n")
|
||||
(.emit stdout "close"))
|
||||
0)
|
||||
child)))
|
||||
(-> (agent-command/start-codex! ["codex" "exec" "--json" "prompt"] {})
|
||||
(p/then (fn [result]
|
||||
(is (= "thread-after-close" (:session result)))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn []
|
||||
(set! (.-spawn child-process) original-spawn)
|
||||
(done)))))))
|
||||
|
||||
(deftest test-registration-writes-agent-name-to-graph
|
||||
(async done
|
||||
(let [calls* (atom [])
|
||||
page-uuid (uuid "33333333-3333-3333-3333-333333333333")]
|
||||
(p/finally
|
||||
(p/catch
|
||||
(p/with-redefs [agent-command/random-bridge-block-uuid (fn [] (uuid "44444444-4444-4444-4444-444444444444"))
|
||||
transport/invoke (fn [_ method args]
|
||||
(swap! calls* conj [method args])
|
||||
(case method
|
||||
:thread-api/q
|
||||
(let [[_ [query & query-args]] args]
|
||||
(cond
|
||||
(= query agent-command/agent-bridge-registry-page-query)
|
||||
(p/resolved [])
|
||||
|
||||
(= query agent-command/registered-agent-query)
|
||||
(p/resolved [])
|
||||
|
||||
:else
|
||||
(p/rejected (ex-info "unexpected query"
|
||||
{:query query
|
||||
:query-args query-args}))))
|
||||
|
||||
:thread-api/apply-outliner-ops
|
||||
(let [[_ ops _] args]
|
||||
(if (= [[:create-page [agent-command/agent-bridge-registry-page {}]]] ops)
|
||||
(p/resolved [agent-command/agent-bridge-registry-page page-uuid])
|
||||
(p/resolved {:ok true})))
|
||||
|
||||
:thread-api/pull
|
||||
(p/resolved {:db/id 300
|
||||
:block/uuid page-uuid
|
||||
:block/title agent-command/agent-bridge-registry-page})
|
||||
|
||||
(p/rejected (ex-info "unexpected invoke"
|
||||
{:method method
|
||||
:args args}))))]
|
||||
(p/let [_ (agent-command/register-agent-bridge! {:root-dir "/tmp/logseq"} "logseq_db_demo" "build-host")]
|
||||
(let [apply-ops (->> @calls*
|
||||
(filter #(= :thread-api/apply-outliner-ops (first %)))
|
||||
(mapv (comp second second)))]
|
||||
(is (= [[:create-page [agent-command/agent-bridge-registry-page {}]]]
|
||||
(first apply-ops)))
|
||||
(is (= [[:insert-blocks [[{:block/title "build-host"
|
||||
:block/uuid (uuid "44444444-4444-4444-4444-444444444444")}]
|
||||
page-uuid
|
||||
{:outliner-op :insert-blocks
|
||||
:sibling? false
|
||||
:bottom? true
|
||||
:keep-uuid? true}]]]
|
||||
(second apply-ops))))))
|
||||
(fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
done))))
|
||||
|
||||
(deftest test-write-agent-session-id
|
||||
(async done
|
||||
(let [ops* (atom [])
|
||||
property-query-count* (atom 0)
|
||||
property-ident :user.property/agent-session-id
|
||||
block-uuid (uuid "11111111-1111-1111-1111-111111111111")]
|
||||
(-> (p/with-redefs [transport/invoke (fn [_ method args]
|
||||
(case method
|
||||
:thread-api/q
|
||||
(let [[_ [query & _query-args]] args]
|
||||
(if (= query agent-command/agent-session-id-property-query)
|
||||
(p/resolved (if (= 1 (swap! property-query-count* inc))
|
||||
[]
|
||||
[{:db/id 700
|
||||
:db/ident property-ident
|
||||
:block/title "agent-session-id"}]))
|
||||
(p/rejected (ex-info "unexpected query"
|
||||
{:query query}))))
|
||||
|
||||
:thread-api/apply-outliner-ops
|
||||
(let [[_ ops _] args]
|
||||
(swap! ops* into ops)
|
||||
(p/resolved {:ok true}))
|
||||
|
||||
(p/rejected (ex-info "unexpected invoke"
|
||||
{:method method
|
||||
:args args}))))]
|
||||
(p/let [_ (agent-command/write-agent-session-id! {:root-dir "/tmp/logseq"}
|
||||
"logseq_db_demo"
|
||||
block-uuid
|
||||
"session-123")]
|
||||
(is (= [[:upsert-property [nil
|
||||
{:logseq.property/type :default
|
||||
:db/cardinality :db.cardinality/one}
|
||||
{:property-name "agent-session-id"}]]
|
||||
[:batch-set-property [[block-uuid] property-ident "session-123" {}]]]
|
||||
@ops*))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest test-session-store
|
||||
(let [root (temp-root)
|
||||
config {:root-dir root}]
|
||||
(try
|
||||
(agent-command/record-session! config {:session "codex-running"
|
||||
:status :running
|
||||
:backend :codex
|
||||
:graph "demo"
|
||||
:block "11111111-1111-1111-1111-111111111111"
|
||||
:agent "build-host"
|
||||
:started-at 1000
|
||||
:updated-at 2000})
|
||||
(agent-command/record-session! config {:session "codex-done"
|
||||
:status :completed
|
||||
:backend :codex
|
||||
:graph "demo"
|
||||
:block "22222222-2222-2222-2222-222222222222"
|
||||
:agent "build-host"
|
||||
:started-at 1000
|
||||
:updated-at 3000})
|
||||
(testing "list hides completed sessions by default"
|
||||
(is (= ["codex-running"]
|
||||
(mapv :session (agent-command/list-sessions config {})))))
|
||||
(testing "list can include completed sessions"
|
||||
(is (= ["codex-running" "codex-done"]
|
||||
(mapv :session (agent-command/list-sessions config {:all? true})))))
|
||||
(testing "session file is EDN data"
|
||||
(let [payload (reader/read-string (fs/readFileSync (agent-command/session-store-path config) "utf8"))]
|
||||
(is (= 2 (count (:sessions payload))))))
|
||||
(finally
|
||||
(fs/rmSync root #js {:recursive true :force true})))))
|
||||
|
||||
(deftest test-session-store-keeps-terminal-status-after-late-running-record
|
||||
(let [root (temp-root)
|
||||
config {:root-dir root}]
|
||||
(try
|
||||
(agent-command/update-session-status! config "codex-fast" :completed)
|
||||
(agent-command/record-session! config {:session "codex-fast"
|
||||
:status :running
|
||||
:backend :codex
|
||||
:graph "demo"
|
||||
:block "11111111-1111-1111-1111-111111111111"
|
||||
:agent "build-host"
|
||||
:started-at 1000
|
||||
:updated-at 2000})
|
||||
(let [session (first (agent-command/list-sessions config {:all? true}))]
|
||||
(is (= :completed (:status session)))
|
||||
(is (= :codex (:backend session)))
|
||||
(is (= "demo" (:graph session)))
|
||||
(is (= "11111111-1111-1111-1111-111111111111" (:block session)))
|
||||
(is (= "build-host" (:agent session)))
|
||||
(is (= 1000 (:started-at session)))
|
||||
(is (= 2000 (:updated-at session))))
|
||||
(finally
|
||||
(fs/rmSync root #js {:recursive true :force true})))))
|
||||
|
||||
(deftest test-execute-agent-bridge-list-and-format
|
||||
(let [root (temp-root)]
|
||||
(try
|
||||
(agent-command/record-session! {:root-dir root}
|
||||
{:session "codex-running"
|
||||
:status :running
|
||||
:backend :codex
|
||||
:graph "demo"
|
||||
:block "11111111-1111-1111-1111-111111111111"
|
||||
:agent "build-host"
|
||||
:started-at 1000
|
||||
:updated-at 2000})
|
||||
(let [result (agent-command/execute-list {:type :agent-bridge-list} {:root-dir root})
|
||||
output (cli-format/format-result result {:output-format :human :now-ms 3000})]
|
||||
(is (= :ok (:status result)))
|
||||
(is (string/includes? output "SESSION"))
|
||||
(is (string/includes? output "STATUS"))
|
||||
(is (string/includes? output "BACKEND"))
|
||||
(is (string/includes? output "codex-running"))
|
||||
(is (string/includes? output "running"))
|
||||
(is (not (string/includes? output ":running")))
|
||||
(is (not (string/includes? output ":codex")))
|
||||
(is (string/includes? output "Count: 1")))
|
||||
(finally
|
||||
(fs/rmSync root #js {:recursive true :force true})))))
|
||||
|
||||
(deftest test-format-agent-bridge-logs
|
||||
(let [output (cli-format/format-result {:status :ok
|
||||
:command :agent-bridge
|
||||
:data {:logs ["2026-05-16T00:00:00.000Z checking the environment ..."
|
||||
"2026-05-16T00:00:01.000Z listening graph changes ..."]}}
|
||||
{:output-format :human})]
|
||||
(is (= "2026-05-16T00:00:00.000Z checking the environment ...\n2026-05-16T00:00:01.000Z listening graph changes ..."
|
||||
output))))
|
||||
|
||||
(deftest test-execute-agent-bridge-dry-run
|
||||
(async done
|
||||
(let [calls (atom [])]
|
||||
(-> (p/with-redefs [agent-command/codex-available? (fn [_] true)
|
||||
cli-server/ensure-server! (fn [cfg repo]
|
||||
(swap! calls conj [:ensure-server (:root-dir cfg) repo])
|
||||
(assoc cfg :base-url "http://127.0.0.1:1234"))
|
||||
agent-command/register-agent-bridge! (fn [cfg repo agent-name]
|
||||
(swap! calls conj [:register (:root-dir cfg) repo agent-name])
|
||||
(p/resolved true))
|
||||
agent-command/list-routable-tasks (fn [_cfg repo agent-name]
|
||||
(swap! calls conj [:list repo agent-name])
|
||||
(p/resolved [{:block (task-block {})
|
||||
:tree-text "- Ship the CLI bridge"}]))]
|
||||
(p/let [result (agent-command/execute-bridge {:type :agent-bridge
|
||||
:repo "logseq_db_demo"
|
||||
:graph "demo"
|
||||
:dry-run? true}
|
||||
{:root-dir "/tmp/logseq"
|
||||
:agent-name "build-host"})]
|
||||
(is (= :ok (:status result)))
|
||||
(is (= :dry-run (get-in result [:data :mode])))
|
||||
(is (= [[:ensure-server "/tmp/logseq" "logseq_db_demo"]
|
||||
[:register "/tmp/logseq" "logseq_db_demo" "build-host"]
|
||||
[:list "logseq_db_demo" "build-host"]]
|
||||
@calls))
|
||||
(is (= 1 (count (get-in result [:data :commands]))))
|
||||
(is (string/includes? (first (get-in result [:data :logs]))
|
||||
"checking the environment"))
|
||||
(is (string/includes? (last (get-in result [:data :logs]))
|
||||
"would run Codex command"))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest test-execute-agent-bridge-non-dry-run-routes-task
|
||||
(async done
|
||||
(let [root (temp-root)
|
||||
calls (atom [])
|
||||
block (task-block {})]
|
||||
(try
|
||||
(p/finally
|
||||
(p/catch
|
||||
(p/with-redefs [agent-command/codex-available? (fn [_] true)
|
||||
cli-server/ensure-server! (fn [cfg repo]
|
||||
(swap! calls conj [:ensure-server (:root-dir cfg) repo])
|
||||
(assoc cfg :base-url "http://127.0.0.1:1234"))
|
||||
agent-command/register-agent-bridge! (fn [_cfg repo agent-name]
|
||||
(swap! calls conj [:register repo agent-name])
|
||||
(p/resolved true))
|
||||
agent-command/list-routable-tasks (fn [_cfg repo agent-name]
|
||||
(swap! calls conj [:list repo agent-name])
|
||||
(p/resolved [{:block block
|
||||
:tree-text "- Ship the CLI bridge"}]))
|
||||
agent-command/start-codex! (fn [command _opts]
|
||||
(swap! calls conj [:codex command])
|
||||
(p/resolved {:session "session-123"
|
||||
:status :running}))
|
||||
agent-command/write-agent-session-id! (fn [_cfg repo block-uuid session-id]
|
||||
(swap! calls conj [:write-session repo block-uuid session-id])
|
||||
(p/resolved true))]
|
||||
(p/let [result (agent-command/execute-bridge {:type :agent-bridge
|
||||
:repo "logseq_db_demo"
|
||||
:graph "demo"
|
||||
:dry-run? false
|
||||
:process-once? true}
|
||||
{:root-dir root
|
||||
:agent-name "build-host"
|
||||
:log-fn (fn [_] nil)})]
|
||||
(is (= :ok (:status result)))
|
||||
(is (= :processed-once (get-in result [:data :mode])))
|
||||
(is (= [[:ensure-server root "logseq_db_demo"]
|
||||
[:register "logseq_db_demo" "build-host"]
|
||||
[:list "logseq_db_demo" "build-host"]
|
||||
[:codex ["codex" "exec" "--json" (agent-command/build-codex-prompt
|
||||
{:graph "demo"
|
||||
:agent-name "build-host"
|
||||
:block block
|
||||
:tree-text "- Ship the CLI bridge"})]]
|
||||
[:ensure-server root "logseq_db_demo"]
|
||||
[:write-session "logseq_db_demo" (:block/uuid block) "session-123"]]
|
||||
@calls))
|
||||
(let [sessions (agent-command/list-sessions {:root-dir root} {})]
|
||||
(is (= [{:session "session-123"
|
||||
:status :running
|
||||
:backend :codex
|
||||
:graph "demo"
|
||||
:block "11111111-1111-1111-1111-111111111111"
|
||||
:agent "build-host"}]
|
||||
(mapv #(select-keys % [:session :status :backend :graph :block :agent]) sessions))))))
|
||||
(fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(fn []
|
||||
(fs/rmSync root #js {:recursive true :force true})
|
||||
(done)))
|
||||
(catch :default e
|
||||
(fs/rmSync root #js {:recursive true :force true})
|
||||
(is false (str "unexpected setup error: " e))
|
||||
(done))))))
|
||||
|
||||
(deftest test-agent-bridge-listener-ignores-unrelated-events
|
||||
(async done
|
||||
(let [handler* (atom nil)
|
||||
broad-scans* (atom 0)]
|
||||
(-> (p/with-redefs [transport/connect-events! (fn [_cfg handler]
|
||||
(reset! handler* handler)
|
||||
{:close! (fn [] nil)})
|
||||
agent-command/list-routable-tasks (fn [_cfg _repo _agent-name]
|
||||
(swap! broad-scans* inc)
|
||||
(p/resolved []))]
|
||||
(do
|
||||
(#'agent-command/listen-forever! {:root-dir "/tmp/logseq"
|
||||
:base-url "http://127.0.0.1:1234"
|
||||
:log-fn (fn [_] nil)}
|
||||
{:repo "logseq_db_demo"
|
||||
:graph "demo"
|
||||
:agent-name "build-host"})
|
||||
(@handler* :rtc-log {:tx-data [{:e 42
|
||||
:a :logseq.property/assignee
|
||||
:v {:db/id 101
|
||||
:block/title "build-host"}}]})
|
||||
(@handler* :sync-db-changes {:tx-data [{:e 42
|
||||
:a :block/title
|
||||
:v "renamed"}]})
|
||||
(@handler* :sync-db-changes {:tx-data [{:e 42
|
||||
:a :logseq.property/assignee
|
||||
:v {:db/id 102
|
||||
:block/title "other-host"}}]})
|
||||
(p/let [_ (p/delay 5)]
|
||||
(is (= 0 @broad-scans*)))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
|
||||
(deftest test-agent-bridge-listener-logs-exit-reason-before-process-exit
|
||||
(async done
|
||||
(let [handler* (atom nil)
|
||||
exit-calls* (atom [])
|
||||
info-logs* (atom [])
|
||||
original-exit (.-exit js/process)
|
||||
log-handler (fn [record]
|
||||
(when-let [data (get (:message record) :agent-bridge-exit)]
|
||||
(swap! info-logs* conj data)))]
|
||||
(set! (.-exit js/process)
|
||||
(fn [code]
|
||||
(swap! exit-calls* conj code)))
|
||||
(log/add-handler log-handler)
|
||||
(-> (p/with-redefs [transport/connect-events! (fn [_cfg handler]
|
||||
(reset! handler* handler)
|
||||
{:close! (fn [] nil)})
|
||||
transport/invoke (fn [_cfg _method _args]
|
||||
(p/rejected (ex-info "db-worker unavailable"
|
||||
{:code :db-worker-unavailable})))]
|
||||
(do
|
||||
(#'agent-command/listen-forever! {:root-dir "/tmp/logseq"
|
||||
:base-url "http://127.0.0.1:1234"
|
||||
:log-fn (fn [_] nil)}
|
||||
{:repo "logseq_db_demo"
|
||||
:graph "demo"
|
||||
:agent-name "build-host"})
|
||||
(@handler* :sync-db-changes {:tx-data [{:e 42
|
||||
:a :logseq.property/assignee
|
||||
:v {:db/id 101
|
||||
:block/title "build-host"}}]})
|
||||
(p/let [_ (p/delay 5)]
|
||||
(is (= [1] @exit-calls*))
|
||||
(is (= [{:reason :task-processing-failed
|
||||
:exit-code 1
|
||||
:repo "logseq_db_demo"
|
||||
:graph "demo"
|
||||
:agent-name "build-host"
|
||||
:error-code :db-worker-unavailable
|
||||
:message "db-worker unavailable"}]
|
||||
@info-logs*)))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn []
|
||||
(log/remove-handler log-handler)
|
||||
(set! (.-exit js/process) original-exit)
|
||||
(done)))))))
|
||||
|
||||
(deftest test-agent-bridge-listener-routes-assignee-datom-without-broad-scan
|
||||
(async done
|
||||
(let [root (temp-root)
|
||||
handler* (atom nil)
|
||||
calls (atom [])
|
||||
block (task-block {"Assignee" nil
|
||||
:logseq.property/assignee [{:db/id 101
|
||||
:block/title "build-host"}]})]
|
||||
(try
|
||||
(-> (p/with-redefs [transport/connect-events! (fn [_cfg handler]
|
||||
(reset! handler* handler)
|
||||
{:close! (fn [] nil)})
|
||||
agent-command/list-routable-tasks (fn [_cfg repo agent-name]
|
||||
(swap! calls conj [:broad-scan repo agent-name])
|
||||
(p/resolved []))
|
||||
transport/invoke (fn [_cfg method args]
|
||||
(swap! calls conj [method args])
|
||||
(case method
|
||||
:thread-api/pull
|
||||
(let [[_repo selector lookup] args]
|
||||
(cond
|
||||
(= lookup :logseq.property/assignee)
|
||||
(p/resolved {:db/id 900
|
||||
:db/ident :logseq.property/assignee})
|
||||
|
||||
(= lookup 101)
|
||||
(p/resolved {:db/id 101
|
||||
:block/title "build-host"})
|
||||
|
||||
(= lookup 42)
|
||||
(p/resolved block)
|
||||
|
||||
:else
|
||||
(p/rejected (ex-info "unexpected pull"
|
||||
{:selector selector
|
||||
:lookup lookup}))))
|
||||
|
||||
(p/rejected (ex-info "unexpected invoke"
|
||||
{:method method
|
||||
:args args}))))
|
||||
show-command/execute-show (fn [action _cfg]
|
||||
(swap! calls conj [:show action])
|
||||
(p/resolved {:status :ok
|
||||
:data {:message "- Ship the CLI bridge"}}))
|
||||
cli-server/ensure-server! (fn [cfg repo]
|
||||
(swap! calls conj [:ensure-server (:root-dir cfg) repo])
|
||||
(assoc cfg :base-url "http://127.0.0.1:1234"))
|
||||
agent-command/start-codex! (fn [command _opts]
|
||||
(swap! calls conj [:codex command])
|
||||
(p/resolved {:session "session-123"
|
||||
:status :running}))
|
||||
agent-command/write-agent-session-id! (fn [_cfg repo block-uuid session-id]
|
||||
(swap! calls conj [:write-session repo block-uuid session-id])
|
||||
(p/resolved true))]
|
||||
(do
|
||||
(#'agent-command/listen-forever! {:root-dir root
|
||||
:base-url "http://127.0.0.1:1234"
|
||||
:log-fn (fn [_] nil)}
|
||||
{:repo "logseq_db_demo"
|
||||
:graph "demo"
|
||||
:agent-name "build-host"})
|
||||
(@handler* :sync-db-changes {:tx-data [{:e 42
|
||||
:a 900
|
||||
:v 101}]})
|
||||
(p/let [_ (p/delay 5)]
|
||||
(is (not-any? #(= :broad-scan (first %)) @calls))
|
||||
(is (some #(= [:thread-api/pull ["logseq_db_demo" [:db/id :db/ident] :logseq.property/assignee]] %)
|
||||
@calls))
|
||||
(is (some #(= [:thread-api/pull ["logseq_db_demo" [:db/id :block/title :block/name] 101]] %)
|
||||
@calls))
|
||||
(is (some #(= [:write-session "logseq_db_demo" (:block/uuid block) "session-123"] %)
|
||||
@calls)))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally (fn []
|
||||
(fs/rmSync root #js {:recursive true :force true})
|
||||
(done))))
|
||||
(catch :default e
|
||||
(fs/rmSync root #js {:recursive true :force true})
|
||||
(is false (str "unexpected setup error: " e))
|
||||
(done))))))
|
||||
@@ -7,6 +7,7 @@
|
||||
[cljs.test :refer [async deftest is]]
|
||||
[clojure.string :as string]
|
||||
[frontend.test.node-helper :as node-helper]
|
||||
[lambdaisland.glogi :as log]
|
||||
[logseq.cli.config :as cli-config]
|
||||
[logseq.cli.profile :as profile]
|
||||
[logseq.cli.server :as cli-server]
|
||||
@@ -171,6 +172,11 @@
|
||||
discover-calls (atom 0)
|
||||
spawn-calls (atom 0)
|
||||
shutdown-calls (atom 0)
|
||||
info-logs (atom [])
|
||||
log-handler (fn [record]
|
||||
(when-let [data (get (:message record)
|
||||
:cli-server-restart-version-mismatch)]
|
||||
(swap! info-logs conj data)))
|
||||
old-server (revision-test-server {:repo repo
|
||||
:port 9411
|
||||
:owner-source :cli
|
||||
@@ -199,6 +205,7 @@
|
||||
[new-server]))))
|
||||
daemon/wait-for-lock (fn [_] (p/resolved true))
|
||||
daemon/wait-for-ready (fn [_] (p/resolved true))]
|
||||
(log/add-handler log-handler)
|
||||
(cli-server/ensure-server! {:root-dir root-dir
|
||||
:owner-source :cli
|
||||
:expected-revision "expected-revision"}
|
||||
@@ -206,10 +213,22 @@
|
||||
(p/then (fn [config]
|
||||
(is (= "http://127.0.0.1:9412" (:base-url config)))
|
||||
(is (= 1 @shutdown-calls))
|
||||
(is (= 1 @spawn-calls))))
|
||||
(is (= 1 @spawn-calls))
|
||||
(is (= [{:repo repo
|
||||
:expected-revision "expected-revision"
|
||||
:current-revision "old-revision"
|
||||
:owner-source :cli
|
||||
:pid (.-pid js/process)
|
||||
:host "127.0.0.1"
|
||||
:port 9411
|
||||
:root-dir root-dir
|
||||
:status :ready}]
|
||||
@info-logs))))
|
||||
(p/catch (fn [e]
|
||||
(is false (str "unexpected error: " e))))
|
||||
(p/finally done)))))
|
||||
(p/finally (fn []
|
||||
(log/remove-handler log-handler)
|
||||
(done)))))))
|
||||
|
||||
(deftest ensure-server-restarts-cross-owner-mismatched-revision
|
||||
(async done
|
||||
|
||||
Reference in New Issue
Block a user