feat(cli): agent bridge

This commit is contained in:
rcmerci
2026-05-19 20:04:39 +08:00
parent a50407846f
commit 507a39fff4
20 changed files with 2297 additions and 7 deletions

View File

@@ -29,7 +29,7 @@ Use `logseq` to inspect and edit graph entities, run Datascript queries, and con
- `doctor`
- `sync status|start|stop|upload|download|remote-graphs|ensure-keys|grant-access|config set|get|unset`
- Authentication: `login|logout`
- Utilities: `completion`, `debug`, `example`, `skill`
- Utilities: `agent bridge|agent bridge list`, `completion`, `debug`, `example`, `skill`
## Global options
@@ -116,6 +116,9 @@ Use `logseq` to inspect and edit graph entities, run Datascript queries, and con
## Tips
- `query list` returns both built-ins and `custom-queries` from `cli.edn`.
- `agent bridge --dry-run` checks the local bridge setup, resolves `:agent-name` or hostname, registers the AgentBridge name in the graph, finds routable `#Task` TODO blocks assigned to that AgentBridge name, and prints the Codex commands it would run without starting Codex or writing `agent-session-id`.
- `agent bridge` starts/reuses db-worker-node, listens to db-worker-node events, scans routable tasks on startup and each event, starts `codex exec --json` for matched tasks, stores the Codex session/thread id in `agent-bridge-sessions.edn`, and writes it to the task's `agent-session-id` property.
- `agent bridge list` reads root-dir scoped bridge session records and hides completed sessions by default; use `--all` to include them.
- `show --id` accepts either one db/id or an EDN vector of ids.
- `remove block --id` also accepts one db/id or an EDN vector.
- `upsert block` enters update mode when `--id` or `--uuid` is provided.

View File

