diff --git a/.agents/skills/logseq-cli/SKILL.md b/.agents/skills/logseq-cli/SKILL.md index c482fea6f6..36d1c8683f 100644 --- a/.agents/skills/logseq-cli/SKILL.md +++ b/.agents/skills/logseq-cli/SKILL.md @@ -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. diff --git a/cli-e2e/scripts/agent_bridge_demo.sh b/cli-e2e/scripts/agent_bridge_demo.sh new file mode 100755 index 0000000000..97c642bf1f --- /dev/null +++ b/cli-e2e/scripts/agent_bridge_demo.sh @@ -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: /static/logseq-cli.js + --root-dir DIR Logseq CLI root dir. Default: a new temp dir + --graph NAME Graph name. Default: agent-bridge-demo- + --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:-}" >&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:-}" >&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" diff --git a/cli-e2e/scripts/agent_bridge_e2e.py b/cli-e2e/scripts/agent_bridge_e2e.py new file mode 100644 index 0000000000..1de0825740 --- /dev/null +++ b/cli-e2e/scripts/agent_bridge_e2e.py @@ -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() diff --git a/cli-e2e/spec/non_sync_cases.edn b/cli-e2e/spec/non_sync_cases.edn index b42731d51f..281e7adfe1 100644 --- a/cli-e2e/spec/non_sync_cases.edn +++ b/cli-e2e/spec/non_sync_cases.edn @@ -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 diff --git a/cli-e2e/spec/non_sync_inventory.edn b/cli-e2e/spec/non_sync_inventory.edn index 917274f35b..bf868c6180 100644 --- a/cli-e2e/spec/non_sync_inventory.edn +++ b/cli-e2e/spec/non_sync_inventory.edn @@ -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" diff --git a/deps/db/src/logseq/db/frontend/property.cljs b/deps/db/src/logseq/db/frontend/property.cljs index 9ed9221d83..2ac9162a51 100644 --- a/deps/db/src/logseq/db/frontend/property.cljs +++ b/deps/db/src/logseq/db/frontend/property.cljs @@ -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}} diff --git a/deps/db/test/logseq/db/frontend/property_test.cljs b/deps/db/test/logseq/db/frontend/property_test.cljs index 1d04897e4d..b6b76a448d 100644 --- a/deps/db/test/logseq/db/frontend/property_test.cljs +++ b/deps/db/test/logseq/db/frontend/property_test.cljs @@ -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))))) diff --git a/docs/agent-guide/logseq-cli/001-logseq-cli.md b/docs/agent-guide/logseq-cli/001-logseq-cli.md index 690a9d9e5c..0f0dc6d151 100644 --- a/docs/agent-guide/logseq-cli/001-logseq-cli.md +++ b/docs/agent-guide/logseq-cli/001-logseq-cli.md @@ -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`: diff --git a/src/main/frontend/worker/db/migrate.cljs b/src/main/frontend/worker/db/migrate.cljs index 40d8033038..b1c76fde87 100644 --- a/src/main/frontend/worker/db/migrate.cljs +++ b/src/main/frontend/worker/db/migrate.cljs @@ -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)))] diff --git a/src/main/frontend/worker/db_worker_node.cljs b/src/main/frontend/worker/db_worker_node.cljs index 81478e962f..f3c470e126 100644 --- a/src/main/frontend/worker/db_worker_node.cljs +++ b/src/main/frontend/worker/db_worker_node.cljs @@ -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) diff --git a/src/main/logseq/cli/command/agent.cljs b/src/main/logseq/cli/command/agent.cljs new file mode 100644 index 0000000000..d644cb23c5 --- /dev/null +++ b/src/main/logseq/cli/command/agent.cljs @@ -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})))))))))))) diff --git a/src/main/logseq/cli/command/core.cljs b/src/main/logseq/cli/command/core.cljs index f3a0b95a5e..ad4102a2b3 100644 --- a/src/main/logseq/cli/command/core.cljs +++ b/src/main/logseq/cli/command/core.cljs @@ -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"}}] diff --git a/src/main/logseq/cli/commands.cljs b/src/main/logseq/cli/commands.cljs index 6ac18febf0..cef69e1bd9 100644 --- a/src/main/logseq/cli/commands.cljs +++ b/src/main/logseq/cli/commands.cljs @@ -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) diff --git a/src/main/logseq/cli/format.cljs b/src/main/logseq/cli/format.cljs index 5d045d93b7..204bbc8860 100644 --- a/src/main/logseq/cli/format.cljs +++ b/src/main/logseq/cli/format.cljs @@ -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) diff --git a/src/main/logseq/cli/server.cljs b/src/main/logseq/cli/server.cljs index 1d27f1ecdb..9364c9b9c5 100644 --- a/src/main/logseq/cli/server.cljs +++ b/src/main/logseq/cli/server.cljs @@ -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" diff --git a/src/resources/dicts/en.edn b/src/resources/dicts/en.edn index cdd4a4a1fb..5538c51031 100644 --- a/src/resources/dicts/en.edn +++ b/src/resources/dicts/en.edn @@ -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" diff --git a/src/resources/dicts/zh-cn.edn b/src/resources/dicts/zh-cn.edn index fb8c356bc8..a4eb1a6cc0 100644 --- a/src/resources/dicts/zh-cn.edn +++ b/src/resources/dicts/zh-cn.edn @@ -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 "显示为复选框的属性" diff --git a/src/test/frontend/worker/db_worker_node_test.cljs b/src/test/frontend/worker/db_worker_node_test.cljs index f1ba48efe4..b8a7622c43 100644 --- a/src/test/frontend/worker/db_worker_node_test.cljs +++ b/src/test/frontend/worker/db_worker_node_test.cljs @@ -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") diff --git a/src/test/logseq/cli/command/agent_test.cljs b/src/test/logseq/cli/command/agent_test.cljs new file mode 100644 index 0000000000..ad553f2631 --- /dev/null +++ b/src/test/logseq/cli/command/agent_test.cljs @@ -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)))))) diff --git a/src/test/logseq/cli/server_test.cljs b/src/test/logseq/cli/server_test.cljs index 47fc725b72..5a4ff96c0a 100644 --- a/src/test/logseq/cli/server_test.cljs +++ b/src/test/logseq/cli/server_test.cljs @@ -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