mirror of
https://github.com/logseq/logseq.git
synced 2026-05-24 12:44:22 +00:00
feat(cli): agent bridge
This commit is contained in:
@@ -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.
|
||||
|
||||
298
cli-e2e/scripts/agent_bridge_demo.sh
Executable file
298
cli-e2e/scripts/agent_bridge_demo.sh
Executable 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"
|
||||
270
cli-e2e/scripts/agent_bridge_e2e.py
Normal file
270
cli-e2e/scripts/agent_bridge_e2e.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
10
deps/db/src/logseq/db/frontend/property.cljs
vendored
10
deps/db/src/logseq/db/frontend/property.cljs
vendored
@@ -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}}
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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