@@ -0,0 +1,298 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: agent_bridge_demo.sh [options]
Creates a fresh Logseq graph, starts `logseq agent bridge`, writes a routable
task after the bridge listener is ready, and verifies that a fake Codex worker
executed the task and that AgentBridge wrote agent-session-id.
Options:
--cli PATH Path to static/logseq-cli.js. Default: <repo-root>/static/logseq-cli.js
--root-dir DIR Logseq CLI root dir. Default: a new temp dir
--graph NAME Graph name. Default: agent-bridge-demo-<timestamp>
--repo-root DIR Repository root. Default: inferred from this script
--timeout-sec N Wait timeout for bridge events. Default: 45
-h, --help Show this help
USAGE
}
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
cli_path=""
root_dir=""
graph="agent-bridge-demo-$(date +%s)"
timeout_sec=45
agent_name="AgentBridgeDemo"
task_title="AgentBridge demo task: mark this block done"
expected_session="thread-agent-bridge-demo"
bridge_pid=""
graph_created=0
while [[ $# -gt 0 ]]; do
case "$1" in
--cli)
cli_path="$2"
shift 2
;;
--root-dir)
root_dir="$2"
shift 2
;;
--graph)
graph="$2"
shift 2
;;
--repo-root)
repo_root="$2"
shift 2
;;
--timeout-sec)
timeout_sec="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
cli_path="${cli_path:-$repo_root/static/logseq-cli.js}"
root_dir="${root_dir:-$(mktemp -d "${TMPDIR:-/tmp}/logseq-agent-bridge-demo.XXXXXX")}"
config_path="$root_dir/cli.edn"
work_dir="$root_dir/agent-bridge-demo"
fake_bin="$work_dir/fake-bin"
bridge_log="$work_dir/agent-bridge.log"
bridge_err="$work_dir/agent-bridge.err"
codex_log="$work_dir/codex-invocations.jsonl"
cleanup() {
local status=$?
if [[ -n "${bridge_pid:-}" ]] && kill -0 "$bridge_pid" 2>/dev/null; then
kill "$bridge_pid" 2>/dev/null || true
wait "$bridge_pid" 2>/dev/null || true
fi
if [[ "$graph_created" -eq 1 ]]; then
node "$cli_path" --root-dir "$root_dir" --config "$config_path" --output json server stop --graph "$graph" >/dev/null 2>&1 || true
fi
exit "$status"
}
trap cleanup EXIT INT TERM
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Required command is missing: $1" >&2
exit 2
fi
}
json_result() {
python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["result"])'
}
json_result_first() {
python3 -c 'import json,sys; r=json.load(sys.stdin)["data"]["result"]; print(r[0] if isinstance(r, list) else r)'
}
run_cli_json() {
node "$cli_path" --root-dir "$root_dir" --config "$config_path" --output json "$@"
}
wait_for_file_text() {
local path="$1"
local text="$2"
local deadline=$((SECONDS + timeout_sec))
while (( SECONDS < deadline )); do
if [[ -f "$path" ]] && grep -Fq "$text" "$path"; then
return 0
fi
if [[ -n "${bridge_pid:-}" ]] && ! kill -0 "$bridge_pid" 2>/dev/null; then
echo "agent bridge exited before '$text'" >&2
[[ -f "$bridge_log" ]] && cat "$bridge_log" >&2
[[ -f "$bridge_err" ]] && cat "$bridge_err" >&2
exit 1
fi
sleep 0.2
done
echo "Timed out waiting for '$text' in $path" >&2
[[ -f "$bridge_log" ]] && cat "$bridge_log" >&2
[[ -f "$bridge_err" ]] && cat "$bridge_err" >&2
exit 1
}
query_task_status() {
run_cli_json query --graph "$graph" --query "[:find ?status-ident . :where [$1 :logseq.property/status ?status] [?status :db/ident ?status-ident]]" | json_result
}
query_agent_session() {
local payload
payload="$(run_cli_json query --graph "$graph" --query "[:find ?session . :where [$1 ?attr ?session] [?p :block/name \"agent-session-id\"] [?p :db/ident ?attr]]")"
python3 - "$root_dir" "$config_path" "$graph" "$cli_path" "$payload" <<'PY'
import json
import subprocess
import sys
payload = json.loads(sys.argv[5])
value = payload.get("data", {}).get("result")
if isinstance(value, int):
root_dir, config_path, graph, cli_path = sys.argv[1:5]
query = f"[:find ?title . :where [{value} :block/title ?title]]"
resolved = subprocess.check_output(
[
"node",
cli_path,
"--root-dir",
root_dir,
"--config",
config_path,
"--output",
"json",
"query",
"--graph",
graph,
"--query",
query,
],
text=True,
)
value = json.loads(resolved)["data"]["result"]
print("" if value is None else value)
PY
}
write_fake_codex() {
mkdir -p "$fake_bin"
cat > "$fake_bin/codex" <<'FAKE_CODEX'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -eq 1 && "$1" == "--version" ]]; then
echo "codex-cli 0.0.0-agent-bridge-demo"
exit 0
fi
if [[ "$#" -ge 3 && "$1" == "exec" && "$2" == "--json" ]]; then
prompt="$3"
python3 - "$CODEX_FAKE_LOG" "$prompt" "$@" <<'PY'
import json
import pathlib
import sys
log_path = pathlib.Path(sys.argv[1])
prompt = sys.argv[2]
args = sys.argv[3:]
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open("a", encoding="utf8") as f:
f.write(json.dumps({"args": args, "prompt": prompt}, ensure_ascii=False) + "\n")
PY
block_uuid="$(python3 - "$prompt" <<'PY'
import re
import sys
match = re.search(r"^Block UUID:\s*([0-9a-fA-F-]+)\s*$", sys.argv[1], re.MULTILINE)
if not match:
raise SystemExit("Block UUID not found in AgentBridge prompt")
print(match.group(1))
PY
)"
node "$DEMO_CLI" --root-dir "$DEMO_ROOT_DIR" --config "$DEMO_CONFIG" --output json upsert task --graph "$DEMO_GRAPH" --uuid "$block_uuid" --status done >/dev/null
printf '{"type":"thread.started","thread_id":"thread-agent-bridge-demo"}\n'
exit 0
fi
echo "unexpected codex args: $*" >&2
exit 2
FAKE_CODEX
chmod +x "$fake_bin/codex"
}
require_command node
require_command python3
mkdir -p "$work_dir"
printf '{:output-format :json :agent-name "%s"}\n' "$agent_name" > "$config_path"
write_fake_codex
echo "creating graph: $graph"
echo "root dir: $root_dir"
run_cli_json graph create --graph "$graph" >/dev/null
graph_created=1
echo "starting agent bridge"
PATH="$fake_bin:$PATH" \
CODEX_FAKE_LOG="$codex_log" \
DEMO_CLI="$cli_path" \
DEMO_ROOT_DIR="$root_dir" \
DEMO_CONFIG="$config_path" \
DEMO_GRAPH="$graph" \
node "$cli_path" --root-dir "$root_dir" --config "$config_path" --output human agent bridge --graph "$graph" >"$bridge_log" 2>"$bridge_err" &
bridge_pid=$!
wait_for_file_text "$bridge_log" "listening graph changes"
task_id="$(run_cli_json upsert task --graph "$graph" --target-page AgentBridgeDemo --content "$task_title" --status todo | json_result_first)"
run_cli_json upsert block --graph "$graph" --id "$task_id" --update-properties "{\"Assignee\" \"$agent_name\"}" >/dev/null
deadline=$((SECONDS + timeout_sec))
task_status=""
agent_session=""
while (( SECONDS < deadline )); do
task_status="$(query_task_status "$task_id")"
agent_session="$(query_agent_session "$task_id")"
if [[ "$task_status" == "logseq.property/status.done" && "$agent_session" == "$expected_session" ]]; then
break
fi
if [[ -n "${bridge_pid:-}" ]] && ! kill -0 "$bridge_pid" 2>/dev/null; then
echo "agent bridge exited before verification completed" >&2
cat "$bridge_log" >&2
cat "$bridge_err" >&2
exit 1
fi
sleep 0.5
done
if [[ "$task_status" != "logseq.property/status.done" ]]; then
echo "Expected task status done, got: ${task_status:-<empty>}" >&2
cat "$bridge_log" >&2
cat "$bridge_err" >&2
exit 1
fi
if [[ "$agent_session" != "$expected_session" ]]; then
echo "Expected agent-session-id $expected_session, got: ${agent_session:-<empty>}" >&2
cat "$bridge_log" >&2
cat "$bridge_err" >&2
exit 1
fi
python3 - "$codex_log" "$task_title" "$graph" <<'PY'
import json
import pathlib
import sys
log_path = pathlib.Path(sys.argv[1])
task_title = sys.argv[2]
graph = sys.argv[3]
lines = [json.loads(line) for line in log_path.read_text(encoding="utf8").splitlines() if line.strip()]
if len(lines) != 1:
raise SystemExit(f"expected one Codex invocation, got {len(lines)}")
prompt = lines[0]["prompt"]
if task_title not in prompt:
raise SystemExit("task title missing from Codex prompt")
if f"Graph: {graph}" not in prompt:
raise SystemExit("graph missing from Codex prompt")
if "Block UUID:" not in prompt:
raise SystemExit("block uuid missing from Codex prompt")
PY
echo "task status: done"
echo "agent-session-id: $agent_session"
echo "agent bridge demo completed"

View File

@@ -0,0 +1,270 @@
#!/usr/bin/env python3
import argparse
import json
import os
import pathlib
import subprocess
import time
TASK_TITLE = "测试 agent bridge 功能把当前task status设置为done"
EXPECTED_SESSION = "thread-e2e-agent-bridge"
def write_fake_codex(fake_bin):
fake_codex = fake_bin / "codex"
fake_codex.parent.mkdir(parents=True, exist_ok=True)
fake_codex.write_text(
"""#!/usr/bin/env python3
import json
import os
import pathlib
import sys
args = sys.argv[1:]
if args == ["--version"]:
print("codex-cli 0.0.0-e2e")
sys.exit(0)
if args[:2] == ["exec", "--json"]:
prompt = args[2] if len(args) > 2 else ""
log_path = pathlib.Path(os.environ["CODEX_FAKE_LOG"])
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open("a", encoding="utf8") as f:
f.write(json.dumps({"args": args, "prompt": prompt}, ensure_ascii=False) + "\\n")
print(json.dumps({"type": "thread.started", "thread_id": "thread-e2e-agent-bridge"}), flush=True)
sys.exit(0)
print("unexpected codex args: " + repr(args), file=sys.stderr)
sys.exit(2)
""",
encoding="utf8",
)
fake_codex.chmod(0o755)
def run_json(cli, repo_root, root_dir, config, graph, query):
result = subprocess.run(
cli
+ [
"--root-dir",
root_dir,
"--config",
config,
"--output",
"json",
"query",
"--graph",
graph,
"--query",
query,
],
cwd=repo_root,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode != 0:
return None
return json.loads(result.stdout).get("data", {}).get("result")
def run_cli(cli, repo_root, root_dir, config, graph, extra_args):
result = subprocess.run(
cli
+ [
"--root-dir",
root_dir,
"--config",
config,
"--output",
"json",
]
+ extra_args,
cwd=repo_root,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode != 0:
raise SystemExit(
"CLI command failed: {}\nstdout:\n{}\nstderr:\n{}".format(
" ".join(extra_args), result.stdout, result.stderr
)
)
return json.loads(result.stdout) if result.stdout.strip() else None
def deref_session_value(cli, repo_root, root_dir, config, graph, value):
if not isinstance(value, int):
return value
return run_json(
cli,
repo_root,
root_dir,
config,
graph,
f"[:find ?title . :where [{value} :block/title ?title]]",
)
def read_text(path):
return path.read_text(encoding="utf8", errors="replace")
def wait_for_log(path, text, process):
deadline = time.time() + 30
while time.time() < deadline:
if path.exists() and text in read_text(path):
return
if process.poll() is not None:
raise SystemExit(
"agent bridge exited before {}\nstdout:\n{}".format(
text, read_text(path)
)
)
time.sleep(0.2)
raise SystemExit("agent bridge did not log {!r}\nstdout:\n{}".format(text, read_text(path)))
def assign_task(cli, repo_root, root_dir, config, graph):
task_id = run_json(
cli,
repo_root,
root_dir,
config,
graph,
'[:find ?e . :where [?e :block/title "{}"]]'.format(TASK_TITLE),
)
if task_id is None:
raise SystemExit("task block was not found")
run_cli(
cli,
repo_root,
root_dir,
config,
graph,
[
"upsert",
"block",
"--graph",
graph,
"--id",
str(task_id),
"--update-properties",
'{{"Assignee" "{}"}}'.format(os.uname().nodename),
],
)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--cli", required=True)
parser.add_argument("--root-dir", required=True)
parser.add_argument("--config", required=True)
parser.add_argument("--graph", required=True)
parser.add_argument("--tmp-dir", required=True)
parser.add_argument("--repo-root", required=True)
parser.add_argument("--prepare-fake-codex-only", action="store_true")
parser.add_argument("--assign-after-start", action="store_true")
args = parser.parse_args()
repo_root = pathlib.Path(args.repo_root)
tmp_dir = pathlib.Path(args.tmp_dir)
fake_bin = tmp_dir / "fake-bin"
bridge_log = tmp_dir / "agent-bridge.log"
bridge_err = tmp_dir / "agent-bridge.err"
codex_log = tmp_dir / "codex-invocations.jsonl"
cli = ["node", args.cli]
write_fake_codex(fake_bin)
if args.prepare_fake_codex_only:
return
env = os.environ.copy()
env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "")
env["CODEX_FAKE_LOG"] = str(codex_log)
with bridge_log.open("wb") as out, bridge_err.open("wb") as err:
bridge = subprocess.Popen(
cli
+ [
"--root-dir",
args.root_dir,
"--config",
args.config,
"--output",
"human",
"agent",
"bridge",
"--graph",
args.graph,
],
cwd=repo_root,
env=env,
stdout=out,
stderr=err,
)
try:
if args.assign_after_start:
wait_for_log(bridge_log, "listening graph changes", bridge)
assign_task(cli, repo_root, args.root_dir, args.config, args.graph)
deadline = time.time() + 30
session = None
query = (
'[:find ?session . :where [?e :block/title "{}"] '
'[?p :block/name "agent-session-id"] '
"[?p :db/ident ?attr] [?e ?attr ?session]]"
).format(TASK_TITLE)
while time.time() < deadline:
session = deref_session_value(
cli,
repo_root,
args.root_dir,
args.config,
args.graph,
run_json(cli, repo_root, args.root_dir, args.config, args.graph, query),
)
if session == EXPECTED_SESSION:
break
if bridge.poll() is not None:
raise SystemExit(
"agent bridge exited early with {}\nstdout:\n{}\nstderr:\n{}".format(
bridge.returncode, read_text(bridge_log), read_text(bridge_err)
)
)
time.sleep(0.5)
else:
raise SystemExit(
"agent-session-id was not written; last session={!r}\nstdout:\n{}\nstderr:\n{}".format(
session, read_text(bridge_log), read_text(bridge_err)
)
)
lines = [
json.loads(line)
for line in codex_log.read_text(encoding="utf8").splitlines()
if line.strip()
]
assert len(lines) == 1, lines
prompt = lines[0]["prompt"]
assert TASK_TITLE in prompt, prompt
assert "Graph: " + args.graph in prompt, prompt
assert "Block UUID:" in prompt, prompt
print("agent bridge routed task to " + session)
finally:
if bridge.poll() is None:
bridge.terminate()
try:
bridge.wait(timeout=5)
except subprocess.TimeoutExpired:
bridge.kill()
bridge.wait(timeout=5)
if __name__ == "__main__":
main()

View File

@@ -1307,6 +1307,65 @@ PY"
:server ["--graph"]}},
:tags [:server],
:extends :non-sync/graph-json-env}
{:id "agent-bridge-routes-assigned-task",
:setup
["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert task --graph {{graph-arg}} --target-page AgentBridgeE2E --content '测试 agent bridge 功能把当前task status设置为done' --status todo >/dev/null"],
:cmds
["python3 '{{repo-root}}/cli-e2e/scripts/agent_bridge_e2e.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{root-dir}}' --config '{{config-path}}' --graph '{{graph}}' --tmp-dir '{{tmp-dir}}' --repo-root '{{repo-root}}' --assign-after-start"],
:expect {:exit 0, :stdout-contains ["agent bridge routed task to thread-e2e-agent-bridge"]},
:covers
{:commands ["agent bridge"],
:options
{:global ["--config" "--graph" "--root-dir" "--output"],
:agent []}},
:cleanup
["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}"],
:tags [:agent],
:extends :non-sync/graph-json-env}
{:id "agent-bridge-demo-script",
:cmds
["bash '{{repo-root}}/cli-e2e/scripts/agent_bridge_demo.sh' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{tmp-dir}}/demo-root' --graph cli-e2e-agent-bridge-demo --repo-root '{{repo-root}}'"],
:expect
{:exit 0,
:stdout-contains ["agent bridge demo completed"
"task status: done"
"agent-session-id: thread-agent-bridge-demo"]},
:covers
{:commands ["agent bridge"],
:options
{:global ["--config" "--graph" "--root-dir" "--output"],
:agent []}},
:tags [:agent]}
{:id "agent-bridge-dry-run-json",
:setup
["python3 '{{repo-root}}/cli-e2e/scripts/agent_bridge_e2e.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{root-dir}}' --config '{{config-path}}' --graph '{{graph}}' --tmp-dir '{{tmp-dir}}' --repo-root '{{repo-root}}' --prepare-fake-codex-only"
"{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert task --graph {{graph-arg}} --target-page AgentBridgeE2E --content '测试 agent bridge 功能把当前task status设置为done' --status todo >/dev/null"
"TASK_ID=\"$({{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json query --graph {{graph-arg}} --query '[:find ?e . :where [?e :block/title \"测试 agent bridge 功能把当前task status设置为done\"]]' | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"result\"])')\"; {{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --id \"$TASK_ID\" --update-properties \"{\\\"Assignee\\\" \\\"$(hostname)\\\"}\" >/dev/null"],
:cmds
["CODEX_FAKE_LOG={{tmp-dir-arg}}/dry-run-codex.jsonl PATH={{tmp-dir-arg}}/fake-bin:$PATH {{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json agent bridge --graph {{graph-arg}} --dry-run"],
:expect
{:exit 0,
:stdout-json-paths {[:status] "ok", [:data :mode] "dry-run"},
:stdout-contains ["测试 agent bridge 功能把当前task status设置为done"]},
:covers
{:commands ["agent bridge"],
:options
{:global ["--config" "--graph" "--root-dir" "--output"],
:agent ["--dry-run"]}},
:cleanup
["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}"],
:tags [:agent],
:extends :non-sync/graph-json-env}
{:id "agent-bridge-list-all-json",
:cmds
["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json agent bridge list --all"],
:expect {:exit 0, :stdout-json-paths {[:status] "ok", [:data :sessions] []}},
:covers
{:commands ["agent bridge list"],
:options
{:global ["--config" "--root-dir" "--output"],
:agent ["--all"]}},
:tags [:agent]}
{:id "skill-show-human",
:cmds ["{{cli}} skill show"],
:expect

View File

@@ -154,6 +154,12 @@
{:commands ["doctor"]
:options ["--dev-script"]}
:agent
{:commands ["agent bridge"
"agent bridge list"]
:options ["--dry-run"
"--all"]}
:completion
{:commands ["completion"]
:options ["bash"

View File

@@ -398,7 +398,15 @@
:schema {:type :property
:hide? true}}
;; TODO: Add more props :Assignee, :Estimate, :Cycle, :Project
:logseq.property/assignee {:title "Assignee"
:schema {:type :node
:cardinality :many
:public? true
:ui-position :block-below}
:properties {:logseq.property/hide-empty-value true}
:queryable? true}
;; TODO: Add more props :Estimate, :Cycle, :Project
:logseq.property/icon {:title "Icon"
:schema {:type :map}}

View File

@@ -74,3 +74,16 @@
(is (= false (get-in props [property :schema :public?])))
(is (= true (get-in props [property :schema :hide?])))
(is (db-property/logseq-property? property))))
(deftest assignee-built-in-property
(let [property (get db-property/built-in-properties :logseq.property/assignee)]
(testing "schema"
(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?]))))
(testing "queryable built-in logseq property"
(is (= true (:queryable? property)))
(is (contains? db-property/public-built-in-properties :logseq.property/assignee))
(is (db-property/logseq-property? :logseq.property/assignee)))))

View File

@@ -112,6 +112,8 @@ The top-level help groups commands into graph inspection/editing, graph manageme
- `login`
- `logout`
- `agent bridge`
- `agent bridge list`
- `completion`
- `debug pull`
- `skill show`
@@ -120,6 +122,12 @@ The top-level help groups commands into graph inspection/editing, graph manageme
`example` entries are generated from command metadata for the inspect/edit families `list`, `upsert`, `remove`, `query`, `search`, and `show`. Use exact selectors such as `example upsert page` or prefix selectors such as `example upsert`.
### Agent bridge
`agent bridge` is the first AgentBridge command surface. It resolves the target graph using normal CLI config precedence, resolves the AgentBridge name from `:agent-name` in `cli.edn` or the machine hostname, checks that `codex` is available, starts or reuses the graph's db-worker-node, registers the AgentBridge name in the graph, scans TODO `#Task` blocks assigned to that AgentBridge name, and listens to db-worker-node events for follow-up scans. Matched tasks are routed to `codex exec --json`; the bridge records the Codex session/thread id in `agent-bridge-sessions.edn` and writes it to the task's `agent-session-id` property. `--dry-run` performs the setup and scan but only prints the Codex commands it would run.
`agent bridge list` reads root-dir scoped bridge session records from `agent-bridge-sessions.edn`. Human output uses the columns `SESSION`, `STATUS`, `BACKEND`, `GRAPH`, `BLOCK`, `AGENT`, `STARTED`, and `UPDATED`, ending with `Count: N`. Completed sessions are hidden by default; use `--all` to include them.
## Global flags and output modes
Global flags are defined in `logseq.cli.command.core/global-spec`:

View File

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

View File

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

View 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}))))))))))))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 "显示为复选框的属性"

View File

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

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

View File

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