From 608e8ccfb582c0fbd4674eb7b3ef63515d86e1a4 Mon Sep 17 00:00:00 2001 From: Grivn Date: Thu, 2 Jul 2026 01:53:42 +0800 Subject: [PATCH 1/9] test: scaffold R3-1 harness validation scripts Add the harness/scripts/r3-1 entrypoint with preflight and MnemonHub contract phases plus JSON summaries and skipped-capability reporting for pending live phases. Validation: harness/scripts/r3-1/run.sh --smoke --- harness/scripts/r3-1/cloudflare-live/run.sh | 7 ++ harness/scripts/r3-1/cloudflare-local/run.sh | 7 ++ harness/scripts/r3-1/docker-cloudflare/run.sh | 7 ++ harness/scripts/r3-1/docker-local/run.sh | 7 ++ harness/scripts/r3-1/lib/common.sh | 40 ++++++ harness/scripts/r3-1/lib/report.py | 45 +++++++ harness/scripts/r3-1/live-multica/run.sh | 7 ++ .../scripts/r3-1/mnemonhub-contract/run.sh | 22 ++++ .../scripts/r3-1/mnemonhub-contract/verify.py | 57 +++++++++ .../scripts/r3-1/multica-runtime-gate/run.sh | 7 ++ harness/scripts/r3-1/multica-writeback/run.sh | 7 ++ harness/scripts/r3-1/preflight/run.sh | 13 ++ harness/scripts/r3-1/preflight/verify.py | 117 ++++++++++++++++++ harness/scripts/r3-1/run.sh | 81 ++++++++++++ harness/scripts/r3-1/zh-complex-cases/run.sh | 7 ++ 15 files changed, 431 insertions(+) create mode 100755 harness/scripts/r3-1/cloudflare-live/run.sh create mode 100755 harness/scripts/r3-1/cloudflare-local/run.sh create mode 100755 harness/scripts/r3-1/docker-cloudflare/run.sh create mode 100755 harness/scripts/r3-1/docker-local/run.sh create mode 100644 harness/scripts/r3-1/lib/common.sh create mode 100755 harness/scripts/r3-1/lib/report.py create mode 100755 harness/scripts/r3-1/live-multica/run.sh create mode 100755 harness/scripts/r3-1/mnemonhub-contract/run.sh create mode 100755 harness/scripts/r3-1/mnemonhub-contract/verify.py create mode 100755 harness/scripts/r3-1/multica-runtime-gate/run.sh create mode 100755 harness/scripts/r3-1/multica-writeback/run.sh create mode 100755 harness/scripts/r3-1/preflight/run.sh create mode 100755 harness/scripts/r3-1/preflight/verify.py create mode 100755 harness/scripts/r3-1/run.sh create mode 100755 harness/scripts/r3-1/zh-complex-cases/run.sh diff --git a/harness/scripts/r3-1/cloudflare-live/run.sh b/harness/scripts/r3-1/cloudflare-live/run.sh new file mode 100755 index 00000000..6be6c1d5 --- /dev/null +++ b/harness/scripts/r3-1/cloudflare-live/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" +PHASE="cloudflare-live" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Cloudflare bootstrap CLI is not present yet" diff --git a/harness/scripts/r3-1/cloudflare-local/run.sh b/harness/scripts/r3-1/cloudflare-local/run.sh new file mode 100755 index 00000000..89222652 --- /dev/null +++ b/harness/scripts/r3-1/cloudflare-local/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" +PHASE="cloudflare-local" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Cloudflare DO implementation is not present yet" diff --git a/harness/scripts/r3-1/docker-cloudflare/run.sh b/harness/scripts/r3-1/docker-cloudflare/run.sh new file mode 100755 index 00000000..71dd67b4 --- /dev/null +++ b/harness/scripts/r3-1/docker-cloudflare/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" +PHASE="docker-cloudflare" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Docker Cloudflare endpoint validation is pending" diff --git a/harness/scripts/r3-1/docker-local/run.sh b/harness/scripts/r3-1/docker-local/run.sh new file mode 100755 index 00000000..dc2959d3 --- /dev/null +++ b/harness/scripts/r3-1/docker-local/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" +PHASE="docker-local" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Docker R3-1 compose fixture is pending" diff --git a/harness/scripts/r3-1/lib/common.sh b/harness/scripts/r3-1/lib/common.sh new file mode 100644 index 00000000..9e9fc1ea --- /dev/null +++ b/harness/scripts/r3-1/lib/common.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -uo pipefail + +mnemon_r3_script_dir() { + cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd +} + +mnemon_r3_root() { + local dir + dir="$(mnemon_r3_script_dir)" + cd "$dir/../../.." && pwd +} + +mnemon_r3_timestamp() { + date -u +"%Y%m%dT%H%M%SZ" +} + +mnemon_r3_out_dir() { + local root="${MNEMON_R3_ROOT:-$(mnemon_r3_root)}" + local stamp="${MNEMON_R3_RUN_ID:-$(mnemon_r3_timestamp)}" + printf "%s/.mnemon-dev/tmp/r3-1-test/%s" "$root" "$stamp" +} + +mnemon_r3_phase_dir() { + local phase="$1" + local out="${MNEMON_R3_OUT_DIR:-$(mnemon_r3_out_dir)}" + printf "%s/%s" "$out" "$phase" +} + +mnemon_r3_prepare_phase() { + local phase="$1" + local dir + dir="$(mnemon_r3_phase_dir "$phase")" + mkdir -p "$dir" + printf "%s" "$dir" +} + +mnemon_r3_log() { + printf "[r3-1] %s\n" "$*" +} diff --git a/harness/scripts/r3-1/lib/report.py b/harness/scripts/r3-1/lib/report.py new file mode 100755 index 00000000..ac2c753b --- /dev/null +++ b/harness/scripts/r3-1/lib/report.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def write_summary(path, phase, status, commands=None, assertions=None, skipped=None, failures=None): + data = { + "schema_version": 1, + "phase": phase, + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": commands or [], + "assertions": assertions or [], + "skipped": skipped or [], + "failures": failures or [], + } + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return data + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--phase", required=True) + parser.add_argument("--status", required=True, choices=["ok", "failed", "skipped"]) + parser.add_argument("--skip", action="append", default=[]) + parser.add_argument("--failure", action="append", default=[]) + args = parser.parse_args() + + skipped = [{"category": "skipped_missing_capability", "detail": item} for item in args.skip] + failures = [{"category": "environment_failure", "detail": item} for item in args.failure] + write_summary(args.summary, args.phase, args.status, skipped=skipped, failures=failures) + + +if __name__ == "__main__": + main() diff --git a/harness/scripts/r3-1/live-multica/run.sh b/harness/scripts/r3-1/live-multica/run.sh new file mode 100755 index 00000000..d8b40849 --- /dev/null +++ b/harness/scripts/r3-1/live-multica/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" +PHASE="live-multica" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Live Multica validation is pending R3-1 runtime setup" diff --git a/harness/scripts/r3-1/mnemonhub-contract/run.sh b/harness/scripts/r3-1/mnemonhub-contract/run.sh new file mode 100755 index 00000000..67831060 --- /dev/null +++ b/harness/scripts/r3-1/mnemonhub-contract/run.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" + +PHASE="mnemonhub-contract" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +LOG="$PHASE_DIR/go-test.log" +cd "$MNEMON_R3_ROOT" +set +e +go test ./harness/internal/event ./harness/internal/contract ./harness/internal/mnemonhub ./harness/internal/mnemonhub/... ./harness/internal/coreguard ./harness/internal/productconfig >"$LOG" 2>&1 +STATUS=$? +set -e + +python3 "$SCRIPT_DIR/verify.py" \ + --summary "$PHASE_DIR/summary.json" \ + --log "$LOG" \ + --exit-code "$STATUS" +exit "$STATUS" diff --git a/harness/scripts/r3-1/mnemonhub-contract/verify.py b/harness/scripts/r3-1/mnemonhub-contract/verify.py new file mode 100755 index 00000000..87542788 --- /dev/null +++ b/harness/scripts/r3-1/mnemonhub-contract/verify.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--log", required=True) + parser.add_argument("--exit-code", required=True, type=int) + args = parser.parse_args() + + log_text = Path(args.log).read_text(encoding="utf-8", errors="replace") + failures = [] + if args.exit_code != 0: + failures.append({ + "category": "protocol_failure", + "name": "mnemonhub contract go tests", + "detail": f"go test exited {args.exit_code}; see {args.log}", + }) + + assertions = [ + {"name": "go test ./harness/internal/event ./harness/internal/contract ./harness/internal/mnemonhub ./harness/internal/mnemonhub/... ./harness/internal/coreguard ./harness/internal/productconfig", "passed": args.exit_code == 0}, + {"name": "mnemonhub output does not mention Cloudflare token", "passed": "CLOUDFLARE_API_TOKEN=" not in log_text}, + ] + status = "ok" if not failures and all(a["passed"] for a in assertions) else "failed" + summary = { + "schema_version": 1, + "phase": "mnemonhub-contract", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": [{ + "name": "go test ./harness/internal/event ./harness/internal/contract ./harness/internal/mnemonhub ./harness/internal/mnemonhub/... ./harness/internal/coreguard ./harness/internal/productconfig", + "status": "ok" if args.exit_code == 0 else "failed", + "exit_code": args.exit_code, + "log": args.log, + }], + "assertions": assertions, + "skipped": [], + "failures": failures, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + raise SystemExit(0 if status == "ok" else 1) + + +if __name__ == "__main__": + main() diff --git a/harness/scripts/r3-1/multica-runtime-gate/run.sh b/harness/scripts/r3-1/multica-runtime-gate/run.sh new file mode 100755 index 00000000..36fe13d9 --- /dev/null +++ b/harness/scripts/r3-1/multica-runtime-gate/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" +PHASE="multica-runtime-gate" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "R3-1 gate-first runtime implementation is pending" diff --git a/harness/scripts/r3-1/multica-writeback/run.sh b/harness/scripts/r3-1/multica-writeback/run.sh new file mode 100755 index 00000000..18e886b2 --- /dev/null +++ b/harness/scripts/r3-1/multica-writeback/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" +PHASE="multica-writeback" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Command-driven Multica writeback implementation is pending" diff --git a/harness/scripts/r3-1/preflight/run.sh b/harness/scripts/r3-1/preflight/run.sh new file mode 100755 index 00000000..a5a4aae8 --- /dev/null +++ b/harness/scripts/r3-1/preflight/run.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" + +PHASE="preflight" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +python3 "$SCRIPT_DIR/verify.py" \ + --root "$MNEMON_R3_ROOT" \ + --summary "$PHASE_DIR/summary.json" diff --git a/harness/scripts/r3-1/preflight/verify.py b/harness/scripts/r3-1/preflight/verify.py new file mode 100755 index 00000000..c0c670b6 --- /dev/null +++ b/harness/scripts/r3-1/preflight/verify.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import shutil +import stat +import subprocess +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def command_available(name): + return shutil.which(name) is not None + + +def run_git_status(root): + proc = subprocess.run( + ["git", "status", "--short", "--branch"], + cwd=root, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return { + "name": "git status --short --branch", + "status": "ok" if proc.returncode == 0 else "failed", + "exit_code": proc.returncode, + "stdout": proc.stdout.strip(), + "stderr": proc.stderr.strip(), + } + + +def env_file_check(): + path = Path.home() / ".mnemon" / "cloudflare-bootstrap.env" + if not path.exists(): + return None, {"category": "skipped_missing_capability", "name": "cloudflare env", "detail": str(path)} + mode = stat.S_IMODE(path.stat().st_mode) + ok = mode == 0o600 + assertion = { + "name": "cloudflare bootstrap env permissions are 0600", + "passed": ok, + "detail": oct(mode), + } + allowed = {"CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "MNEMON_CLOUDFLARE_WORKER_NAME"} + extra = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key = line.split("=", 1)[0].strip().removeprefix("export ").strip() + if key not in allowed: + extra.append(key) + if extra: + assertion["passed"] = False + assertion["detail"] += " unexpected keys: " + ",".join(extra) + return assertion, None + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--root", required=True) + parser.add_argument("--summary", required=True) + args = parser.parse_args() + + required = ["go", "python3", "git"] + optional = ["docker", "wrangler", "multica", "node", "npm"] + + assertions = [] + skipped = [] + failures = [] + + for name in required: + ok = command_available(name) + assertions.append({"name": f"{name} available", "passed": ok}) + if not ok: + failures.append({"category": "environment_failure", "name": f"{name} missing", "detail": name}) + + for name in optional: + if not command_available(name): + skipped.append({"category": "skipped_missing_capability", "name": f"{name} missing", "detail": name}) + + env_assertion, env_skip = env_file_check() + if env_assertion: + assertions.append(env_assertion) + if not env_assertion["passed"]: + failures.append({"category": "environment_failure", "name": env_assertion["name"], "detail": env_assertion["detail"]}) + if env_skip: + skipped.append(env_skip) + + commands = [run_git_status(args.root)] + if commands[0]["status"] != "ok": + failures.append({"category": "environment_failure", "name": "git status failed", "detail": commands[0]["stderr"]}) + + status = "ok" if not failures else "failed" + summary = { + "schema_version": 1, + "phase": "preflight", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": commands, + "assertions": assertions, + "skipped": skipped, + "failures": failures, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + raise SystemExit(0 if status == "ok" else 1) + + +if __name__ == "__main__": + main() diff --git a/harness/scripts/r3-1/run.sh b/harness/scripts/r3-1/run.sh new file mode 100755 index 00000000..d5430149 --- /dev/null +++ b/harness/scripts/r3-1/run.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +export MNEMON_R3_ROOT="${MNEMON_R3_ROOT:-$ROOT}" +export MNEMON_R3_RUN_ID="${MNEMON_R3_RUN_ID:-$(date -u +"%Y%m%dT%H%M%SZ")}" +export MNEMON_R3_OUT_DIR="${MNEMON_R3_OUT_DIR:-$ROOT/.mnemon-dev/tmp/r3-1-test/$MNEMON_R3_RUN_ID}" + +PHASES=( + preflight + mnemonhub-contract + cloudflare-local + cloudflare-live + multica-runtime-gate + multica-writeback + docker-local + docker-cloudflare + live-multica + zh-complex-cases +) + +usage() { + cat <<'USAGE' +Usage: + harness/scripts/r3-1/run.sh --phase + harness/scripts/r3-1/run.sh --smoke + harness/scripts/r3-1/run.sh --full + +Phases: + preflight + mnemonhub-contract + cloudflare-local + cloudflare-live + multica-runtime-gate + multica-writeback + docker-local + docker-cloudflare + live-multica + zh-complex-cases +USAGE +} + +run_phase() { + local phase="$1" + local runner="$SCRIPT_DIR/$phase/run.sh" + if [[ ! -x "$runner" ]]; then + echo "[r3-1] phase runner missing or not executable: $runner" >&2 + return 2 + fi + echo "[r3-1] phase=$phase out=$MNEMON_R3_OUT_DIR" + "$runner" +} + +if [[ $# -eq 0 ]]; then + usage + exit 2 +fi + +case "$1" in + --phase) + [[ $# -eq 2 ]] || { usage >&2; exit 2; } + run_phase "$2" + ;; + --smoke) + run_phase preflight + run_phase mnemonhub-contract + ;; + --full) + for phase in "${PHASES[@]}"; do + run_phase "$phase" + done + ;; + -h|--help) + usage + ;; + *) + usage >&2 + exit 2 + ;; +esac diff --git a/harness/scripts/r3-1/zh-complex-cases/run.sh b/harness/scripts/r3-1/zh-complex-cases/run.sh new file mode 100755 index 00000000..a91d3390 --- /dev/null +++ b/harness/scripts/r3-1/zh-complex-cases/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" +PHASE="zh-complex-cases" +PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" +python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Chinese complex case runner is pending Docker/Multica implementation" From 38420de6616c2178e005a33b4324874f0284d627 Mon Sep 17 00:00:00 2001 From: Grivn Date: Thu, 2 Jul 2026 01:56:45 +0800 Subject: [PATCH 2/9] test: add MnemonHub sync ABI fixtures Add a shared sync-abi fixture corpus under harness/testdata and drive MnemonHub push, pull, status, auth, replay, conflict, malformed, and scope tests from it. Validation: go test ./harness/internal/mnemonhub ./harness/internal/contract; harness/scripts/r3-1/run.sh --phase mnemonhub-contract --- .../mnemonhub/sync_abi_fixture_test.go | 278 ++++++++++++++++++ .../mnemonhub/sync-abi/auth-invalid.json | 8 + .../mnemonhub/sync-abi/auth-missing.json | 8 + .../mnemonhub/sync-abi/pull-after-cursor.json | 20 ++ .../sync-abi/pull-excludes-origin.json | 20 ++ .../mnemonhub/sync-abi/push-conflict.json | 24 ++ .../mnemonhub/sync-abi/push-malformed.json | 19 ++ .../mnemonhub/sync-abi/push-out-of-scope.json | 18 ++ .../mnemonhub/sync-abi/push-replay.json | 18 ++ .../mnemonhub/sync-abi/push-valid.json | 18 ++ .../mnemonhub/sync-abi/status-ok.json | 20 ++ 11 files changed, 451 insertions(+) create mode 100644 harness/internal/mnemonhub/sync_abi_fixture_test.go create mode 100644 harness/testdata/mnemonhub/sync-abi/auth-invalid.json create mode 100644 harness/testdata/mnemonhub/sync-abi/auth-missing.json create mode 100644 harness/testdata/mnemonhub/sync-abi/pull-after-cursor.json create mode 100644 harness/testdata/mnemonhub/sync-abi/pull-excludes-origin.json create mode 100644 harness/testdata/mnemonhub/sync-abi/push-conflict.json create mode 100644 harness/testdata/mnemonhub/sync-abi/push-malformed.json create mode 100644 harness/testdata/mnemonhub/sync-abi/push-out-of-scope.json create mode 100644 harness/testdata/mnemonhub/sync-abi/push-replay.json create mode 100644 harness/testdata/mnemonhub/sync-abi/push-valid.json create mode 100644 harness/testdata/mnemonhub/sync-abi/status-ok.json diff --git a/harness/internal/mnemonhub/sync_abi_fixture_test.go b/harness/internal/mnemonhub/sync_abi_fixture_test.go new file mode 100644 index 00000000..1bd85a9e --- /dev/null +++ b/harness/internal/mnemonhub/sync_abi_fixture_test.go @@ -0,0 +1,278 @@ +package mnemonhub + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" +) + +type syncABIFixture struct { + Name string `json:"name"` + Kind string `json:"kind"` + Principal contract.ActorID `json:"principal"` + ReplicaID string `json:"replica_id"` + GrantScopes []contract.ResourceRef `json:"grant_scopes"` + SeedPrincipal contract.ActorID `json:"seed_principal"` + SeedReplicaID string `json:"seed_replica_id"` + SeedEvents []syncABIEventFixture `json:"seed_events"` + Events []syncABIEventFixture `json:"events"` + Route string `json:"route"` + Method string `json:"method"` + Token string `json:"token"` + Want syncABIWant `json:"want"` +} + +type syncABIEventFixture struct { + OriginReplicaID string `json:"origin_replica_id"` + LocalDecisionID string `json:"local_decision_id"` + ResourceRef contract.ResourceRef `json:"resource_ref"` + Fields map[string]any `json:"fields"` + CorruptDigest bool `json:"corrupt_digest"` +} + +type syncABIWant struct { + Accepted int `json:"accepted"` + Rejected int `json:"rejected"` + Conflicts int `json:"conflicts"` + StoredEvents int `json:"stored_events"` + Events int `json:"events"` + AfterCursorEvents int `json:"after_cursor_events"` + HubEventsReceived int `json:"hub_events_received"` + StatusCode int `json:"status_code"` +} + +func TestSyncABIFixtures(t *testing.T) { + for _, fixture := range loadSyncABIFixtures(t) { + fixture := fixture + t.Run(fixture.Name, func(t *testing.T) { + switch fixture.Kind { + case "push": + runSyncABIPushFixture(t, fixture) + case "push_replay": + runSyncABIReplayFixture(t, fixture) + case "push_conflict": + runSyncABIConflictFixture(t, fixture) + case "pull": + runSyncABIPullFixture(t, fixture) + case "status": + runSyncABIStatusFixture(t, fixture) + case "http_auth": + runSyncABIHTTPAuthFixture(t, fixture) + default: + t.Fatalf("unknown sync ABI fixture kind %q", fixture.Kind) + } + }) + } +} + +func loadSyncABIFixtures(t *testing.T) []syncABIFixture { + t.Helper() + root := filepath.Join("..", "..", "testdata", "mnemonhub", "sync-abi") + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("read sync ABI fixtures: %v", err) + } + var fixtures []syncABIFixture + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + raw, err := os.ReadFile(filepath.Join(root, entry.Name())) + if err != nil { + t.Fatalf("read fixture %s: %v", entry.Name(), err) + } + var fixture syncABIFixture + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatalf("decode fixture %s: %v", entry.Name(), err) + } + if fixture.Name == "" { + t.Fatalf("fixture %s has empty name", entry.Name()) + } + fixtures = append(fixtures, fixture) + } + if len(fixtures) == 0 { + t.Fatal("no sync ABI fixtures loaded") + } + return fixtures +} + +func openFixtureHub(t *testing.T, fixture syncABIFixture) *Server { + t.Helper() + grants := GrantMap{} + if fixture.Principal != "" { + grants[fixture.Principal] = ReplicaGrant{Principal: fixture.Principal, Scopes: fixture.GrantScopes} + } + if fixture.SeedPrincipal != "" { + grants[fixture.SeedPrincipal] = ReplicaGrant{Principal: fixture.SeedPrincipal, Scopes: fixture.GrantScopes} + } + hub, _ := openTestHub(t, grants) + return hub +} + +func runSyncABIPushFixture(t *testing.T, fixture syncABIFixture) { + hub := openFixtureHub(t, fixture) + resp, err := hub.Push(fixture.Principal, contract.SyncPushRequest{ + ReplicaID: fixture.ReplicaID, + BatchID: fixture.Name, + Events: fixtureEvents(t, fixture.Events), + }) + if err != nil { + t.Fatalf("push fixture %s: %v", fixture.Name, err) + } + assertExchangeCounts(t, fixture, resp) +} + +func runSyncABIReplayFixture(t *testing.T, fixture syncABIFixture) { + hub, st := openTestHub(t, GrantMap{ + fixture.Principal: {Principal: fixture.Principal, Scopes: fixture.GrantScopes}, + }) + req := contract.SyncPushRequest{ReplicaID: fixture.ReplicaID, BatchID: fixture.Name, Events: fixtureEvents(t, fixture.Events)} + first, err := hub.Push(fixture.Principal, req) + if err != nil { + t.Fatalf("first replay fixture push: %v", err) + } + second, err := hub.Push(fixture.Principal, req) + if err != nil { + t.Fatalf("second replay fixture push: %v", err) + } + assertExchangeCounts(t, fixture, second) + if len(first.Accepted) != len(second.Accepted) || first.NextCursor != second.NextCursor { + t.Fatalf("replay fixture must return stable accepted ack: first=%+v second=%+v", first, second) + } + if fixture.Want.StoredEvents > 0 { + got, _ := st.RemoteSyncedEventCount() + if int(got) != fixture.Want.StoredEvents { + t.Fatalf("stored events = %d, want %d", got, fixture.Want.StoredEvents) + } + } +} + +func runSyncABIConflictFixture(t *testing.T, fixture syncABIFixture) { + if len(fixture.Events) != 2 { + t.Fatalf("conflict fixture requires exactly two events") + } + hub := openFixtureHub(t, fixture) + first := contract.SyncPushRequest{ReplicaID: fixture.ReplicaID, BatchID: fixture.Name + "-first", Events: fixtureEvents(t, fixture.Events[:1])} + if _, err := hub.Push(fixture.Principal, first); err != nil { + t.Fatalf("seed conflict fixture: %v", err) + } + second := contract.SyncPushRequest{ReplicaID: fixture.ReplicaID, BatchID: fixture.Name + "-second", Events: fixtureEvents(t, fixture.Events[1:])} + resp, err := hub.Push(fixture.Principal, second) + if err != nil { + t.Fatalf("conflict fixture push: %v", err) + } + assertExchangeCounts(t, fixture, resp) +} + +func runSyncABIPullFixture(t *testing.T, fixture syncABIFixture) { + hub := openFixtureHub(t, fixture) + seedFixture(t, hub, fixture) + resp, err := hub.Pull(fixture.Principal, contract.SyncPullRequest{ReplicaID: fixture.ReplicaID}) + if err != nil { + t.Fatalf("pull fixture %s: %v", fixture.Name, err) + } + if len(resp.Events) != fixture.Want.Events { + t.Fatalf("pull events = %d, want %d", len(resp.Events), fixture.Want.Events) + } + if fixture.Want.AfterCursorEvents >= 0 && resp.NextCursor != "" { + after, err := hub.Pull(fixture.Principal, contract.SyncPullRequest{ReplicaID: fixture.ReplicaID, RemoteCursor: resp.NextCursor}) + if err != nil { + t.Fatalf("pull after cursor: %v", err) + } + if len(after.Events) != fixture.Want.AfterCursorEvents { + t.Fatalf("after-cursor events = %d, want %d", len(after.Events), fixture.Want.AfterCursorEvents) + } + } +} + +func runSyncABIStatusFixture(t *testing.T, fixture syncABIFixture) { + hub := openFixtureHub(t, fixture) + seedFixture(t, hub, fixture) + resp, err := hub.Status(fixture.Principal) + if err != nil { + t.Fatalf("status fixture %s: %v", fixture.Name, err) + } + if int(resp.HubEventsReceived) != fixture.Want.HubEventsReceived { + t.Fatalf("HubEventsReceived = %d, want %d", resp.HubEventsReceived, fixture.Want.HubEventsReceived) + } +} + +func runSyncABIHTTPAuthFixture(t *testing.T, fixture syncABIFixture) { + mem := contract.ResourceRef{Kind: "memory", ID: "project"} + hub, _ := openTestHub(t, GrantMap{ + "replica-a@team": {Principal: "replica-a@team", Scopes: []contract.ResourceRef{mem}}, + }) + var audit bytes.Buffer + srv := httptest.NewServer(NewHTTPHandler(hub, BearerAuthenticator{Tokens: map[string]contract.ActorID{ + "tok-a": "replica-a@team", + }}, &audit)) + t.Cleanup(srv.Close) + body := contract.SyncPushRequest{ReplicaID: "local-a", BatchID: fixture.Name, Events: fixtureEvents(t, []syncABIEventFixture{{ + OriginReplicaID: "local-a", + LocalDecisionID: "dec-auth", + ResourceRef: mem, + Fields: map[string]any{"content": "auth"}, + }})} + raw, _ := json.Marshal(body) + req, err := http.NewRequest(fixture.Method, srv.URL+fixture.Route, bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + if fixture.Token != "" { + req.Header.Set("Authorization", "Bearer "+fixture.Token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != fixture.Want.StatusCode { + t.Fatalf("status code = %d, want %d; audit=%s", resp.StatusCode, fixture.Want.StatusCode, audit.String()) + } +} + +func seedFixture(t *testing.T, hub *Server, fixture syncABIFixture) { + t.Helper() + if len(fixture.SeedEvents) == 0 { + return + } + _, err := hub.Push(fixture.SeedPrincipal, contract.SyncPushRequest{ + ReplicaID: fixture.SeedReplicaID, + BatchID: fixture.Name + "-seed", + Events: fixtureEvents(t, fixture.SeedEvents), + }) + if err != nil { + t.Fatalf("seed fixture %s: %v", fixture.Name, err) + } +} + +func assertExchangeCounts(t *testing.T, fixture syncABIFixture, resp contract.SyncPushResponse) { + t.Helper() + if len(resp.Accepted) != fixture.Want.Accepted || len(resp.Rejected) != fixture.Want.Rejected || len(resp.Conflicts) != fixture.Want.Conflicts { + t.Fatalf("%s counts accepted/rejected/conflicts = %d/%d/%d, want %d/%d/%d: %+v", + fixture.Name, + len(resp.Accepted), len(resp.Rejected), len(resp.Conflicts), + fixture.Want.Accepted, fixture.Want.Rejected, fixture.Want.Conflicts, + resp) + } +} + +func fixtureEvents(t *testing.T, fixtures []syncABIEventFixture) []eventmodel.EventEnvelope { + t.Helper() + events := make([]eventmodel.EventEnvelope, 0, len(fixtures)) + for _, fixture := range fixtures { + material := testMaterial(fixture.OriginReplicaID, fixture.LocalDecisionID, fixture.ResourceRef, fixture.Fields) + if fixture.CorruptDigest { + material.FieldsDigest = "corrupt" + } + events = append(events, testSyncEvent(t, material)) + } + return events +} diff --git a/harness/testdata/mnemonhub/sync-abi/auth-invalid.json b/harness/testdata/mnemonhub/sync-abi/auth-invalid.json new file mode 100644 index 00000000..fdc3d6f6 --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/auth-invalid.json @@ -0,0 +1,8 @@ +{ + "name": "auth-invalid", + "kind": "http_auth", + "route": "/sync/push", + "method": "POST", + "token": "wrong-token", + "want": {"status_code": 401} +} diff --git a/harness/testdata/mnemonhub/sync-abi/auth-missing.json b/harness/testdata/mnemonhub/sync-abi/auth-missing.json new file mode 100644 index 00000000..c183b5e0 --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/auth-missing.json @@ -0,0 +1,8 @@ +{ + "name": "auth-missing", + "kind": "http_auth", + "route": "/sync/push", + "method": "POST", + "token": "", + "want": {"status_code": 401} +} diff --git a/harness/testdata/mnemonhub/sync-abi/pull-after-cursor.json b/harness/testdata/mnemonhub/sync-abi/pull-after-cursor.json new file mode 100644 index 00000000..04ea07af --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/pull-after-cursor.json @@ -0,0 +1,20 @@ +{ + "name": "pull-after-cursor", + "kind": "pull", + "principal": "replica-b@team", + "replica_id": "local-b", + "grant_scopes": [ + {"kind": "memory", "id": "project"} + ], + "seed_principal": "replica-a@team", + "seed_replica_id": "local-a", + "seed_events": [ + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-mem", + "resource_ref": {"kind": "memory", "id": "project"}, + "fields": {"content": "memory event"} + } + ], + "want": {"events": 1, "after_cursor_events": 0} +} diff --git a/harness/testdata/mnemonhub/sync-abi/pull-excludes-origin.json b/harness/testdata/mnemonhub/sync-abi/pull-excludes-origin.json new file mode 100644 index 00000000..02f325a6 --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/pull-excludes-origin.json @@ -0,0 +1,20 @@ +{ + "name": "pull-excludes-origin", + "kind": "pull", + "principal": "replica-a@team", + "replica_id": "local-a", + "grant_scopes": [ + {"kind": "memory", "id": "project"} + ], + "seed_principal": "replica-a@team", + "seed_replica_id": "local-a", + "seed_events": [ + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-origin", + "resource_ref": {"kind": "memory", "id": "project"}, + "fields": {"content": "origin event"} + } + ], + "want": {"events": 0} +} diff --git a/harness/testdata/mnemonhub/sync-abi/push-conflict.json b/harness/testdata/mnemonhub/sync-abi/push-conflict.json new file mode 100644 index 00000000..fefbcec8 --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/push-conflict.json @@ -0,0 +1,24 @@ +{ + "name": "push-conflict", + "kind": "push_conflict", + "principal": "replica-a@team", + "replica_id": "local-a", + "grant_scopes": [ + {"kind": "memory", "id": "project"} + ], + "events": [ + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-conflict", + "resource_ref": {"kind": "memory", "id": "project"}, + "fields": {"content": "original body"} + }, + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-conflict", + "resource_ref": {"kind": "memory", "id": "project"}, + "fields": {"content": "same idempotency key, different body"} + } + ], + "want": {"accepted": 0, "rejected": 0, "conflicts": 1} +} diff --git a/harness/testdata/mnemonhub/sync-abi/push-malformed.json b/harness/testdata/mnemonhub/sync-abi/push-malformed.json new file mode 100644 index 00000000..b485a58a --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/push-malformed.json @@ -0,0 +1,19 @@ +{ + "name": "push-malformed", + "kind": "push", + "principal": "replica-a@team", + "replica_id": "local-a", + "grant_scopes": [ + {"kind": "memory", "id": "project"} + ], + "events": [ + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-malformed", + "resource_ref": {"kind": "memory", "id": "project"}, + "fields": {"content": "bad digest"}, + "corrupt_digest": true + } + ], + "want": {"accepted": 0, "rejected": 1, "conflicts": 0} +} diff --git a/harness/testdata/mnemonhub/sync-abi/push-out-of-scope.json b/harness/testdata/mnemonhub/sync-abi/push-out-of-scope.json new file mode 100644 index 00000000..9accc199 --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/push-out-of-scope.json @@ -0,0 +1,18 @@ +{ + "name": "push-out-of-scope", + "kind": "push", + "principal": "replica-a@team", + "replica_id": "local-a", + "grant_scopes": [ + {"kind": "memory", "id": "project"} + ], + "events": [ + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-skill", + "resource_ref": {"kind": "skill", "id": "project"}, + "fields": {"name": "project"} + } + ], + "want": {"accepted": 0, "rejected": 1, "conflicts": 0} +} diff --git a/harness/testdata/mnemonhub/sync-abi/push-replay.json b/harness/testdata/mnemonhub/sync-abi/push-replay.json new file mode 100644 index 00000000..f5de2d5b --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/push-replay.json @@ -0,0 +1,18 @@ +{ + "name": "push-replay", + "kind": "push_replay", + "principal": "replica-a@team", + "replica_id": "local-a", + "grant_scopes": [ + {"kind": "memory", "id": "project"} + ], + "events": [ + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-replay", + "resource_ref": {"kind": "memory", "id": "project"}, + "fields": {"content": "replayed memory"} + } + ], + "want": {"accepted": 1, "rejected": 0, "conflicts": 0, "stored_events": 1} +} diff --git a/harness/testdata/mnemonhub/sync-abi/push-valid.json b/harness/testdata/mnemonhub/sync-abi/push-valid.json new file mode 100644 index 00000000..8200c4ac --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/push-valid.json @@ -0,0 +1,18 @@ +{ + "name": "push-valid", + "kind": "push", + "principal": "replica-a@team", + "replica_id": "local-a", + "grant_scopes": [ + {"kind": "memory", "id": "project"} + ], + "events": [ + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-valid", + "resource_ref": {"kind": "memory", "id": "project"}, + "fields": {"content": "hub accepted memory"} + } + ], + "want": {"accepted": 1, "rejected": 0, "conflicts": 0} +} diff --git a/harness/testdata/mnemonhub/sync-abi/status-ok.json b/harness/testdata/mnemonhub/sync-abi/status-ok.json new file mode 100644 index 00000000..02882e3d --- /dev/null +++ b/harness/testdata/mnemonhub/sync-abi/status-ok.json @@ -0,0 +1,20 @@ +{ + "name": "status-ok", + "kind": "status", + "principal": "replica-a@team", + "replica_id": "local-a", + "grant_scopes": [ + {"kind": "memory", "id": "project"} + ], + "seed_principal": "replica-a@team", + "seed_replica_id": "local-a", + "seed_events": [ + { + "origin_replica_id": "local-a", + "local_decision_id": "dec-status", + "resource_ref": {"kind": "memory", "id": "project"}, + "fields": {"content": "counted"} + } + ], + "want": {"hub_events_received": 1} +} From 4cd3b7aa2988f109481340dd9d4a5d72894d13e9 Mon Sep 17 00:00:00 2001 From: Grivn Date: Thu, 2 Jul 2026 02:09:57 +0800 Subject: [PATCH 3/9] refactor: remove GitHubMesh exchange path Remove GitHub as a first-party harness Remote Workspace backend, product connection, sync CLI mode, daemon role, and acceptance path. The remaining sync path is HTTP MnemonHub only, with guard coverage to keep GitHubMesh from re-entering the production harness surface. Validation: go test ./harness/internal/mnemonhub/... ./harness/internal/app ./harness/internal/productconfig ./harness/internal/daemon ./harness/internal/coreguard ./harness/cmd/mnemon-harness ./harness/cmd/mnemon-acceptance; harness/scripts/r3-1/run.sh --phase mnemonhub-contract. --- harness/README.md | 39 +- harness/cmd/mnemon-acceptance/acceptance.go | 2 +- .../acceptance_github_mesh.go | 1711 ----------------- .../acceptance_github_mesh_test.go | 609 ------ .../acceptance_managed_runtime.go | 130 +- .../acceptance_managed_runtime_test.go | 8 +- harness/cmd/mnemon-acceptance/root_test.go | 1 - harness/cmd/mnemon-acceptance/sync_remote.go | 4 +- .../cmd/mnemon-harness/config_daemon_test.go | 20 +- harness/cmd/mnemon-harness/connect.go | 36 +- harness/cmd/mnemon-harness/doctor.go | 3 - harness/cmd/mnemon-harness/doctor_test.go | 3 +- harness/cmd/mnemon-harness/sync.go | 40 +- harness/cmd/mnemon-harness/sync_test.go | 53 +- harness/internal/app/sync_github_live_test.go | 188 -- harness/internal/app/sync_github_mesh_test.go | 278 --- harness/internal/app/sync_worker.go | 52 +- harness/internal/app/sync_worker_test.go | 8 +- .../github_remote_workspace_guard_test.go | 200 -- .../remote_workspace_no_github_guard_test.go | 126 ++ harness/internal/daemon/configured_test.go | 2 +- .../exchange/backend/github/backend.go | 232 --- .../exchange/backend/github/backend_test.go | 246 --- .../backend/github/publication_store.go | 616 ------ .../backend/github/publication_store_test.go | 489 ----- .../mnemonhub/exchange/publication_store.go | 4 +- .../mnemonhub/exchange/remote_workspace.go | 4 +- .../internal/mnemonhub/exchange/remotes.go | 55 +- .../mnemonhub/exchange/remotes_test.go | 59 +- harness/internal/productconfig/config.go | 29 - harness/internal/productconfig/config_test.go | 6 +- 31 files changed, 206 insertions(+), 5047 deletions(-) delete mode 100644 harness/cmd/mnemon-acceptance/acceptance_github_mesh.go delete mode 100644 harness/cmd/mnemon-acceptance/acceptance_github_mesh_test.go delete mode 100644 harness/internal/app/sync_github_live_test.go delete mode 100644 harness/internal/app/sync_github_mesh_test.go delete mode 100644 harness/internal/coreguard/github_remote_workspace_guard_test.go create mode 100644 harness/internal/coreguard/remote_workspace_no_github_guard_test.go delete mode 100644 harness/internal/mnemonhub/exchange/backend/github/backend.go delete mode 100644 harness/internal/mnemonhub/exchange/backend/github/backend_test.go delete mode 100644 harness/internal/mnemonhub/exchange/backend/github/publication_store.go delete mode 100644 harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go diff --git a/harness/README.md b/harness/README.md index 8c96f716..9d11115f 100644 --- a/harness/README.md +++ b/harness/README.md @@ -10,7 +10,7 @@ The current product surface is intentionally small: - `status` reports Agent Integration, Local Mnemon, and sync status. - `sync` connects Local Mnemon to a Remote Workspace and pushes/pulls governed commits with attribution preserved. The first-party backend is `mnemon-hub`; - the experimental GitHub backend uses repo-mediated publication branches. + future backends must preserve the same EventEnvelope sync contract. - `loop validate` remains hidden and is used by `make harness-validate`. Host directories such as `.codex` and `.claude` are projection surfaces. Runtime @@ -49,40 +49,3 @@ Remove projected assets for a principal: ``` More command examples are in `docs/harness/USAGE.md`. - -## Experimental GitHub Remote Workspace - -GitHub can be used as a bootstrap Remote Workspace backend for a decentralized -publication mesh. Each Local Mnemon publishes accepted synced events to its own -configured branch and subscribes to explicitly configured peer branches. - -This is not P2P networking or GitHub Issues/PR-based teamwork. GitHub is only the -publication substrate; every imported event still enters through the receiving -Local Mnemon's Event Intake. - -Example shape: - -```sh -./mnemon-harness sync connect self \ - --backend github \ - --direction publish \ - --github-repo mnemon-dev/mnemon-teamwork-example \ - --github-branch mnemon/agent-a \ - --token-file ~/.config/mnemon/github.token - -./mnemon-harness sync connect agent-b \ - --backend github \ - --direction subscribe \ - --github-repo mnemon-dev/mnemon-teamwork-example \ - --github-branch mnemon/agent-b \ - --token-file ~/.config/mnemon/github.token -``` - -Live validation is opt-in: - -```sh -MNEMON_GITHUB_LIVE=1 \ -MNEMON_GITHUB_REPO=mnemon-dev/mnemon-teamwork-example \ -MNEMON_GITHUB_TOKEN_FILE=~/.config/mnemon/github.token \ -go test ./harness/internal/app -run TestGitHubLivePublishPullImport -count=1 -v -``` diff --git a/harness/cmd/mnemon-acceptance/acceptance.go b/harness/cmd/mnemon-acceptance/acceptance.go index d3db9968..65d0f83b 100644 --- a/harness/cmd/mnemon-acceptance/acceptance.go +++ b/harness/cmd/mnemon-acceptance/acceptance.go @@ -1185,7 +1185,7 @@ func setupR1CodexSyncAgents(ctx context.Context, runRoot, binDir string, hub r1S if i-1 >= len(hub.Tokens) { return nil, fmt.Errorf("hub token missing for agent %d", i) } - if err := upsertSyncRemote(filepath.Join(workspace, ".mnemon", "harness", "sync", "remotes.json"), workspace, "hub", exchange.RemoteBackendHTTP, "", hub.URL, "", "", hub.Tokens[i-1], "", ""); err != nil { + if err := upsertSyncRemote(filepath.Join(workspace, ".mnemon", "harness", "sync", "remotes.json"), workspace, "hub", exchange.RemoteBackendHTTP, "", hub.URL, hub.Tokens[i-1], "", ""); err != nil { return nil, err } loaded, err := access.LoadBindingFile(workspace, filepath.Join(workspace, access.DefaultBindingFile)) diff --git a/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go b/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go deleted file mode 100644 index e6c84342..00000000 --- a/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go +++ /dev/null @@ -1,1711 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "sort" - "strings" - "time" - - "github.com/mnemon-dev/mnemon/harness/internal/app" - "github.com/mnemon-dev/mnemon/harness/internal/codexapp" - "github.com/mnemon-dev/mnemon/harness/internal/contract" - "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" - "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" - githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" - "github.com/mnemon-dev/mnemon/harness/internal/runtime" - "github.com/spf13/cobra" -) - -var ( - acceptanceGitHubRepo string - acceptanceGitHubTokenFile string - acceptanceGitHubBranchPrefix string - acceptanceGitHubScenarios []string - acceptanceGitHubSyncInterval time.Duration -) - -var ( - r1GitHubMeshRateLimitAPIURL = "https://api.github.com/rate_limit" - r1GitHubMeshAPIBaseURL = "https://api.github.com" - r1GitHubMeshHTTPClient = &http.Client{Timeout: 10 * time.Second} -) - -var acceptanceR1GitHubMeshCmd = &cobra.Command{ - Use: "r1-github-mesh-task-suite", - Short: "Run GitHub-backed Remote Workspace task-suite acceptance", - RunE: func(cmd *cobra.Command, args []string) error { - report, err := runR1GitHubMeshAcceptance(cmd.Context(), r1GitHubMeshAcceptanceOptions{ - r1CodexAcceptanceOptions: r1CodexAcceptanceOptions{ - RunRoot: acceptanceRunRoot, - Command: acceptanceCommand, - CodexHome: acceptanceCodexHome, - Agents: acceptanceAgents, - AgentTurns: acceptanceAgentTurns, - TurnTimeout: acceptanceTurnTimeout, - Stdout: cmd.OutOrStdout(), - Stderr: cmd.ErrOrStderr(), - }, - Repo: acceptanceGitHubRepo, - TokenFile: acceptanceGitHubTokenFile, - BranchPrefix: acceptanceGitHubBranchPrefix, - Scenarios: acceptanceGitHubScenarios, - SyncInterval: acceptanceGitHubSyncInterval, - }) - if report.ReportPath != "" { - fmt.Fprintf(cmd.OutOrStdout(), "acceptance report: %s\n", report.ReportPath) - } - if err != nil { - return err - } - if report.Status != "ok" { - return fmt.Errorf("GitHub mesh task-suite acceptance status: %s", report.Status) - } - return nil - }, -} - -const r1GitHubMeshWorkerWakePrompt = `Check your Mnemon context. If there is governed work for you, act on it through -your own Local Mnemon and record durable progress. If your focus, availability, -or working state changed, update your agent_profile through your own Local Mnemon. -If there is no work for you, answer "no governed work".` - -func init() { - acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceRunRoot, "run-root", "", "acceptance run directory") - acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceCommand, "command", "codex --dangerously-bypass-hook-trust", "Codex CLI command") - acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceCodexHome, "codex-home-source", "", "source CODEX_HOME to copy auth/config from") - acceptanceR1GitHubMeshCmd.Flags().IntVar(&acceptanceAgents, "agents", 5, "number of Codex appservers") - acceptanceR1GitHubMeshCmd.Flags().BoolVar(&acceptanceAgentTurns, "agent-turns", false, "run real model turns that write governed GitHub mesh events") - acceptanceR1GitHubMeshCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per real agent turn") - acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubRepo, "github-repo", "mnemon-dev/mnemon-teamwork-example", "GitHub Remote Workspace repository (owner/name)") - acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubTokenFile, "github-token-file", "", "GitHub token file for publication store access") - acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubBranchPrefix, "github-branch-prefix", "", "GitHub publication branch prefix; empty uses a run-scoped mnemond id prefix") - acceptanceR1GitHubMeshCmd.Flags().StringArrayVar(&acceptanceGitHubScenarios, "scenario", nil, "natural scenario to run; repeatable") - acceptanceR1GitHubMeshCmd.Flags().DurationVar(&acceptanceGitHubSyncInterval, "sync-interval", 30*time.Second, "GitHub sync interval per local mnemond") - rootCmd.AddCommand(acceptanceR1GitHubMeshCmd) -} - -type r1GitHubMeshAcceptanceOptions struct { - r1CodexAcceptanceOptions - Repo string - TokenFile string - BranchPrefix string - Scenarios []string - SyncInterval time.Duration -} - -func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceOptions) (r1CodexAcceptanceReport, error) { - if opts.Stdout == nil { - opts.Stdout = io.Discard - } - if opts.Stderr == nil { - opts.Stderr = io.Discard - } - if opts.Command == "" { - opts.Command = "codex" - } - if opts.Agents < 5 { - opts.Agents = 5 - } - if opts.TurnTimeout <= 0 { - opts.TurnTimeout = 5 * time.Minute - } - if opts.Repo == "" { - opts.Repo = "mnemon-dev/mnemon-teamwork-example" - } - if opts.SyncInterval <= 0 { - opts.SyncInterval = 30 * time.Second - } - started := time.Now().UTC().Truncate(time.Second) - branchPrefix := r1GitHubMeshBranchPrefix(opts.BranchPrefix, started) - runRoot := opts.RunRoot - if runRoot == "" { - runRoot = filepath.Join(".testdata", "r1-github-mesh-task-suite", started.Format("20060102T150405Z")) - } - runRoot, err := filepath.Abs(runRoot) - if err != nil { - return r1CodexAcceptanceReport{}, err - } - report := r1CodexAcceptanceReport{ - SchemaVersion: 1, - Status: "running", - StartedAt: started.Format(time.RFC3339), - RunRoot: runRoot, - Scenario: "github-mesh-task-suite", - AgentTurns: opts.AgentTurns, - LedgerCounts: map[string]int{}, - DerivedEventAudit: map[string]int{}, - Artifacts: map[string]string{}, - Raw: map[string]json.RawMessage{}, - RunnerContract: &r1RunnerContractReport{ - EntrypointProgressBeforeIntegration: -1, - EntrypointProgressAfterIntegration: -1, - WorkerWakePrompt: r1GitHubMeshWorkerWakePrompt, - }, - } - reportPath := filepath.Join(runRoot, "report.json") - report.ReportPath = reportPath - defer func() { - report.FinishedAt = time.Now().UTC().Truncate(time.Second).Format(time.RFC3339) - _ = os.MkdirAll(filepath.Dir(reportPath), 0o755) - data, _ := json.MarshalIndent(report, "", " ") - _ = os.WriteFile(reportPath, append(data, '\n'), 0o644) - }() - if err := prepareR1AcceptanceRunRoot(runRoot); err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - if err := validateR1GitHubMeshSyncInterval(opts); err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - if opts.TokenFile == "" { - err := fmt.Errorf("--github-token-file is required") - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - tokenFile, err := filepath.Abs(opts.TokenFile) - if err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - if _, err := os.Stat(tokenFile); err != nil { - err = fmt.Errorf("github token file: %w", err) - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - report.Artifacts["github_repo"] = opts.Repo - report.Artifacts["github_token_file"] = tokenFile - rateLimit, err := preflightR1GitHubMeshRateLimit(ctx, tokenFile, r1GitHubMeshMinimumRateLimitRemaining(opts)) - if rateLimit.Limit > 0 || !rateLimit.ResetAt.IsZero() { - report.Artifacts["github_rate_limit_remaining"] = fmt.Sprintf("%d", rateLimit.Remaining) - report.Artifacts["github_rate_limit_limit"] = fmt.Sprintf("%d", rateLimit.Limit) - report.Artifacts["github_rate_limit_reset"] = rateLimit.ResetAt.UTC().Format(time.RFC3339) - } - if err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - if err := preflightR1GitHubMeshRepositoryAccess(ctx, opts.Repo, tokenFile); err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - branches := r1GitHubMeshBranches(branchPrefix, opts.Agents) - if err := ensureR1GitHubMeshBranches(ctx, opts.Repo, tokenFile, branches); err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - binDir, err := installAcceptanceHarnessBinary(runRoot) - if err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - sourceCodexHome := resolveSourceCodexHome(opts.CodexHome) - report.Artifacts["codex_home_source"] = sourceCodexHome - report.Artifacts["github_branch_prefix"] = branchPrefix - report.Artifacts["github_sync_interval"] = opts.SyncInterval.String() - initialOnline := opts.Agents - if opts.AgentTurns { - initialOnline = r1GitHubMeshInitialOnline(opts.Agents) - } - report.Artifacts["github_initial_online_agents"] = fmt.Sprintf("%d", initialOnline) - - agents, err := setupR1CodexGitHubMeshAgents(ctx, runRoot, binDir, opts.Repo, tokenFile, branchPrefix, opts.Agents, sourceCodexHome, opts.SyncInterval, initialOnline) - if err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - defer stopR1CodexSyncAgents(agents) - report.Topology = buildR1ProdSimTopology(agents) - report.Topology.MnemonhubInstances = 0 - addR1Assertion(&report, "github-mesh strict per-hostagent mnemond topology", r1GitHubMeshStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) - - syncReport := buildR1GitHubMeshSyncReport(opts.Repo, agents) - report.Sync = syncReport - for _, agent := range agents { - report.Artifacts["mnemond:"+agent.principal] = prodSimMnemondPath(agent) - report.Artifacts["render_audit:"+agent.principal] = agent.renderAuditPath - } - addR1Assertion(&report, "github-mesh no central mnemon-hub endpoint", syncReport.HubURL == "" && syncReport.HubStatus.HubEventsReceived == 0, "backend=github repo-mediated publication") - addR1Assertion(&report, "github-mesh no p2p node discovery", syncReport.NetworkDiscovery == "none" && syncReport.RosterSource == "configured-remotes-json", fmt.Sprintf("roster_source=%s network_discovery=%s", syncReport.RosterSource, syncReport.NetworkDiscovery)) - addR1Assertion(&report, "github-mesh publication branches configured", len(syncReport.PublicationBranches) == opts.Agents && len(syncReport.BranchByAgent) == opts.Agents, fmt.Sprintf("branches=%v", syncReport.PublicationBranches)) - addR1Assertion(&report, "github-mesh remote plans are per-workspace", distinctStrings(syncReport.RemotePlanPaths) && len(syncReport.RemotePlanPaths) == opts.Agents, fmt.Sprintf("remote_plans=%v", syncReport.RemotePlanPaths)) - addR1Assertion(&report, "github-mesh local stores isolated", distinctStrings(syncReport.LocalStorePaths) && len(syncReport.LocalStorePaths) == opts.Agents, fmt.Sprintf("stores=%v", syncReport.LocalStorePaths)) - addR1Assertion(&report, "github-mesh runtime workspaces isolated", distinctStrings(syncReport.RuntimeWorkspaces) && len(syncReport.RuntimeWorkspaces) == opts.Agents, fmt.Sprintf("workspaces=%v", syncReport.RuntimeWorkspaces)) - - if !opts.AgentTurns { - addR1Assertion(&report, "github-mesh real agent turns requested", false, "rerun with --agent-turns") - report.Status = "failed" - return report, fmt.Errorf("GitHub mesh task-suite acceptance requires --agent-turns") - } - for _, i := range r1GitHubMeshLocalOnlineIndexes(agents) { - if err := startR1CodexAppserver(&agents[i].r1CodexAgent, opts.Command); err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - agentReport, raw, err := initializeR1CodexAgent(&agents[i].r1CodexAgent, opts.TurnTimeout) - if err != nil { - addR1Error(&report, err) - report.Status = "blocked" - return report, err - } - report.Agents = append(report.Agents, agentReport) - syncReport.Agents = append(syncReport.Agents, agentReport) - if raw != nil { - report.Raw[agents[i].principal+":hooks"] = raw - } - } - addR1Assertion(&report, "github-mesh initial appservers start/init", len(report.Agents) == initialOnline, fmt.Sprintf("started=%d initial_online=%d requested=%d", len(report.Agents), initialOnline, opts.Agents)) - - run := r1GitHubMeshRun{ - ctx: ctx, - opts: opts, - report: &report, - agents: agents, - runID: started.Format("150405"), - initialOnline: initialOnline, - } - if err := run.bootstrapProfiles(); err != nil { - addR1Error(&report, err) - } - for _, name := range r1GitHubMeshScenarioNames(opts.Scenarios) { - if err := run.runScenario(name); err != nil { - addR1Error(&report, err) - } - } - addR1Assertion(&report, "github-mesh 5/5 appservers start/init", len(report.Agents) == opts.Agents, fmt.Sprintf("started=%d requested=%d", len(report.Agents), opts.Agents)) - addR1Assertion(&report, "github-mesh delayed mnemond join exercised", run.joined || initialOnline == opts.Agents, fmt.Sprintf("initial_online=%d total=%d lifecycle=%v", initialOnline, opts.Agents, syncReport.Lifecycle)) - addR1Assertion(&report, "github-mesh local mnemond leave/restart exercised", run.lifecycleExercised, fmt.Sprintf("lifecycle=%v", syncReport.Lifecycle)) - run.addPostScenarioAssertions() - obs, obsErr := observeAcceptanceRun(runRoot, 1000) - if obsErr == nil { - report.Observability = &obs - populateR1GitHubMeshSyncEvidence(&report, obs) - counts, warnings := r1GitHubMeshAuthoredEventCounts(agents) - if len(counts) == 0 { - counts = r1ClusterActorEventCounts(obs) - } - if len(warnings) > 0 { - report.Observability.Warnings = append(report.Observability.Warnings, warnings...) - } - report.Participants = r1ClusterParticipants(counts, report.Entrypoint) - ok, detail := acceptedR2PayloadShapeAssertion(obs) - addR1Assertion(&report, "github-mesh accepted event payloads are R2 nested", ok, detail) - } else { - addR1Error(&report, obsErr) - addR1Assertion(&report, "github-mesh accepted event payloads are R2 nested", false, obsErr.Error()) - } - report.DerivedEventAudit = prodSimDerivedAudit(agents) - if len(agents) > 0 { - report.LedgerCounts = countR1Ledger(agents[0].localURL, agents[0].r1CodexAgent) - } - addR1Assertion(&report, "github-mesh no shared governed.db", r1GitHubMeshStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) - addR1Assertion(&report, "github-mesh accepted event subjects only", r1SyncEventSubjectsOnlyAccepted(syncReport.AllowedEventSubjects), fmt.Sprintf("subjects=%v", syncReport.AllowedEventSubjects)) - addR1Assertion(&report, "github-mesh report includes publication/import evidence", len(syncReport.PublishedByBranch) == opts.Agents && len(syncReport.ImportedByMnemond) == opts.Agents, fmt.Sprintf("published=%v imported=%v diagnostics=%v", syncReport.PublishedByBranch, syncReport.ImportedByMnemond, syncReport.DiagnosticsByMnemond)) - if len(report.Errors) == 0 && allR1AssertionsPassed(report.Assertions) && allR1GitHubMeshScenariosOK(report.Scenarios, opts.Scenarios) { - syncReport.Status = "ok" - report.Status = "ok" - return report, nil - } - syncReport.Status = "failed" - report.Status = "failed" - return report, fmt.Errorf("GitHub mesh natural task suite failed") -} - -type r1GitHubMeshRun struct { - ctx context.Context - opts r1GitHubMeshAcceptanceOptions - report *r1CodexAcceptanceReport - agents []r1CodexSyncAgent - runID string - initialOnline int - joined bool - lifecycleExercised bool -} - -func r1GitHubMeshScenarioNames(selected []string) []string { - if len(selected) == 0 { - return []string{"onboarding-synthesis", "sync-risk-review", "live-readiness-operator-safety"} - } - out := make([]string, 0, len(selected)) - for _, name := range selected { - name = strings.TrimSpace(name) - if name != "" { - out = append(out, name) - } - } - return out -} - -func allR1GitHubMeshScenariosOK(scenarios []r1TaskSimScenarioReport, selected []string) bool { - want := map[string]bool{} - for _, name := range r1GitHubMeshScenarioNames(selected) { - want[name] = false - } - for _, scenario := range scenarios { - if _, ok := want[scenario.Name]; ok && scenario.Status == "ok" { - want[scenario.Name] = true - } - } - if len(want) == 0 { - return false - } - for _, ok := range want { - if !ok { - return false - } - } - return true -} - -func r1GitHubMeshScenarioSelected(selected []string, name string) bool { - for _, selectedName := range selected { - if selectedName == name { - return true - } - } - return false -} - -func r1GitHubMeshOKScenarioNames(scenarios []r1TaskSimScenarioReport) []string { - out := []string{} - for _, scenario := range scenarios { - if scenario.Status == "ok" && strings.TrimSpace(scenario.Name) != "" { - out = append(out, scenario.Name) - } - } - sort.Strings(out) - return out -} - -func r1GitHubMeshCrossTaskReuseCandidate(name string, priorOK []string) bool { - if name != "sync-risk-review" { - return false - } - return r1GitHubMeshScenarioSelected(priorOK, "onboarding-synthesis") -} - -func r1GitHubMeshHasOKScenarioEvidenceBool(scenarios []r1TaskSimScenarioReport, name, key string) bool { - for _, scenario := range scenarios { - if scenario.Name != name || scenario.Status != "ok" { - continue - } - if value, ok := scenario.Evidence[key].(bool); ok && value { - return true - } - } - return false -} - -func r1GitHubMeshHasAnyOKScenarioEvidenceIntAtLeast(scenarios []r1TaskSimScenarioReport, key string, min int) bool { - for _, scenario := range scenarios { - if scenario.Status != "ok" { - continue - } - switch value := scenario.Evidence[key].(type) { - case int: - if value >= min { - return true - } - case float64: - if int(value) >= min { - return true - } - } - } - return false -} - -func (s *r1GitHubMeshRun) bootstrapProfiles() error { - profileCounts := map[string]int{} - active := r1GitHubMeshReadyAgentIndexes(s.agents) - if len(active) == 0 { - return fmt.Errorf("github mesh profile bootstrap requires at least one online agent") - } - for _, i := range active { - agent := &s.agents[i] - payload := taskSimJSON(map[string]any{ - "rule": map[string]any{ - "actor": agent.principal, - "availability": "available", - "ttl": "30m", - }, - "narrative": map[string]any{ - "focus": fmt.Sprintf("GitHub mesh Remote Workspace acceptance node %s", agent.principal), - "context_advantages": []string{"isolated local mnemond", "github publication branch sync", "real Codex appserver turn"}, - "summary": fmt.Sprintf("%s is available for GitHub mesh teamwork validation.", agent.principal), - }, - }) - prompt := fmt.Sprintf(`Emit exactly one agent_profile.write_candidate.observed event through your own Local Mnemon. -Use external id github-mesh-profile-%s-%s and payload: -%s -After the command succeeds, answer "profile written".`, s.runID, prodSafeID(agent.principal), payload) - recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "profile_bootstrap", prompt) - s.report.RunnerContract.ProfileBootstrapPrompts++ - answer, err := runR1Turn(&agent.r1CodexAgent, prompt, s.opts.TurnTimeout) - appendSyncAgentAnswer(s.report.Sync, agent.principal, answer) - if err != nil { - addR1Assertion(s.report, "github-mesh profile emitted "+agent.principal, false, err.Error()) - return err - } - waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", 1, 20*time.Second) - profileCounts[agent.principal] = countR1Ledger(agent.localURL, agent.r1CodexAgent)["agent_profile"] - } - allVisible := true - for _, i := range active { - agent := s.agents[i] - waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", len(active), 120*time.Second) - counts := countR1Ledger(agent.localURL, agent.r1CodexAgent) - profileCounts[agent.principal] = counts["agent_profile"] - if counts["agent_profile"] < len(active) { - allVisible = false - } - } - addR1Assertion(s.report, "github-mesh initial profiles converge through publication branches", allVisible, fmt.Sprintf("initial_online_agents=%d", len(active))) - s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{ - Name: "bootstrap_profiles", - Status: statusFromBool(allVisible), - Evidence: map[string]any{ - "profile_counts_by_agent": profileCounts, - "initial_online_agents": len(active), - "publication_branches": s.report.Sync.PublicationBranches, - }, - }) - if !allVisible { - return fmt.Errorf("profiles did not converge through GitHub publication branches") - } - return nil -} - -func (s *r1GitHubMeshRun) runScenario(name string) error { - entries, err := s.scenarioEntries(name) - if err != nil { - s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{Name: name, Status: "blocked", Evidence: map[string]any{"error": err.Error()}}) - return err - } - var actors []string - var entryTurns []*r1GitHubMeshEntryTurn - for _, entry := range entries { - agent := &s.agents[entry.index] - actors = append(actors, agent.principal) - s.report.RunnerContract.BusinessTaskPrompts++ - if s.report.Entrypoint == "" { - s.report.Entrypoint = agent.principal - s.report.Starter = agent.principal - s.report.Sync.Source = agent.principal - } - recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "natural_user_message:"+name, entry.prompt) - turn, err := startR1GitHubMeshEntryTurn(agent, entry.index, entry.prompt, s.opts.TurnTimeout) - if err != nil { - addR1Assertion(s.report, "github-mesh "+name+" entry "+agent.principal, false, err.Error()) - return err - } - entryTurns = append(entryTurns, turn) - } - seedTimeout := r1GitHubMeshEntrySeedTimeout(s.opts.TurnTimeout) - for _, turn := range entryTurns { - agent := &s.agents[turn.index] - turn.counts, turn.seeded = waitR1GitHubMeshEntrySeed(agent, seedTimeout) - if res, ok := turn.poll(); ok { - appendR1GitHubMeshEntryTurnAnswer(s.report.Sync, turn, res) - if res.Err != nil && !turn.seeded { - addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal, false, fmt.Sprintf("%v counts=%v", res.Err, turn.counts)) - return res.Err - } - } - if !turn.seeded { - err := fmt.Errorf("%s did not publish governed seed events within %s", turn.principal, seedTimeout) - addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal, false, fmt.Sprintf("%v counts=%v", err, turn.counts)) - return err - } - addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal, true, fmt.Sprintf("seeded governed teamwork events counts=%v", turn.counts)) - } - if err := s.wakeWorkers(name, entries); err != nil { - return err - } - priorScenarios := r1GitHubMeshOKScenarioNames(s.report.Scenarios) - busyEntries := map[int]bool{} - for _, turn := range entryTurns { - if res, ok := turn.poll(); ok { - appendR1GitHubMeshEntryTurnAnswer(s.report.Sync, turn, res) - if res.Err != nil { - addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal+" yielded governed seed before timeout", turn.seeded, fmt.Sprintf("%v counts=%v", res.Err, turn.counts)) - if turn.seeded { - if err := s.restartAgentAppserver(turn.index, name, "entry turn timed out after publishing governed seed events"); err != nil { - return err - } - } - } - continue - } - busyEntries[turn.index] = true - } - leadIndex := r1GitHubMeshIntegrationAgentIndex(s.agents, entries, busyEntries) - if leadIndex < 0 { - return fmt.Errorf("github mesh scenario %s has no idle integration agent", name) - } - lead := &s.agents[leadIndex] - integrationPrompt := r1GitHubMeshIntegrationPrompt(name) - s.report.RunnerContract.IntegrationPrompts++ - recordR1ClusterPrompt(s.report.RunnerContract, lead.principal, "integration:"+name, integrationPrompt) - answer, err := runR1Turn(&lead.r1CodexAgent, integrationPrompt, s.opts.TurnTimeout) - appendSyncAgentAnswer(s.report.Sync, lead.principal, answer) - if err != nil { - addR1Assertion(s.report, "github-mesh "+name+" integration", false, err.Error()) - return err - } - for _, turn := range entryTurns { - if turn.pollReady() { - res := turn.wait() - appendR1GitHubMeshEntryTurnAnswer(s.report.Sync, turn, res) - if res.Err != nil && turn.seeded { - addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal+" completed team handoff despite timeout", true, fmt.Sprintf("%v counts=%v", res.Err, turn.counts)) - } - continue - } - if turn.seeded { - addR1Assertion(s.report, "github-mesh "+name+" entry "+turn.principal+" handed off while still running", true, fmt.Sprintf("counts=%v", turn.counts)) - if err := s.restartAgentAppserver(turn.index, name, "entry turn still running after team integration"); err != nil { - return err - } - } - } - waitR1ClusterAcceptedEventSettle(s.report.RunRoot, 15*time.Second, 2*time.Second) - obs, err := observeAcceptanceRun(s.report.RunRoot, 1000) - if err != nil { - return err - } - counts, countWarnings := r1GitHubMeshAuthoredEventCounts(s.agents) - if len(counts) == 0 { - counts = r1ClusterActorEventCounts(obs) - } - participants := r1ClusterNonProfileParticipantCount(counts) - replans := r1GitHubMeshPromptRounds(s.report.RunnerContract, name) - naturalMessages := r1GitHubMeshPromptKindCount(s.report.RunnerContract, "natural_user_message:"+name) - workerWakes := r1GitHubMeshPromptKindCount(s.report.RunnerContract, "worker_wake:"+name) - integrationPrompts := r1GitHubMeshPromptKindCount(s.report.RunnerContract, "integration:"+name) - assignments := r1GitHubMeshKindTotal(counts, "assignment") - progress := r1GitHubMeshKindTotal(counts, "progress_digest") - signals := r1GitHubMeshKindTotal(counts, "teamwork_signal") - intents := r1GitHubMeshKindTotal(counts, "project_intent") - passed := participants >= 2 && - replans >= 2 && - naturalMessages == len(entries) && - integrationPrompts >= 1 && - s.report.RunnerContract.DirectWorkerBusinessPrompts == 0 && - assignments >= 1 && - progress >= 1 - addR1Assertion(s.report, "github-mesh "+name+" team-shaped multi-round evidence", passed, fmt.Sprintf("participants=%d rounds=%d natural=%d worker_wakes=%d integration=%d assignments=%d progress_digest=%d teamwork_signal=%d project_intent=%d actors=%v", participants, replans, naturalMessages, workerWakes, integrationPrompts, assignments, progress, signals, intents, counts)) - s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{ - Name: name, - Status: statusFromBool(passed), - Actors: actors, - Evidence: map[string]any{ - "participants": participants, - "replanning_rounds": replans, - "natural_user_messages": naturalMessages, - "worker_wake_prompts": workerWakes, - "integration_prompts": integrationPrompts, - "entry_poc_agents": actors, - "integration_agent": lead.principal, - "multi_poc": len(actors) > 1, - "prior_ok_scenarios": priorScenarios, - "cross_task_reuse_or_completion": r1GitHubMeshCrossTaskReuseCandidate(name, priorScenarios), - "profile_update_prompted": strings.Contains(r1GitHubMeshWorkerWakePrompt, "agent_profile"), - "direct_worker_business": s.report.RunnerContract.DirectWorkerBusinessPrompts, - "shared_appserver_threads": r1GitHubMeshThreadIDs(s.agents), - "cross_scenario_mnemon_ctx": true, - "actor_event_counts": counts, - "assignment_events": assignments, - "progress_digest_events": progress, - "teamwork_signal_events": signals, - "project_intent_events": intents, - }, - }) - if len(countWarnings) > 0 { - s.report.Scenarios[len(s.report.Scenarios)-1].Evidence["actor_event_count_warnings"] = countWarnings - } - if !passed { - return fmt.Errorf("github mesh scenario %s did not produce team-shaped multi-round evidence", name) - } - return nil -} - -func (s *r1GitHubMeshRun) addPostScenarioAssertions() { - profileCounts := r1GitHubMeshLedgerCountsByAgent(s.agents, "agent_profile") - baseline := len(s.agents) - if s.initialOnline > 0 && s.initialOnline < len(s.agents) { - baseline = s.initialOnline - } - refreshed := false - for _, count := range profileCounts { - if count > baseline { - refreshed = true - break - } - } - addR1Assertion(s.report, "github-mesh profiles refresh during work", refreshed, fmt.Sprintf("agent_profile_counts=%v initial_online_agents=%d total_agents=%d", profileCounts, baseline, len(s.agents))) - if s.report.Sync != nil { - if s.report.Raw == nil { - s.report.Raw = map[string]json.RawMessage{} - } - raw, _ := json.Marshal(profileCounts) - s.report.Raw["github_mesh:profile_counts_after_scenarios"] = raw - } - selected := r1GitHubMeshScenarioNames(s.opts.Scenarios) - if r1GitHubMeshScenarioSelected(selected, "live-readiness-operator-safety") { - addR1Assertion(s.report, "github-mesh multi-poc scenario exercised", r1GitHubMeshHasOKScenarioEvidenceBool(s.report.Scenarios, "live-readiness-operator-safety", "multi_poc"), "scenario=live-readiness-operator-safety") - } - if r1GitHubMeshScenarioSelected(selected, "onboarding-synthesis") && r1GitHubMeshScenarioSelected(selected, "sync-risk-review") { - addR1Assertion(s.report, "github-mesh cross-task reuse/completion evidence recorded", r1GitHubMeshHasOKScenarioEvidenceBool(s.report.Scenarios, "sync-risk-review", "cross_task_reuse_or_completion"), "scenario=sync-risk-review prior=onboarding-synthesis") - } - addR1Assertion(s.report, "github-mesh output-driven replanning evidence recorded", r1GitHubMeshHasAnyOKScenarioEvidenceIntAtLeast(s.report.Scenarios, "replanning_rounds", 2), "requires at least one successful natural scenario with two or more prompt rounds") -} - -type r1GitHubMeshScenarioEntry struct { - index int - prompt string -} - -type r1GitHubMeshTurnResult struct { - Answer string - Err error -} - -type r1GitHubMeshEntryTurn struct { - index int - principal string - before int - done chan r1GitHubMeshTurnResult - result *r1GitHubMeshTurnResult - reported bool - seeded bool - counts map[string]int -} - -func startR1GitHubMeshEntryTurn(agent *r1CodexSyncAgent, index int, prompt string, timeout time.Duration) (*r1GitHubMeshEntryTurn, error) { - server := agent.server - before := server.NotificationCount() - if _, err := server.Request("turn/start", map[string]any{ - "threadId": agent.threadID, - "input": []map[string]any{{"type": "text", "text": prompt}}, - "cwd": agent.workspace, - "approvalPolicy": "never", - "sandboxPolicy": map[string]any{"type": "dangerFullAccess"}, - }, 30*time.Second); err != nil { - return nil, fmt.Errorf("%s: turn/start: %w", agent.principal, err) - } - turn := &r1GitHubMeshEntryTurn{ - index: index, - principal: agent.principal, - before: before, - done: make(chan r1GitHubMeshTurnResult, 1), - } - go func() { - if _, err := server.WaitNotification("turn/completed", timeout, before); err != nil { - text := codexapp.CombinedText(server.NotificationsSince(before)) - turn.done <- r1GitHubMeshTurnResult{ - Answer: truncateR1Cluster(text, 2000), - Err: fmt.Errorf("%s: wait turn/completed: %w", agent.principal, err), - } - return - } - notifications := server.NotificationsSince(before) - answer := codexapp.FinalAnswer(notifications) - if answer == "" { - answer = codexapp.CombinedText(notifications) - } - turn.done <- r1GitHubMeshTurnResult{Answer: answer} - }() - return turn, nil -} - -func (t *r1GitHubMeshEntryTurn) poll() (r1GitHubMeshTurnResult, bool) { - if t == nil { - return r1GitHubMeshTurnResult{}, false - } - if t.result != nil { - return *t.result, true - } - select { - case res := <-t.done: - t.result = &res - return res, true - default: - return r1GitHubMeshTurnResult{}, false - } -} - -func (t *r1GitHubMeshEntryTurn) pollReady() bool { - _, ok := t.poll() - return ok -} - -func (t *r1GitHubMeshEntryTurn) wait() r1GitHubMeshTurnResult { - if res, ok := t.poll(); ok { - return res - } - res := <-t.done - t.result = &res - return res -} - -func appendR1GitHubMeshEntryTurnAnswer(report *r1CodexSyncReport, turn *r1GitHubMeshEntryTurn, res r1GitHubMeshTurnResult) { - if report == nil || turn == nil || turn.reported { - return - } - appendSyncAgentAnswer(report, turn.principal, res.Answer) - turn.reported = true -} - -func r1GitHubMeshEntrySeedTimeout(turnTimeout time.Duration) time.Duration { - if turnTimeout <= 0 { - return 5 * time.Minute - } - return turnTimeout -} - -func waitR1GitHubMeshEntrySeed(agent *r1CodexSyncAgent, timeout time.Duration) (map[string]int, bool) { - deadline := time.Now().Add(timeout) - var counts map[string]int - for time.Now().Before(deadline) { - counts = countR1Ledger(agent.localURL, agent.r1CodexAgent) - if r1GitHubMeshEntrySeedReady(counts) { - return counts, true - } - time.Sleep(500 * time.Millisecond) - } - counts = countR1Ledger(agent.localURL, agent.r1CodexAgent) - return counts, r1GitHubMeshEntrySeedReady(counts) -} - -func r1GitHubMeshEntrySeedReady(counts map[string]int) bool { - return counts["assignment"] >= 1 -} - -func r1GitHubMeshIntegrationAgentIndex(agents []r1CodexSyncAgent, entries []r1GitHubMeshScenarioEntry, busy map[int]bool) int { - if len(entries) > 0 { - idx := entries[0].index - if idx >= 0 && idx < len(agents) && !busy[idx] && r1GitHubMeshAgentReady(agents[idx]) { - return idx - } - } - for i := range agents { - if busy[i] || !r1GitHubMeshAgentReady(agents[i]) { - continue - } - return i - } - return -1 -} - -func (s *r1GitHubMeshRun) restartAgentAppserver(index int, scenario, reason string) error { - if index < 0 || index >= len(s.agents) { - return fmt.Errorf("github mesh restart index out of range: %d", index) - } - agent := &s.agents[index] - if agent.server != nil { - agent.server.Close() - agent.server = nil - } - if err := startR1CodexAppserver(&agent.r1CodexAgent, s.opts.Command); err != nil { - addR1Assertion(s.report, "github-mesh restart appserver "+agent.principal, false, err.Error()) - return err - } - agentReport, raw, err := initializeR1CodexAgent(&agent.r1CodexAgent, s.opts.TurnTimeout) - if err != nil { - addR1Assertion(s.report, "github-mesh restart appserver "+agent.principal, false, err.Error()) - return err - } - if raw != nil && s.report.Raw != nil { - s.report.Raw[agent.principal+":hooks:restart:"+scenario] = raw - } - if s.report.Sync != nil { - s.report.Sync.Lifecycle = append(s.report.Sync.Lifecycle, r1SyncLifecycleReport{ - At: time.Now().UTC().Format(time.RFC3339), - Principal: agent.principal, - Action: "appserver_restart_after_entry_handoff", - Result: "ready", - Branch: s.report.Sync.BranchByAgent[agent.principal], - Detail: reason, - }) - appendSyncAgentAnswer(s.report.Sync, agent.principal, "restarted thread "+agentReport.ThreadID+" after "+reason) - } - addR1Assertion(s.report, "github-mesh restart appserver "+agent.principal, true, reason) - return nil -} - -func (s *r1GitHubMeshRun) scenarioEntries(name string) ([]r1GitHubMeshScenarioEntry, error) { - if len(s.agents) < 5 { - return nil, fmt.Errorf("github mesh natural scenarios require five agents") - } - switch name { - case "onboarding-synthesis": - return []r1GitHubMeshScenarioEntry{{index: 0, prompt: `帮我用团队协作快速理解这个仓库现在的 GitHub Remote Workspace 改造方向,整理一份新成员能读懂的上手说明。请先通过 Mnemon 拉其他成员分别核对架构、测试和风险中的至少两个方向,再继续阅读并根据第一轮反馈做一次补齐或复核后汇总。`}}, nil - case "sync-risk-review": - return []r1GitHubMeshScenarioEntry{{index: 1, prompt: `同步这块我担心还有隐藏问题。请先通过 Mnemon 发起团队协作,检查 GitHub Remote Workspace 相关的配置、诊断和测试设计;把风险排查和验证/补文档拆给合适同伴推进。第一轮先找风险,再根据结果安排第二轮验证或补齐。`}}, nil - case "live-readiness-operator-safety": - return []r1GitHubMeshScenarioEntry{ - {index: 0, prompt: `请你用团队协作推进一次 GitHub live case 的准备,目标是能在 mnemon-dev/mnemon-teamwork-example 上证明 publish/pull/import 成立。请先通过 Mnemon 启动协作,让同伴分别找出实现、测试和运行缺口,再根据第一轮结果安排第二轮补齐。`}, - {index: 2, prompt: `我主要担心这个 GitHub 方案的操作者安全和失败诊断。请你先通过 Mnemon 拉同伴一起从 token、repo、branch、报错可读性这几个角度检查,并把发现反馈给 live case 准备工作。`}, - }, nil - default: - return nil, fmt.Errorf("unknown GitHub mesh scenario %q", name) - } -} - -func (s *r1GitHubMeshRun) wakeWorkers(name string, entries []r1GitHubMeshScenarioEntry) error { - entry := map[int]bool{} - for _, item := range entries { - entry[item.index] = true - } - for cycle := 1; cycle <= 3; cycle++ { - for i := range s.agents { - if entry[i] || !r1GitHubMeshAgentReady(s.agents[i]) { - continue - } - agent := &s.agents[i] - s.report.RunnerContract.WorkerWakePrompts++ - recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "worker_wake:"+name, r1GitHubMeshWorkerWakePrompt) - answer, err := runR1Turn(&agent.r1CodexAgent, r1GitHubMeshWorkerWakePrompt, s.opts.TurnTimeout) - appendSyncAgentAnswer(s.report.Sync, agent.principal, answer) - if err != nil { - s.report.RunnerContract.WorkerWakeErrors = append(s.report.RunnerContract.WorkerWakeErrors, fmt.Sprintf("%s cycle %d %s: %v", name, cycle, agent.principal, err)) - } - } - waitR1ClusterAcceptedEventSettle(s.report.RunRoot, 8*time.Second, 1*time.Second) - if cycle == 1 { - if err := s.joinDelayedAgents(name); err != nil { - return err - } - } - if cycle == 2 && !s.lifecycleExercised { - if err := exerciseR1GitHubMeshLifecycle(s.ctx, s.report, s.agents, s.opts.SyncInterval); err != nil { - return err - } - s.lifecycleExercised = true - } - if cycle >= 2 { - counts, _ := r1GitHubMeshAuthoredEventCounts(s.agents) - if r1GitHubMeshTeamEvidenceCountsReady(counts) { - return nil - } - } - } - return nil -} - -func (s *r1GitHubMeshRun) joinDelayedAgents(scenario string) error { - if s.joined { - return nil - } - var joined []string - profileCounts := map[string]int{} - for i := range s.agents { - if r1GitHubMeshAgentReady(s.agents[i]) { - continue - } - agent := &s.agents[i] - branch := "" - if s.report.Sync != nil { - branch = s.report.Sync.BranchByAgent[agent.principal] - s.report.Sync.Lifecycle = append(s.report.Sync.Lifecycle, r1SyncLifecycleReport{ - At: time.Now().UTC().Format(time.RFC3339), - Principal: agent.principal, - Action: "delayed_join_start", - Result: "requested", - Branch: branch, - Detail: "start a preconfigured isolated Local Mnemon during the natural GitHub mesh task", - }) - } - if err := startR1GitHubMeshLocalMnemond(s.ctx, agent, s.opts.SyncInterval); err != nil { - addR1Assertion(s.report, "github-mesh delayed mnemond join "+agent.principal, false, err.Error()) - return err - } - if err := startR1CodexAppserver(&agent.r1CodexAgent, s.opts.Command); err != nil { - addR1Assertion(s.report, "github-mesh delayed appserver join "+agent.principal, false, err.Error()) - return err - } - agentReport, raw, err := initializeR1CodexAgent(&agent.r1CodexAgent, s.opts.TurnTimeout) - if err != nil { - addR1Assertion(s.report, "github-mesh delayed appserver join "+agent.principal, false, err.Error()) - return err - } - s.report.Agents = append(s.report.Agents, agentReport) - if s.report.Sync != nil { - s.report.Sync.Agents = append(s.report.Sync.Agents, agentReport) - } - if raw != nil { - s.report.Raw[agent.principal+":hooks"] = raw - } - if err := s.emitJoinedProfile(agent, scenario); err != nil { - return err - } - counts := countR1Ledger(agent.localURL, agent.r1CodexAgent) - profileCounts[agent.principal] = counts["agent_profile"] - joined = append(joined, agent.principal) - if s.report.Sync != nil { - s.report.Sync.Lifecycle = append(s.report.Sync.Lifecycle, r1SyncLifecycleReport{ - At: time.Now().UTC().Format(time.RFC3339), - Principal: agent.principal, - Action: "delayed_join_ready", - Result: "ready", - Branch: branch, - Detail: "joined configured publication mesh, initialized appserver, and published fresh profile", - Ledger: counts, - }) - } - } - s.joined = true - if len(joined) == 0 { - return nil - } - allVisible := true - convergeTimeout := r1GitHubMeshProfileConvergenceTimeout(s.opts.SyncInterval) - for _, i := range r1GitHubMeshReadyAgentIndexes(s.agents) { - agent := s.agents[i] - waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", len(s.agents), convergeTimeout) - counts := countR1Ledger(agent.localURL, agent.r1CodexAgent) - profileCounts[agent.principal] = counts["agent_profile"] - if counts["agent_profile"] < len(s.agents) { - allVisible = false - } - } - passed := len(joined) >= 2 && allVisible - addR1Assertion(s.report, "github-mesh two delayed mnemond join and import backlog", passed, fmt.Sprintf("joined=%v profile_counts=%v", joined, profileCounts)) - s.report.Scenarios = append(s.report.Scenarios, r1TaskSimScenarioReport{ - Name: "delayed_join_profiles", - Status: statusFromBool(passed), - Actors: joined, - Evidence: map[string]any{ - "scenario": scenario, - "joined_agents": joined, - "profile_counts_by_agent": profileCounts, - "publication_branches": s.report.Sync.PublicationBranches, - }, - }) - if !passed { - return fmt.Errorf("delayed GitHub mesh join did not converge profiles through publication branches") - } - return nil -} - -func r1GitHubMeshProfileConvergenceTimeout(syncInterval time.Duration) time.Duration { - if syncInterval <= 0 { - return 120 * time.Second - } - timeout := syncInterval*4 + 30*time.Second - if timeout < 120*time.Second { - return 120 * time.Second - } - if timeout > 6*time.Minute { - return 6 * time.Minute - } - return timeout -} - -func (s *r1GitHubMeshRun) emitJoinedProfile(agent *r1CodexSyncAgent, scenario string) error { - payload := taskSimJSON(map[string]any{ - "rule": map[string]any{ - "actor": agent.principal, - "availability": "available", - "ttl": "30m", - }, - "narrative": map[string]any{ - "focus": fmt.Sprintf("Joined GitHub mesh task %s with fresh local context", scenario), - "context_advantages": []string{"late join backlog import", "github publication branch sync", "isolated local mnemond"}, - "summary": fmt.Sprintf("%s joined during %s and can pick up governed work from imported context.", agent.principal, scenario), - }, - }) - prompt := fmt.Sprintf(`Emit exactly one agent_profile.write_candidate.observed event through your own Local Mnemon. -Use external id github-mesh-join-profile-%s-%s and payload: -%s -After the command succeeds, answer "joined profile written".`, s.runID, prodSafeID(agent.principal), payload) - recordR1ClusterPrompt(s.report.RunnerContract, agent.principal, "delayed_join_profile:"+scenario, prompt) - s.report.RunnerContract.ProfileBootstrapPrompts++ - answer, err := runR1Turn(&agent.r1CodexAgent, prompt, s.opts.TurnTimeout) - appendSyncAgentAnswer(s.report.Sync, agent.principal, answer) - if err != nil { - addR1Assertion(s.report, "github-mesh delayed profile emitted "+agent.principal, false, err.Error()) - return err - } - waitForLedgerCount(agent.localURL, agent.r1CodexAgent, "agent_profile", 1, 20*time.Second) - return nil -} - -func r1GitHubMeshIntegrationPrompt(name string) string { - return fmt.Sprintf(`Read your own Local Mnemon context and integrate the GitHub mesh teamwork scenario %q. -Use only governed Mnemon events as teammate evidence. If first-round output reveals gaps, emit a follow-up assignment before finalizing; otherwise emit a final progress_digest with participants, evidence, gaps, and next action. -Answer with the event-backed result.`, name) -} - -func r1GitHubMeshPromptRounds(contract *r1RunnerContractReport, scenario string) int { - if contract == nil { - return 0 - } - rounds := 0 - for _, prompt := range contract.PromptAudit { - if strings.Contains(prompt.Kind, scenario) { - rounds++ - } - } - return rounds -} - -func r1GitHubMeshPromptKindCount(contract *r1RunnerContractReport, kind string) int { - if contract == nil { - return 0 - } - count := 0 - for _, prompt := range contract.PromptAudit { - if prompt.Kind == kind { - count++ - } - } - return count -} - -func r1GitHubMeshKindTotal(counts map[string]map[string]int, kind string) int { - total := 0 - for _, byKind := range counts { - total += byKind[kind] - } - return total -} - -func r1GitHubMeshTeamEvidenceCountsReady(counts map[string]map[string]int) bool { - return r1ClusterNonProfileParticipantCount(counts) >= 2 && - r1GitHubMeshKindTotal(counts, "assignment") >= 1 && - r1GitHubMeshKindTotal(counts, "progress_digest") >= 1 -} - -func r1GitHubMeshAuthoredEventCounts(agents []r1CodexSyncAgent) (map[string]map[string]int, []string) { - out := map[string]map[string]int{} - var warnings []string - for _, agent := range agents { - path := prodSimMnemondPath(agent) - db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)") - if err != nil { - warnings = append(warnings, fmt.Sprintf("%s open authored event counts: %v", agent.principal, err)) - continue - } - func() { - defer db.Close() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - rows, err := db.QueryContext(ctx, ` -SELECT actor, resource_kind, COUNT(*) -FROM sync_events -WHERE actor <> '' -GROUP BY actor, resource_kind -ORDER BY actor, resource_kind`) - if err != nil { - warnings = append(warnings, fmt.Sprintf("%s query authored event counts: %v", agent.principal, err)) - return - } - defer rows.Close() - for rows.Next() { - var actor, kind string - var count int - if err := rows.Scan(&actor, &kind, &count); err != nil { - warnings = append(warnings, fmt.Sprintf("%s scan authored event counts: %v", agent.principal, err)) - return - } - if out[actor] == nil { - out[actor] = map[string]int{} - } - out[actor][kind] += count - } - if err := rows.Err(); err != nil { - warnings = append(warnings, fmt.Sprintf("%s read authored event counts: %v", agent.principal, err)) - } - }() - } - return out, warnings -} - -func r1GitHubMeshLedgerCountsByAgent(agents []r1CodexSyncAgent, kind string) map[string]int { - out := make(map[string]int, len(agents)) - for _, agent := range agents { - out[agent.principal] = countR1Ledger(agent.localURL, agent.r1CodexAgent)[kind] - } - return out -} - -func r1GitHubMeshThreadIDs(agents []r1CodexSyncAgent) map[string]string { - out := make(map[string]string, len(agents)) - for _, agent := range agents { - if strings.TrimSpace(agent.threadID) != "" { - out[agent.principal] = agent.threadID - } - } - return out -} - -func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, tokenFile, branchPrefix string, count int, sourceCodexHome string, syncInterval time.Duration, initialOnline int) ([]r1CodexSyncAgent, error) { - if syncInterval <= 0 { - syncInterval = 30 * time.Second - } - if initialOnline < 0 || initialOnline > count { - initialOnline = count - } - var agents []r1CodexSyncAgent - branches := r1GitHubMeshBranches(branchPrefix, count) - for i := 1; i <= count; i++ { - principal := fmt.Sprintf("codex-%02d@project", i) - workspace := filepath.Join(runRoot, "workspaces", fmt.Sprintf("codex-%02d", i)) - codexHome := filepath.Join(runRoot, "codex-home", fmt.Sprintf("codex-%02d", i)) - if err := os.MkdirAll(workspace, 0o755); err != nil { - return nil, err - } - if err := os.WriteFile(filepath.Join(workspace, "README.md"), []byte("# R1 GitHub mesh acceptance workspace\n"), 0o644); err != nil { - return nil, err - } - if err := prepareAcceptanceCodexHome(codexHome, workspace, sourceCodexHome); err != nil { - return nil, err - } - localAddr, err := freeLoopbackAddr() - if err != nil { - return nil, err - } - localURL := "http://" + localAddr - if _, err := app.New(workspace).Setup(context.Background(), io.Discard, io.Discard, app.SetupOptions{ - Host: "codex", - ControlURL: localURL, - Principal: principal, - HarnessBin: filepath.Join(binDir, "mnemon-harness"), - ProjectRoot: workspace, - UseToken: true, - }); err != nil { - return nil, err - } - if err := writeR1GitHubMeshRemotes(workspace, repo, tokenFile, branches, i-1); err != nil { - return nil, err - } - loaded, err := access.LoadBindingFile(workspace, filepath.Join(workspace, access.DefaultBindingFile)) - if err != nil { - return nil, err - } - token, err := acceptanceTokenForPrincipal(loaded.Tokens, contract.ActorID(principal)) - if err != nil { - return nil, err - } - agent := r1CodexSyncAgent{ - r1CodexAgent: r1CodexAgent{ - principal: principal, - workspace: workspace, - codexHome: codexHome, - token: token, - env: acceptanceEnv(binDir, codexHome, runRoot), - }, - localURL: localURL, - replicaPrincipal: principal, - replicaToken: tokenFile, - renderAuditPath: filepath.Join(workspace, ".mnemon", "harness", "local", "render-audit.jsonl"), - } - if i <= initialOnline { - if err := startR1GitHubMeshLocalMnemond(ctx, &agent, syncInterval); err != nil { - return nil, err - } - } - agents = append(agents, agent) - } - return agents, nil -} - -func exerciseR1GitHubMeshLifecycle(ctx context.Context, report *r1CodexAcceptanceReport, agents []r1CodexSyncAgent, syncInterval time.Duration) error { - if report == nil || report.Sync == nil || len(agents) < 5 { - return nil - } - if syncInterval <= 0 { - syncInterval = 30 * time.Second - } - target := &agents[2] - branch := report.Sync.BranchByAgent[target.principal] - report.Sync.Lifecycle = append(report.Sync.Lifecycle, r1SyncLifecycleReport{ - At: time.Now().UTC().Format(time.RFC3339), - Principal: target.principal, - Action: "pause_local_mnemond", - Result: "requested", - Branch: branch, - Detail: "cancel one isolated Local Mnemon before teamwork turns; appserver remains initialized", - }) - if target.localCancel == nil { - addR1Assertion(report, "github-mesh local mnemond pause/restart exercised", false, "target has no localCancel") - return fmt.Errorf("%s has no local mnemond cancel function", target.principal) - } - target.localCancel() - if target.localErr != nil { - stopTimeout := 45 * time.Second - select { - case <-target.localErr: - case <-time.After(stopTimeout): - addR1Assertion(report, "github-mesh local mnemond pause observed", false, "timeout waiting for local mnemond stop after "+stopTimeout.String()) - return fmt.Errorf("%s local mnemond did not stop within %s", target.principal, stopTimeout) - } - } - target.localCancel = nil - target.localErr = nil - if err := startR1GitHubMeshLocalMnemond(ctx, target, syncInterval); err != nil { - addR1Assertion(report, "github-mesh local mnemond pause/restart exercised", false, err.Error()) - return err - } - counts := countR1Ledger(target.localURL, target.r1CodexAgent) - report.Sync.Lifecycle = append(report.Sync.Lifecycle, r1SyncLifecycleReport{ - At: time.Now().UTC().Format(time.RFC3339), - Principal: target.principal, - Action: "restart_local_mnemond", - Result: "ready", - Branch: branch, - Detail: "restarted the same isolated Local Mnemon store/workspace and configured GitHub publication branch", - Ledger: counts, - }) - addR1Assertion(report, "github-mesh local mnemond pause/restart exercised", true, fmt.Sprintf("principal=%s branch=%s", target.principal, branch)) - return nil -} - -func startR1GitHubMeshLocalMnemond(ctx context.Context, agent *r1CodexSyncAgent, syncInterval time.Duration) error { - if agent == nil { - return fmt.Errorf("nil GitHub mesh agent") - } - if agent.localCancel != nil { - return nil - } - if syncInterval <= 0 { - syncInterval = 30 * time.Second - } - loaded, err := access.LoadBindingFile(agent.workspace, filepath.Join(agent.workspace, access.DefaultBindingFile)) - if err != nil { - return err - } - addr := strings.TrimPrefix(agent.localURL, "http://") - if strings.TrimSpace(addr) == "" { - return fmt.Errorf("%s has no local URL", agent.principal) - } - localCtx, cancel := context.WithCancel(ctx) - localErr := make(chan error, 1) - go func(workspace, addr string, loaded access.LoadedBindings) { - localErr <- app.RunLocalHTTPServerWithBindings(localCtx, addr, filepath.Join(workspace, runtime.DefaultStorePath), loaded, app.ServeOptions{ - ProjectRoot: workspace, - SyncInterval: syncInterval, - }, io.Discard) - }(agent.workspace, addr, loaded) - agent.localCancel = cancel - agent.localErr = localErr - if err := waitR1LocalReady(ctx, agent.r1CodexAgent, agent.localURL, 10*time.Second); err != nil { - cancel() - agent.localCancel = nil - agent.localErr = nil - return err - } - return nil -} - -func r1GitHubMeshInitialOnline(count int) int { - if count <= 2 { - return count - } - if count <= 5 { - return count - 2 - } - return count - 2 -} - -func r1GitHubMeshAgentReady(agent r1CodexSyncAgent) bool { - return agent.localCancel != nil && agent.server != nil && strings.TrimSpace(agent.threadID) != "" -} - -func r1GitHubMeshLocalOnlineIndexes(agents []r1CodexSyncAgent) []int { - out := []int{} - for i, agent := range agents { - if agent.localCancel != nil { - out = append(out, i) - } - } - return out -} - -func r1GitHubMeshReadyAgentIndexes(agents []r1CodexSyncAgent) []int { - out := []int{} - for i, agent := range agents { - if r1GitHubMeshAgentReady(agent) { - out = append(out, i) - } - } - return out -} - -func writeR1GitHubMeshRemotes(workspace, repo, tokenFile string, branches []string, self int) error { - if self < 0 || self >= len(branches) { - return fmt.Errorf("self index %d outside branches", self) - } - repo, err := exchange.NormalizeGitHubRepo(repo) - if err != nil { - return err - } - if strings.TrimSpace(tokenFile) == "" { - return fmt.Errorf("github token file is required") - } - normalized := make([]string, 0, len(branches)) - for _, branch := range branches { - branch, err := exchange.NormalizePublicationBranch(branch) - if err != nil { - return err - } - normalized = append(normalized, branch) - } - branches = normalized - remotesPath := filepath.Join(workspace, ".mnemon", "harness", "sync", "remotes.json") - if err := upsertSyncRemote(remotesPath, workspace, "self", exchange.RemoteBackendGitHub, exchange.RemoteDirectionPublish, "", repo, branches[self], "", tokenFile, ""); err != nil { - return err - } - for i, branch := range branches { - if i == self { - continue - } - id := fmt.Sprintf("stream-%02d", i+1) - if err := upsertSyncRemote(remotesPath, workspace, id, exchange.RemoteBackendGitHub, exchange.RemoteDirectionSubscribe, "", repo, branch, "", tokenFile, ""); err != nil { - return err - } - } - return nil -} - -func r1GitHubMeshBranches(prefix string, count int) []string { - if prefix == "" { - prefix = "mnemon/mnemond-" - } - out := make([]string, 0, count) - for i := 0; i < count; i++ { - suffix := fmt.Sprintf("%02d", i+1) - if strings.HasSuffix(prefix, "-") && i < 26 { - suffix = string(rune('a' + i)) - } - out = append(out, prefix+suffix) - } - return out -} - -func r1GitHubMeshBranchPrefix(prefix string, started time.Time) string { - prefix = strings.TrimSpace(prefix) - if prefix != "" { - return prefix - } - return "mnemon/mnemond-" + started.UTC().Format("20060102T150405Z") + "-" -} - -func validateR1GitHubMeshSyncInterval(opts r1GitHubMeshAcceptanceOptions) error { - if !opts.AgentTurns { - return nil - } - if opts.SyncInterval < 30*time.Second { - return fmt.Errorf("github mesh agent-turns require --sync-interval >= 30s to protect GitHub API quota (got %s)", opts.SyncInterval) - } - return nil -} - -func ensureR1GitHubMeshBranches(ctx context.Context, repo, tokenFile string, branches []string) error { - token, err := readR1GitHubMeshToken(tokenFile) - if err != nil { - return err - } - store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ - Repo: repo, - Token: token, - MutativeDelay: time.Second, - }) - if err != nil { - return err - } - if err := store.EnsureBranches(ctx, branches, "main"); err != nil { - return fmt.Errorf("ensure GitHub branches: %w", err) - } - return nil -} - -type r1GitHubMeshRateLimit struct { - Limit int - Remaining int - Used int - ResetAt time.Time -} - -func r1GitHubMeshMinimumRateLimitRemaining(opts r1GitHubMeshAcceptanceOptions) int { - scenarios := len(r1GitHubMeshScenarioNames(opts.Scenarios)) - if scenarios == 0 { - scenarios = 1 - } - agents := opts.Agents - if agents < 5 { - agents = 5 - } - min := 500 + agents*scenarios*150 - if min < 1500 { - return 1500 - } - return min -} - -func preflightR1GitHubMeshRateLimit(ctx context.Context, tokenFile string, minRemaining int) (r1GitHubMeshRateLimit, error) { - token, err := readR1GitHubMeshToken(tokenFile) - if err != nil { - return r1GitHubMeshRateLimit{}, err - } - limit, err := fetchR1GitHubMeshRateLimit(ctx, token) - if err != nil { - return r1GitHubMeshRateLimit{}, err - } - if limit.Remaining < minRemaining { - return limit, fmt.Errorf("github core API rate limit remaining %d below required %d; reset=%s", limit.Remaining, minRemaining, limit.ResetAt.UTC().Format(time.RFC3339)) - } - return limit, nil -} - -func preflightR1GitHubMeshRepositoryAccess(ctx context.Context, repo, tokenFile string) error { - token, err := readR1GitHubMeshToken(tokenFile) - if err != nil { - return err - } - return fetchR1GitHubMeshRepositoryAccess(ctx, repo, token) -} - -func fetchR1GitHubMeshRepositoryAccess(ctx context.Context, repo, token string) error { - repo, err := exchange.NormalizeGitHubRepo(repo) - if err != nil { - return err - } - baseURL := strings.TrimRight(strings.TrimSpace(r1GitHubMeshAPIBaseURL), "/") - if baseURL == "" { - baseURL = "https://api.github.com" - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/repos/"+repo, nil) - if err != nil { - return err - } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("X-GitHub-Api-Version", "2022-11-28") - req.Header.Set("User-Agent", "mnemon-acceptance") - if strings.TrimSpace(token) != "" { - req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) - } - client := r1GitHubMeshHTTPClient - if client == nil { - client = http.DefaultClient - } - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - if resp.StatusCode >= 300 { - return fmt.Errorf("github repo access status %d for %s: %s", resp.StatusCode, repo, strings.TrimSpace(string(body))) - } - return nil -} - -func fetchR1GitHubMeshRateLimit(ctx context.Context, token string) (r1GitHubMeshRateLimit, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, r1GitHubMeshRateLimitAPIURL, nil) - if err != nil { - return r1GitHubMeshRateLimit{}, err - } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("X-GitHub-Api-Version", "2022-11-28") - req.Header.Set("User-Agent", "mnemon-acceptance") - if strings.TrimSpace(token) != "" { - req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) - } - client := r1GitHubMeshHTTPClient - if client == nil { - client = http.DefaultClient - } - resp, err := client.Do(req) - if err != nil { - return r1GitHubMeshRateLimit{}, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return r1GitHubMeshRateLimit{}, err - } - if resp.StatusCode >= 300 { - return r1GitHubMeshRateLimit{}, fmt.Errorf("github rate_limit status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - var doc struct { - Resources struct { - Core struct { - Limit int `json:"limit"` - Remaining int `json:"remaining"` - Reset int64 `json:"reset"` - Used int `json:"used"` - } `json:"core"` - } `json:"resources"` - } - if err := json.Unmarshal(body, &doc); err != nil { - return r1GitHubMeshRateLimit{}, fmt.Errorf("parse github rate_limit response: %w", err) - } - core := doc.Resources.Core - return r1GitHubMeshRateLimit{ - Limit: core.Limit, - Remaining: core.Remaining, - Used: core.Used, - ResetAt: time.Unix(core.Reset, 0).UTC(), - }, nil -} - -func readR1GitHubMeshToken(tokenFile string) (string, error) { - body, err := os.ReadFile(tokenFile) - if err != nil { - return "", fmt.Errorf("read github token file: %w", err) - } - token := strings.TrimSpace(string(body)) - if token == "" { - return "", fmt.Errorf("github token file is empty") - } - return token, nil -} - -func buildR1GitHubMeshSyncReport(repo string, agents []r1CodexSyncAgent) *r1CodexSyncReport { - report := &r1CodexSyncReport{ - Status: "running", - Backend: exchange.RemoteBackendGitHub, - Repo: repo, - TransportModel: "repo-mediated-publication", - RosterSource: "configured-remotes-json", - NetworkDiscovery: "none", - AllowedEventSubjects: r1SyncEventSubjectLabels(r1GitHubMeshScopes()), - Artifacts: map[string]string{}, - BranchByAgent: map[string]string{}, - PublishedByBranch: map[string]int{}, - ImportedByMnemond: map[string]int{}, - DiagnosticsByMnemond: map[string]int{}, - ProfileByMnemond: map[string]int{}, - } - for i, agent := range agents { - branch := "" - if remote, err := exchange.LoadRemoteEntry(filepath.Join(agent.workspace, ".mnemon", "harness", "sync", "remotes.json"), "self"); err == nil { - branch = remote.Branch - } else if agent.replicaPrincipal != "" { - branch = agent.replicaPrincipal - } - if branch != "" { - report.PublicationBranches = append(report.PublicationBranches, branch) - report.BranchByAgent[agent.principal] = branch - } - report.RuntimeWorkspaces = append(report.RuntimeWorkspaces, agent.workspace) - report.LocalStorePaths = append(report.LocalStorePaths, filepath.Join(agent.workspace, runtime.DefaultStorePath)) - remotesPath := filepath.Join(agent.workspace, ".mnemon", "harness", "sync", "remotes.json") - report.RemotePlanPaths = append(report.RemotePlanPaths, remotesPath) - report.Artifacts[fmt.Sprintf("remotes:%s", agent.principal)] = remotesPath - if i == 0 { - report.Source = agent.principal - } - } - sort.Strings(report.PublicationBranches) - sort.Strings(report.RemotePlanPaths) - return report -} - -func populateR1GitHubMeshSyncEvidence(report *r1CodexAcceptanceReport, obs acceptanceObserveReport) { - if report == nil || report.Sync == nil { - return - } - syncReport := report.Sync - if syncReport.PublishedByBranch == nil { - syncReport.PublishedByBranch = map[string]int{} - } - if syncReport.ImportedByMnemond == nil { - syncReport.ImportedByMnemond = map[string]int{} - } - if syncReport.DiagnosticsByMnemond == nil { - syncReport.DiagnosticsByMnemond = map[string]int{} - } - if syncReport.ProfileByMnemond == nil { - syncReport.ProfileByMnemond = map[string]int{} - } - stores := map[string]acceptanceStoreInspect{} - for _, store := range obs.Stores { - if store.Role == "mnemond" { - stores[store.Name] = store - } - } - for _, agent := range syncReport.Agents { - principal := strings.TrimSpace(agent.Principal) - if principal == "" { - continue - } - storeName := r1GitHubMeshStoreName(principal) - store, ok := stores[storeName] - if !ok { - continue - } - branch := syncReport.BranchByAgent[principal] - if branch != "" { - syncReport.PublishedByBranch[branch] = store.SyncEventsByStatus["synced"] - } - syncReport.ImportedByMnemond[principal] = store.Counts["imported_accepted"] - syncReport.DiagnosticsByMnemond[principal] = store.ObservedByType["sync.diagnostic"] + store.ObservedByType["sync.remote_diagnostic.observed"] - syncReport.ProfileByMnemond[principal] = store.EnvelopeByType["agent_profile.accepted"] - } -} - -func r1GitHubMeshStoreName(principal string) string { - name, _, _ := strings.Cut(strings.TrimSpace(principal), "@") - return name -} - -func r1GitHubMeshStrictTopology(top *r1AcceptanceTopologyReport) bool { - if top == nil || top.Mode != "per-hostagent-mnemond" || top.SharedMnemond || top.MnemonhubInstances != 0 || top.Agents < 5 || top.MnemondInstances != top.Agents { - return false - } - seen := map[string]bool{} - for _, path := range top.AgentMnemondMap { - if strings.TrimSpace(path) == "" || seen[path] { - return false - } - seen[path] = true - } - return len(seen) == top.Agents -} - -func r1GitHubMeshScopes() []contract.ResourceRef { - return []contract.ResourceRef{ - {Kind: "agent_profile", ID: "project"}, - {Kind: "project_intent", ID: "project"}, - {Kind: "teamwork_signal", ID: "project"}, - {Kind: "assignment", ID: "project"}, - {Kind: "progress_digest", ID: "project"}, - } -} - -func distinctStrings(values []string) bool { - seen := map[string]bool{} - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" || seen[value] { - return false - } - seen[value] = true - } - return true -} diff --git a/harness/cmd/mnemon-acceptance/acceptance_github_mesh_test.go b/harness/cmd/mnemon-acceptance/acceptance_github_mesh_test.go deleted file mode 100644 index c9ab9027..00000000 --- a/harness/cmd/mnemon-acceptance/acceptance_github_mesh_test.go +++ /dev/null @@ -1,609 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/mnemon-dev/mnemon/harness/internal/codexapp" - "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" - "github.com/mnemon-dev/mnemon/harness/internal/runtime" -) - -func TestR1GitHubMeshBranchesDefaultShape(t *testing.T) { - got := r1GitHubMeshBranches("mnemon/mnemond-", 5) - want := []string{"mnemon/mnemond-a", "mnemon/mnemond-b", "mnemon/mnemond-c", "mnemon/mnemond-d", "mnemon/mnemond-e"} - if len(got) != len(want) { - t.Fatalf("branches = %v, want %v", got, want) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("branches = %v, want %v", got, want) - } - } -} - -func TestR1GitHubMeshBranchPrefixDefaultsToRunScopedBranches(t *testing.T) { - started := time.Date(2026, 6, 25, 18, 57, 20, 0, time.UTC) - prefix := r1GitHubMeshBranchPrefix("", started) - if prefix != "mnemon/mnemond-20260625T185720Z-" { - t.Fatalf("prefix = %q, want run-scoped mnemond prefix", prefix) - } - got := r1GitHubMeshBranches(prefix, 2) - want := []string{ - "mnemon/mnemond-20260625T185720Z-a", - "mnemon/mnemond-20260625T185720Z-b", - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("branches = %v, want %v", got, want) - } - } - if explicit := r1GitHubMeshBranchPrefix("mnemon/mnemond-team-", started); explicit != "mnemon/mnemond-team-" { - t.Fatalf("explicit prefix = %q, want unchanged", explicit) - } -} - -func TestFetchR1GitHubMeshRateLimit(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("Authorization"); got != "Bearer secret-token" { - t.Fatalf("authorization header = %q", got) - } - _, _ = w.Write([]byte(`{"resources":{"core":{"limit":5000,"remaining":4321,"reset":1782725122,"used":679}}}`)) - })) - defer server.Close() - oldURL := r1GitHubMeshRateLimitAPIURL - oldClient := r1GitHubMeshHTTPClient - r1GitHubMeshRateLimitAPIURL = server.URL - r1GitHubMeshHTTPClient = server.Client() - t.Cleanup(func() { - r1GitHubMeshRateLimitAPIURL = oldURL - r1GitHubMeshHTTPClient = oldClient - }) - - limit, err := fetchR1GitHubMeshRateLimit(context.Background(), "secret-token") - if err != nil { - t.Fatal(err) - } - if limit.Limit != 5000 || limit.Remaining != 4321 || limit.Used != 679 || limit.ResetAt.Unix() != 1782725122 { - t.Fatalf("rate limit mismatch: %+v", limit) - } -} - -func TestPreflightR1GitHubMeshRateLimitBlocksLowRemaining(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"resources":{"core":{"limit":5000,"remaining":42,"reset":1782725122,"used":4958}}}`)) - })) - defer server.Close() - oldURL := r1GitHubMeshRateLimitAPIURL - oldClient := r1GitHubMeshHTTPClient - r1GitHubMeshRateLimitAPIURL = server.URL - r1GitHubMeshHTTPClient = server.Client() - t.Cleanup(func() { - r1GitHubMeshRateLimitAPIURL = oldURL - r1GitHubMeshHTTPClient = oldClient - }) - tokenFile := filepath.Join(t.TempDir(), "github.token") - if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { - t.Fatal(err) - } - limit, err := preflightR1GitHubMeshRateLimit(context.Background(), tokenFile, 1500) - if err == nil || !strings.Contains(err.Error(), "below required 1500") { - t.Fatalf("expected low-rate-limit error, got limit=%+v err=%v", limit, err) - } - if limit.Remaining != 42 { - t.Fatalf("rate limit should be returned for diagnostics: %+v", limit) - } -} - -func TestR1GitHubMeshAcceptanceBlocksInvalidRepositoryTokenBeforeSetup(t *testing.T) { - tmp := t.TempDir() - var repoChecked bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("Authorization"); got != "Bearer secret-token" { - t.Fatalf("authorization header = %q", got) - } - switch r.URL.Path { - case "/rate_limit": - _, _ = w.Write([]byte(`{"resources":{"core":{"limit":5000,"remaining":5000,"reset":1782725122,"used":0}}}`)) - case "/repos/mnemon-dev/mnemon-teamwork-example": - repoChecked = true - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"message":"Bad credentials"}`)) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer server.Close() - oldRateLimitURL := r1GitHubMeshRateLimitAPIURL - oldAPIBaseURL := r1GitHubMeshAPIBaseURL - oldClient := r1GitHubMeshHTTPClient - r1GitHubMeshRateLimitAPIURL = server.URL + "/rate_limit" - r1GitHubMeshAPIBaseURL = server.URL - r1GitHubMeshHTTPClient = server.Client() - t.Cleanup(func() { - r1GitHubMeshRateLimitAPIURL = oldRateLimitURL - r1GitHubMeshAPIBaseURL = oldAPIBaseURL - r1GitHubMeshHTTPClient = oldClient - }) - - tokenFile := filepath.Join(tmp, "github.token") - if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { - t.Fatal(err) - } - report, err := runR1GitHubMeshAcceptance(context.Background(), r1GitHubMeshAcceptanceOptions{ - r1CodexAcceptanceOptions: r1CodexAcceptanceOptions{ - RunRoot: filepath.Join(tmp, "run"), - AgentTurns: true, - TurnTimeout: time.Millisecond, - }, - Repo: "mnemon-dev/mnemon-teamwork-example", - TokenFile: tokenFile, - SyncInterval: 30 * time.Second, - }) - if err == nil || !strings.Contains(err.Error(), "github repo access status 401") || !strings.Contains(err.Error(), "Bad credentials") { - t.Fatalf("expected invalid repo token blocker, got report=%+v err=%v", report, err) - } - if !repoChecked { - t.Fatal("repository access preflight was not called") - } - if report.Status != "blocked" || !strings.Contains(strings.Join(report.Errors, "\n"), "Bad credentials") { - t.Fatalf("report should be blocked with repo credential error: %+v", report) - } - if _, err := os.Stat(filepath.Join(report.RunRoot, "bin")); !os.IsNotExist(err) { - t.Fatalf("acceptance should block before installing binaries, stat err=%v", err) - } - data, err := os.ReadFile(report.ReportPath) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), "github repo access status 401") { - t.Fatalf("written report missing repo access blocker:\n%s", data) - } -} - -func TestValidateR1GitHubMeshSyncIntervalProtectsAgentTurns(t *testing.T) { - err := validateR1GitHubMeshSyncInterval(r1GitHubMeshAcceptanceOptions{ - r1CodexAcceptanceOptions: r1CodexAcceptanceOptions{AgentTurns: true}, - SyncInterval: 10 * time.Second, - }) - if err == nil || !strings.Contains(err.Error(), ">= 30s") { - t.Fatalf("expected short sync interval guard, got %v", err) - } - if err := validateR1GitHubMeshSyncInterval(r1GitHubMeshAcceptanceOptions{ - r1CodexAcceptanceOptions: r1CodexAcceptanceOptions{AgentTurns: true}, - SyncInterval: 30 * time.Second, - }); err != nil { - t.Fatalf("30s sync interval should be allowed: %v", err) - } - if err := validateR1GitHubMeshSyncInterval(r1GitHubMeshAcceptanceOptions{SyncInterval: 10 * time.Second}); err != nil { - t.Fatalf("short non-agent-turn sync interval should be allowed: %v", err) - } -} - -func TestWriteR1GitHubMeshRemotesCreatesPublishAndSubscribePlan(t *testing.T) { - root := t.TempDir() - tokenFile := filepath.Join(root, "github.token") - if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { - t.Fatal(err) - } - workspace := filepath.Join(root, "workspaces", "codex-03") - if err := os.MkdirAll(workspace, 0o755); err != nil { - t.Fatal(err) - } - branches := r1GitHubMeshBranches("mnemon/mnemond-", 5) - if err := writeR1GitHubMeshRemotes(workspace, "mnemon-dev/mnemon-teamwork-example", tokenFile, branches, 2); err != nil { - t.Fatalf("write github mesh remotes: %v", err) - } - - remotesPath := filepath.Join(workspace, ".mnemon", "harness", "sync", "remotes.json") - plan, err := exchange.LoadRemotePlan(remotesPath, "default") - if err != nil { - t.Fatalf("load remote plan: %v", err) - } - if len(plan.PushTargets) != 1 || plan.PushTargets[0].ID != "self" || plan.PushTargets[0].Branch != "mnemon/mnemond-c" { - t.Fatalf("push targets = %+v, want self on mnemon/mnemond-c", plan.PushTargets) - } - if len(plan.PullSources) != 4 { - t.Fatalf("pull sources = %+v, want four peer streams", plan.PullSources) - } - for _, remote := range append(plan.PushTargets, plan.PullSources...) { - if remote.Backend != exchange.RemoteBackendGitHub || - remote.Repo != "mnemon-dev/mnemon-teamwork-example" || - remote.CredentialRef != tokenFile || - remote.Endpoint != "" { - t.Fatalf("remote not a github publication stream: %+v", remote) - } - } -} - -func TestSetupR1CodexGitHubMeshAgentsCanDelayLocalMnemondStart(t *testing.T) { - root := t.TempDir() - tokenFile := filepath.Join(root, "github.token") - if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { - t.Fatal(err) - } - agents, err := setupR1CodexGitHubMeshAgents(context.Background(), root, root, "mnemon-dev/mnemon-teamwork-example", tokenFile, "mnemon/mnemond-", 5, "", 30*time.Second, 0) - if err != nil { - t.Fatalf("setup delayed github mesh agents: %v", err) - } - if len(agents) != 5 { - t.Fatalf("agents = %d, want 5", len(agents)) - } - if got := r1GitHubMeshLocalOnlineIndexes(agents); len(got) != 0 { - t.Fatalf("local online indexes = %v, want none before delayed start", got) - } - for i, agent := range agents { - if agent.localCancel != nil || agent.localErr != nil { - t.Fatalf("agent %d local mnemond should not be started", i) - } - remotesPath := filepath.Join(agent.workspace, ".mnemon", "harness", "sync", "remotes.json") - plan, err := exchange.LoadRemotePlan(remotesPath, "default") - if err != nil { - t.Fatalf("load delayed remote plan %d: %v", i, err) - } - if len(plan.PushTargets) != 1 || len(plan.PullSources) != 4 { - t.Fatalf("remote plan %d = %+v, want one publish and four subscribe streams", i, plan) - } - } -} - -func TestR1GitHubMeshInitialOnlineLeavesTwoDelayedAgents(t *testing.T) { - if got := r1GitHubMeshInitialOnline(5); got != 3 { - t.Fatalf("initial online for 5 = %d, want 3", got) - } - if got := r1GitHubMeshInitialOnline(7); got != 5 { - t.Fatalf("initial online for 7 = %d, want 5", got) - } - agents := []r1CodexSyncAgent{ - {localCancel: func() {}}, - {}, - {localCancel: func() {}}, - } - got := r1GitHubMeshLocalOnlineIndexes(agents) - if len(got) != 2 || got[0] != 0 || got[1] != 2 { - t.Fatalf("local online indexes = %v, want [0 2]", got) - } -} - -func TestBuildR1GitHubMeshSyncReportProvesIsolationAndNoHub(t *testing.T) { - root := t.TempDir() - tokenFile := filepath.Join(root, "github.token") - if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { - t.Fatal(err) - } - branches := r1GitHubMeshBranches("mnemon/mnemond-", 3) - agents := make([]r1CodexSyncAgent, 0, 3) - for i := range branches { - workspace := filepath.Join(root, "workspaces", branches[i][len("mnemon/"):]) - if err := os.MkdirAll(workspace, 0o755); err != nil { - t.Fatal(err) - } - if err := writeR1GitHubMeshRemotes(workspace, "mnemon-dev/mnemon-teamwork-example", tokenFile, branches, i); err != nil { - t.Fatal(err) - } - agents = append(agents, r1CodexSyncAgent{r1CodexAgent: r1CodexAgent{ - principal: "codex-0" + string(rune('1'+i)) + "@project", - workspace: workspace, - }}) - } - - report := buildR1GitHubMeshSyncReport("mnemon-dev/mnemon-teamwork-example", agents) - if report.Backend != exchange.RemoteBackendGitHub || report.Repo != "mnemon-dev/mnemon-teamwork-example" || report.HubURL != "" { - t.Fatalf("report backend/repo/hub wrong: %+v", report) - } - if report.TransportModel != "repo-mediated-publication" || report.RosterSource != "configured-remotes-json" || report.NetworkDiscovery != "none" { - t.Fatalf("report must pin github bootstrap semantics without p2p discovery: %+v", report) - } - if len(report.PublicationBranches) != 3 || len(report.BranchByAgent) != 3 { - t.Fatalf("report branches wrong: %+v", report) - } - if len(report.RemotePlanPaths) != 3 || !distinctStrings(report.RemotePlanPaths) { - t.Fatalf("report must expose one remotes.json per workspace, got %+v", report.RemotePlanPaths) - } - if !distinctStrings(report.RuntimeWorkspaces) || !distinctStrings(report.LocalStorePaths) { - t.Fatalf("report must prove isolated workspaces/stores: workspaces=%v stores=%v", report.RuntimeWorkspaces, report.LocalStorePaths) - } - for i, storePath := range report.LocalStorePaths { - want := filepath.Join(agents[i].workspace, runtime.DefaultStorePath) - if storePath != want { - t.Fatalf("store path[%d] = %q, want %q", i, storePath, want) - } - } -} - -func TestR1GitHubMeshStrictTopologyRequiresNoHub(t *testing.T) { - top := &r1AcceptanceTopologyReport{ - Mode: "per-hostagent-mnemond", - Agents: 5, - MnemondInstances: 5, - MnemonhubInstances: 0, - SharedMnemond: false, - AgentMnemondMap: map[string]string{ - "a": "/tmp/a.db", - "b": "/tmp/b.db", - "c": "/tmp/c.db", - "d": "/tmp/d.db", - "e": "/tmp/e.db", - }, - } - if !r1GitHubMeshStrictTopology(top) { - t.Fatalf("github mesh topology should pass without a central hub: %+v", top) - } - top.MnemonhubInstances = 1 - if r1GitHubMeshStrictTopology(top) { - t.Fatal("github mesh topology must reject central mnemon-hub instances") - } -} - -func TestPopulateR1GitHubMeshSyncEvidence(t *testing.T) { - report := &r1CodexAcceptanceReport{Sync: &r1CodexSyncReport{ - Agents: []r1CodexAgentReport{ - {Principal: "codex-01@project"}, - {Principal: "codex-02@project"}, - }, - BranchByAgent: map[string]string{ - "codex-01@project": "mnemon/mnemond-run-a", - "codex-02@project": "mnemon/mnemond-run-b", - }, - }} - obs := acceptanceObserveReport{Stores: []acceptanceStoreInspect{ - { - Name: "codex-01", - Role: "mnemond", - Counts: map[string]int{"imported_accepted": 4}, - SyncEventsByStatus: map[string]int{"synced": 3}, - ObservedByType: map[string]int{"sync.diagnostic": 1}, - EnvelopeByType: map[string]int{"agent_profile.accepted": 5}, - }, - { - Name: "codex-02", - Role: "mnemond", - Counts: map[string]int{"imported_accepted": 2}, - SyncEventsByStatus: map[string]int{"synced": 1}, - ObservedByType: map[string]int{"sync.remote_diagnostic.observed": 2}, - EnvelopeByType: map[string]int{"agent_profile.accepted": 3}, - }, - }} - - populateR1GitHubMeshSyncEvidence(report, obs) - - if got := report.Sync.PublishedByBranch["mnemon/mnemond-run-a"]; got != 3 { - t.Fatalf("published branch a = %d, want 3", got) - } - if got := report.Sync.ImportedByMnemond["codex-02@project"]; got != 2 { - t.Fatalf("imported codex-02 = %d, want 2", got) - } - if got := report.Sync.DiagnosticsByMnemond["codex-02@project"]; got != 2 { - t.Fatalf("diagnostics codex-02 = %d, want 2", got) - } - if got := report.Sync.ProfileByMnemond["codex-01@project"]; got != 5 { - t.Fatalf("profiles codex-01 = %d, want 5", got) - } -} - -func TestR1GitHubMeshScenarioContract(t *testing.T) { - names := r1GitHubMeshScenarioNames(nil) - want := []string{"onboarding-synthesis", "sync-risk-review", "live-readiness-operator-safety"} - if len(names) != len(want) { - t.Fatalf("default scenarios = %v, want %v", names, want) - } - for i := range want { - if names[i] != want[i] { - t.Fatalf("default scenarios = %v, want %v", names, want) - } - } - run := r1GitHubMeshRun{agents: make([]r1CodexSyncAgent, 5)} - entries, err := run.scenarioEntries("live-readiness-operator-safety") - if err != nil { - t.Fatalf("multi-poc scenario entries: %v", err) - } - if len(entries) != 2 || entries[0].index == entries[1].index { - t.Fatalf("live-readiness scenario must use two distinct PoC agents, got %+v", entries) - } - for _, entry := range entries { - for _, forbidden := range []string{"Agent A must", "Agent B must", "must create assignments for"} { - if strings.Contains(entry.prompt, forbidden) { - t.Fatalf("natural prompt contains forced choreography %q: %s", forbidden, entry.prompt) - } - } - } - if !strings.Contains(r1GitHubMeshWorkerWakePrompt, "agent_profile") { - t.Fatal("github mesh worker wake prompt should let agents naturally refresh their profile") - } -} - -func TestR1GitHubMeshScenarioStatusRequiresSelectedOK(t *testing.T) { - scenarios := []r1TaskSimScenarioReport{ - {Name: "onboarding-synthesis", Status: "ok"}, - {Name: "sync-risk-review", Status: "failed"}, - {Name: "live-readiness-operator-safety", Status: "ok"}, - } - if allR1GitHubMeshScenariosOK(scenarios, nil) { - t.Fatal("default scenario set must require every selected scenario to be ok") - } - scenarios[1].Status = "ok" - if !allR1GitHubMeshScenariosOK(scenarios, nil) { - t.Fatal("default scenario set should pass when every default scenario is ok") - } - if !allR1GitHubMeshScenariosOK(scenarios[:1], []string{"onboarding-synthesis"}) { - t.Fatal("explicit scenario selection should require only the selected scenario") - } -} - -func TestR1GitHubMeshNaturalSuiteEvidenceHelpers(t *testing.T) { - scenarios := []r1TaskSimScenarioReport{ - {Name: "onboarding-synthesis", Status: "ok", Evidence: map[string]any{"replanning_rounds": 3}}, - {Name: "sync-risk-review", Status: "ok", Evidence: map[string]any{"cross_task_reuse_or_completion": true}}, - {Name: "live-readiness-operator-safety", Status: "ok", Evidence: map[string]any{"multi_poc": true}}, - } - prior := r1GitHubMeshOKScenarioNames(scenarios[:1]) - if !r1GitHubMeshCrossTaskReuseCandidate("sync-risk-review", prior) { - t.Fatal("sync-risk-review should be a cross-task reuse candidate after onboarding-synthesis") - } - if r1GitHubMeshCrossTaskReuseCandidate("sync-risk-review", nil) { - t.Fatal("sync-risk-review without prior onboarding should not claim cross-task reuse") - } - if !r1GitHubMeshHasOKScenarioEvidenceBool(scenarios, "live-readiness-operator-safety", "multi_poc") { - t.Fatal("multi-poc evidence should be detected on the successful live-readiness scenario") - } - if !r1GitHubMeshHasOKScenarioEvidenceBool(scenarios, "sync-risk-review", "cross_task_reuse_or_completion") { - t.Fatal("cross-task evidence should be detected on the successful sync-risk scenario") - } - if !r1GitHubMeshHasAnyOKScenarioEvidenceIntAtLeast(scenarios, "replanning_rounds", 2) { - t.Fatal("replanning evidence should accept int counts") - } - scenarios[0].Evidence["replanning_rounds"] = float64(2) - if !r1GitHubMeshHasAnyOKScenarioEvidenceIntAtLeast(scenarios, "replanning_rounds", 2) { - t.Fatal("replanning evidence should accept json-decoded float64 counts") - } - scenarios[2].Status = "failed" - if r1GitHubMeshHasOKScenarioEvidenceBool(scenarios, "live-readiness-operator-safety", "multi_poc") { - t.Fatal("failed scenarios must not satisfy evidence assertions") - } -} - -func TestR1GitHubMeshPromptRoundsCountsScenarioPrompts(t *testing.T) { - contract := &r1RunnerContractReport{} - recordR1ClusterPrompt(contract, "codex-01@project", "natural_user_message:onboarding-synthesis", "prompt") - recordR1ClusterPrompt(contract, "codex-02@project", "worker_wake:onboarding-synthesis", "wake") - recordR1ClusterPrompt(contract, "codex-01@project", "integration:onboarding-synthesis", "integrate") - recordR1ClusterPrompt(contract, "codex-01@project", "integration:other", "other") - if got := r1GitHubMeshPromptRounds(contract, "onboarding-synthesis"); got != 3 { - t.Fatalf("prompt rounds = %d, want 3", got) - } - if got := r1GitHubMeshPromptKindCount(contract, "worker_wake:onboarding-synthesis"); got != 1 { - t.Fatalf("worker wake prompt count = %d, want 1", got) - } -} - -func TestR1GitHubMeshEntrySeedTimeoutIsBounded(t *testing.T) { - if got := r1GitHubMeshEntrySeedTimeout(8 * time.Minute); got != 8*time.Minute { - t.Fatalf("seed timeout = %s, want full turn timeout", got) - } - if got := r1GitHubMeshEntrySeedTimeout(2 * time.Minute); got != 2*time.Minute { - t.Fatalf("short seed timeout = %s, want full short turn timeout", got) - } - if got := r1GitHubMeshEntrySeedTimeout(0); got != 5*time.Minute { - t.Fatalf("default seed timeout = %s, want 5m", got) - } -} - -func TestR1GitHubMeshEntrySeedReadyUsesAssignment(t *testing.T) { - if !r1GitHubMeshEntrySeedReady(map[string]int{"assignment": 1}) { - t.Fatal("assignment should be enough to seed worker handoff") - } - if r1GitHubMeshEntrySeedReady(map[string]int{"teamwork_signal": 1}) { - t.Fatal("signal without assignment should not seed worker handoff") - } -} - -func TestR1GitHubMeshProfileConvergenceTimeoutScalesWithSyncInterval(t *testing.T) { - if got := r1GitHubMeshProfileConvergenceTimeout(30 * time.Second); got != 150*time.Second { - t.Fatalf("30s sync convergence timeout = %s, want 150s", got) - } - if got := r1GitHubMeshProfileConvergenceTimeout(90 * time.Second); got != 6*time.Minute { - t.Fatalf("90s sync convergence timeout = %s, want capped 6m", got) - } - if got := r1GitHubMeshProfileConvergenceTimeout(0); got != 120*time.Second { - t.Fatalf("default convergence timeout = %s, want 120s", got) - } -} - -func TestR1GitHubMeshIntegrationAgentSkipsBusyEntrypoint(t *testing.T) { - agents := []r1CodexSyncAgent{ - {r1CodexAgent: r1CodexAgent{principal: "codex-01@project", server: codexapp.New("codex", t.TempDir()), threadID: "thread-1"}, localCancel: func() {}}, - {r1CodexAgent: r1CodexAgent{principal: "codex-02@project", server: codexapp.New("codex", t.TempDir()), threadID: "thread-2"}, localCancel: func() {}}, - {r1CodexAgent: r1CodexAgent{principal: "codex-03@project", server: codexapp.New("codex", t.TempDir()), threadID: "thread-3"}, localCancel: func() {}}, - } - entries := []r1GitHubMeshScenarioEntry{{index: 0}} - if got := r1GitHubMeshIntegrationAgentIndex(agents, entries, nil); got != 0 { - t.Fatalf("integration agent = %d, want entrypoint when idle", got) - } - if got := r1GitHubMeshIntegrationAgentIndex(agents, entries, map[int]bool{0: true}); got != 1 { - t.Fatalf("integration agent = %d, want first idle teammate", got) - } - if got := r1GitHubMeshIntegrationAgentIndex(agents, entries, map[int]bool{0: true, 1: true, 2: true}); got != -1 { - t.Fatalf("integration agent = %d, want none when all ready agents are busy", got) - } -} - -func TestR1GitHubMeshKindTotalCountsGovernedEvents(t *testing.T) { - counts := map[string]map[string]int{ - "codex-01@project": {"assignment": 2, "progress_digest": 1}, - "codex-02@project": {"assignment": 1, "teamwork_signal": 1}, - } - if got := r1GitHubMeshKindTotal(counts, "assignment"); got != 3 { - t.Fatalf("assignment total = %d, want 3", got) - } - if got := r1GitHubMeshKindTotal(counts, "progress_digest"); got != 1 { - t.Fatalf("progress total = %d, want 1", got) - } - if got := r1GitHubMeshKindTotal(counts, "project_intent"); got != 0 { - t.Fatalf("missing kind total = %d, want 0", got) - } -} - -func TestR1GitHubMeshTeamEvidenceCountsReady(t *testing.T) { - counts := map[string]map[string]int{ - "codex-01@project": {"assignment": 2}, - "codex-02@project": {"progress_digest": 1}, - "codex-03@project": {"progress_digest": 1}, - } - if !r1GitHubMeshTeamEvidenceCountsReady(counts) { - t.Fatalf("team evidence should be ready for two non-profile participants: %+v", counts) - } - counts = map[string]map[string]int{ - "codex-01@project": {"assignment": 2}, - "codex-02@project": {"agent_profile": 1}, - } - if r1GitHubMeshTeamEvidenceCountsReady(counts) { - t.Fatalf("team evidence should require progress beyond assignment publication: %+v", counts) - } -} - -func TestR1GitHubMeshAuthoredEventCountsUseLocalSyncEvents(t *testing.T) { - root := t.TempDir() - workspace := filepath.Join(root, "workspaces", "codex-01") - storePath := filepath.Join(workspace, runtime.DefaultStorePath) - if err := os.MkdirAll(filepath.Dir(storePath), 0o755); err != nil { - t.Fatal(err) - } - db, err := sql.Open("sqlite", storePath) - if err != nil { - t.Fatal(err) - } - defer db.Close() - if _, err := db.Exec(`CREATE TABLE sync_events(actor TEXT, resource_kind TEXT, status TEXT)`); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`INSERT INTO sync_events(actor, resource_kind, status) VALUES - ('codex-01@project', 'assignment', 'synced'), - ('codex-01@project', 'assignment', 'pending'), - ('codex-02@project', 'progress_digest', 'synced'), - ('', 'progress_digest', 'synced')`); err != nil { - t.Fatal(err) - } - - counts, warnings := r1GitHubMeshAuthoredEventCounts([]r1CodexSyncAgent{{ - r1CodexAgent: r1CodexAgent{principal: "codex-01@project", workspace: workspace}, - }}) - if len(warnings) != 0 { - t.Fatalf("warnings = %v", warnings) - } - if got := counts["codex-01@project"]["assignment"]; got != 2 { - t.Fatalf("codex-01 assignment count = %d, want 2", got) - } - if got := counts["codex-02@project"]["progress_digest"]; got != 1 { - t.Fatalf("codex-02 progress count = %d, want 1", got) - } - if _, ok := counts[""]; ok { - t.Fatal("blank actors must not be counted") - } -} diff --git a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go index b584cda3..35407032 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go +++ b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go @@ -19,11 +19,9 @@ import ( ) var ( - acceptanceManagedExchange string - acceptanceManagedMnemondBin string - acceptanceManagedRuntimeAdapter string - acceptanceManagedGitHubRepo string - acceptanceManagedGitHubTokenFile string + acceptanceManagedExchange string + acceptanceManagedMnemondBin string + acceptanceManagedRuntimeAdapter string ) var acceptanceManagedRuntimeCmd = &cobra.Command{ @@ -31,19 +29,17 @@ var acceptanceManagedRuntimeCmd = &cobra.Command{ Short: "Run managed-runtime seed-and-observe acceptance", RunE: func(cmd *cobra.Command, args []string) error { report, err := runManagedRuntimeAcceptance(cmd.Context(), managedRuntimeAcceptanceOptions{ - RunRoot: acceptanceRunRoot, - Command: acceptanceCommand, - CodexHome: acceptanceCodexHome, - Agents: acceptanceAgents, - AgentTurns: acceptanceAgentTurns, - Exchange: acceptanceManagedExchange, - MnemondBin: acceptanceManagedMnemondBin, - Runtime: acceptanceManagedRuntimeAdapter, - GitHubRepo: acceptanceManagedGitHubRepo, - GitHubTokenFile: acceptanceManagedGitHubTokenFile, - TurnTimeout: acceptanceTurnTimeout, - Stdout: cmd.OutOrStdout(), - Stderr: cmd.ErrOrStderr(), + RunRoot: acceptanceRunRoot, + Command: acceptanceCommand, + CodexHome: acceptanceCodexHome, + Agents: acceptanceAgents, + AgentTurns: acceptanceAgentTurns, + Exchange: acceptanceManagedExchange, + MnemondBin: acceptanceManagedMnemondBin, + Runtime: acceptanceManagedRuntimeAdapter, + TurnTimeout: acceptanceTurnTimeout, + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), }) if report.ReportPath != "" { fmt.Fprintf(cmd.OutOrStdout(), "acceptance report: %s\n", report.ReportPath) @@ -64,30 +60,26 @@ func init() { acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceCodexHome, "codex-home-source", "", "source CODEX_HOME to copy auth/config from") acceptanceManagedRuntimeCmd.Flags().IntVar(&acceptanceAgents, "agents", 5, "number of managed agent nodes") acceptanceManagedRuntimeCmd.Flags().BoolVar(&acceptanceAgentTurns, "agent-turns", false, "run real managed Codex turns after the seed") - acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedExchange, "exchange", "mnemonhub", "exchange mode: mnemonhub or github") + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedExchange, "exchange", "mnemonhub", "exchange mode: mnemonhub") acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedMnemondBin, "mnemond-bin", "mnemond", "mnemond binary used for product-path wake checks") acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedRuntimeAdapter, "runtime", "codex-appserver", "managed runtime adapter: codex-appserver or noop") - acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedGitHubRepo, "github-repo", "mnemon-dev/mnemon-teamwork-example", "GitHub Remote Workspace repository (owner/name)") - acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedGitHubTokenFile, "github-token-file", "", "GitHub token file for real GitHub exchange validation") acceptanceManagedRuntimeCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per managed wake check") rootCmd.AddCommand(acceptanceManagedRuntimeCmd) } type managedRuntimeAcceptanceOptions struct { - RunRoot string - Command string - CodexHome string - Agents int - AgentTurns bool - Exchange string - MnemondBin string - Runtime string - GitHubRepo string - GitHubTokenFile string - TurnTimeout time.Duration - Stdout io.Writer - Stderr io.Writer - Wake managedRuntimeWakeFunc + RunRoot string + Command string + CodexHome string + Agents int + AgentTurns bool + Exchange string + MnemondBin string + Runtime string + TurnTimeout time.Duration + Stdout io.Writer + Stderr io.Writer + Wake managedRuntimeWakeFunc } type managedRuntimeWakeFunc func(context.Context, managedRuntimeAcceptanceOptions, string) (string, error) @@ -98,7 +90,6 @@ type managedRuntimeAcceptanceReport struct { Layer string `json:"layer"` RunnerRole string `json:"runner_role"` Exchange string `json:"exchange"` - GitHubRepo string `json:"github_repo,omitempty"` Runtime string `json:"runtime"` StartedAt string `json:"started_at"` FinishedAt string `json:"finished_at"` @@ -152,9 +143,6 @@ func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAccepta if opts.TurnTimeout <= 0 { opts.TurnTimeout = 5 * time.Minute } - if strings.TrimSpace(opts.GitHubRepo) == "" { - opts.GitHubRepo = "mnemon-dev/mnemon-teamwork-example" - } exchangeMode := strings.TrimSpace(opts.Exchange) if exchangeMode == "" { exchangeMode = "mnemonhub" @@ -178,7 +166,6 @@ func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAccepta Layer: "managed_runtime_acceptance", RunnerRole: "seed_and_observe", Exchange: exchangeMode, - GitHubRepo: strings.TrimSpace(opts.GitHubRepo), Runtime: runtimeName, StartedAt: started.Format(time.RFC3339), RunRoot: runRoot, @@ -189,8 +176,8 @@ func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAccepta report.Errors = append(report.Errors, err.Error()) return finishManagedRuntimeReport(report), err } - if exchangeMode != "mnemonhub" && exchangeMode != "github" { - err := fmt.Errorf("managed-runtime acceptance exchange must be mnemonhub or github, got %q", exchangeMode) + if exchangeMode != "mnemonhub" { + err := fmt.Errorf("managed-runtime acceptance exchange must be mnemonhub, got %q", exchangeMode) report.Status = "blocked" report.Errors = append(report.Errors, err.Error()) written, writeErr := finishAndWriteManagedRuntimeReport(report) @@ -204,17 +191,6 @@ func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAccepta report.Errors = append(report.Errors, err.Error()) return finishManagedRuntimeReport(report), err } - if exchangeMode == "github" && strings.TrimSpace(opts.GitHubTokenFile) == "" { - err := fmt.Errorf("managed-runtime github acceptance requires --github-token-file for real GitHub access") - report.Status = "blocked" - report.Errors = append(report.Errors, err.Error()) - addManagedAssertion(&report, "github token file provided", false, err.Error()) - written, writeErr := finishAndWriteManagedRuntimeReport(report) - if writeErr != nil { - return written, writeErr - } - return written, err - } if opts.Wake != nil { return runManagedRuntimeInjectedWakeAcceptance(ctx, opts, report) } @@ -229,14 +205,7 @@ func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAccepta } return written, err } - switch exchangeMode { - case "mnemonhub": - return runManagedRuntimeMnemonhubAcceptance(ctx, opts, report) - case "github": - return runManagedRuntimeGitHubAcceptance(ctx, opts, report) - default: - panic("validated exchange mode escaped switch") - } + return runManagedRuntimeMnemonhubAcceptance(ctx, opts, report) } func runManagedRuntimeInjectedWakeAcceptance(ctx context.Context, opts managedRuntimeAcceptanceOptions, report managedRuntimeAcceptanceReport) (managedRuntimeAcceptanceReport, error) { @@ -293,45 +262,6 @@ func runManagedRuntimeMnemonhubAcceptance(ctx context.Context, opts managedRunti return runManagedRuntimeRealScenario(ctx, opts, report, agents) } -func runManagedRuntimeGitHubAcceptance(ctx context.Context, opts managedRuntimeAcceptanceOptions, report managedRuntimeAcceptanceReport) (managedRuntimeAcceptanceReport, error) { - tokenFile, err := filepath.Abs(opts.GitHubTokenFile) - if err != nil { - return managedRuntimeBlocked(report, err) - } - if _, err := os.Stat(tokenFile); err != nil { - return managedRuntimeBlocked(report, fmt.Errorf("github token file: %w", err)) - } - binDir, err := installAcceptanceHarnessBinary(report.RunRoot) - if err != nil { - return managedRuntimeBlocked(report, err) - } - started, _ := time.Parse(time.RFC3339, report.StartedAt) - branchPrefix := r1GitHubMeshBranchPrefix("", started) - branches := r1GitHubMeshBranches(branchPrefix, opts.Agents) - if err := ensureR1GitHubMeshBranches(ctx, report.GitHubRepo, tokenFile, branches); err != nil { - return managedRuntimeBlocked(report, err) - } - sourceCodexHome := resolveSourceCodexHome(opts.CodexHome) - report.Artifacts["codex_home_source"] = sourceCodexHome - report.Artifacts["github_repo"] = report.GitHubRepo - report.Artifacts["github_token_file"] = tokenFile - report.Artifacts["github_branch_prefix"] = branchPrefix - agents, err := setupR1CodexGitHubMeshAgents(ctx, report.RunRoot, binDir, report.GitHubRepo, tokenFile, branchPrefix, opts.Agents, sourceCodexHome, 30*time.Second, opts.Agents) - if err != nil { - return managedRuntimeBlocked(report, err) - } - defer stopR1CodexSyncAgents(agents) - report.Topology = buildR1ProdSimTopology(agents) - report.Topology.MnemonhubInstances = 0 - addManagedAssertion(&report, "managed-runtime github per-hostagent mnemond topology", r1GitHubMeshStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) - addManagedAssertion(&report, "managed-runtime github has no central hub", report.Topology.MnemonhubInstances == 0, "backend=github") - for _, agent := range agents { - report.Artifacts["mnemond:"+agent.principal] = prodSimMnemondPath(agent) - report.Artifacts["render_audit:"+agent.principal] = agent.renderAuditPath - } - return runManagedRuntimeRealScenario(ctx, opts, report, agents) -} - func runManagedRuntimeRealScenario(ctx context.Context, opts managedRuntimeAcceptanceOptions, report managedRuntimeAcceptanceReport, agents []r1CodexSyncAgent) (managedRuntimeAcceptanceReport, error) { if len(agents) < 5 { return managedRuntimeFailed(report, fmt.Errorf("managed-runtime requires at least 5 agents, got %d", len(agents))) diff --git a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go index d7688f45..67e77ac6 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go +++ b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go @@ -67,7 +67,7 @@ func TestManagedRuntimeAcceptanceRejectsNonSentinelWake(t *testing.T) { } } -func TestManagedRuntimeGitHubRequiresTokenFile(t *testing.T) { +func TestManagedRuntimeRejectsGitHubExchange(t *testing.T) { report, err := runManagedRuntimeAcceptance(context.Background(), managedRuntimeAcceptanceOptions{ RunRoot: t.TempDir(), Agents: 1, @@ -77,10 +77,10 @@ func TestManagedRuntimeGitHubRequiresTokenFile(t *testing.T) { }, }) if err == nil { - t.Fatal("github managed acceptance without token file should return an explicit blocker") + t.Fatal("github managed acceptance should be unsupported") } - if report.Status != "blocked" || !strings.Contains(err.Error(), "--github-token-file") { - t.Fatalf("github blocker mismatch: status=%s err=%v report=%+v", report.Status, err, report) + if report.Status != "blocked" || !strings.Contains(err.Error(), "exchange must be mnemonhub") { + t.Fatalf("github exchange blocker mismatch: status=%s err=%v report=%+v", report.Status, err, report) } } diff --git a/harness/cmd/mnemon-acceptance/root_test.go b/harness/cmd/mnemon-acceptance/root_test.go index d4279366..c11de5c9 100644 --- a/harness/cmd/mnemon-acceptance/root_test.go +++ b/harness/cmd/mnemon-acceptance/root_test.go @@ -13,7 +13,6 @@ func TestRootExposesAcceptanceScenarioCommands(t *testing.T) { "r1-prod-sim", "r1-task-sim", "r1-cluster-single-entrypoint", - "r1-github-mesh-task-suite", "multica-provision", "multica-runtime-prod-sim", } { diff --git a/harness/cmd/mnemon-acceptance/sync_remote.go b/harness/cmd/mnemon-acceptance/sync_remote.go index 4a37723f..d91364b1 100644 --- a/harness/cmd/mnemon-acceptance/sync_remote.go +++ b/harness/cmd/mnemon-acceptance/sync_remote.go @@ -10,7 +10,7 @@ import ( "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" ) -func upsertSyncRemote(path, root, id, backend, direction, endpoint, repo, branch, token, tokenFile, caFile string) error { +func upsertSyncRemote(path, root, id, backend, direction, endpoint, token, tokenFile, caFile string) error { doc := exchange.RemotesDoc{SchemaVersion: 1} if raw, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(raw))) > 0 { if err := json.Unmarshal(raw, &doc); err != nil { @@ -31,8 +31,6 @@ func upsertSyncRemote(path, root, id, backend, direction, endpoint, repo, branch Direction: direction, ID: id, Endpoint: endpoint, - Repo: repo, - Branch: branch, CredentialRef: credentialRef, CAFile: normalizeSyncFileRef(caFile), } diff --git a/harness/cmd/mnemon-harness/config_daemon_test.go b/harness/cmd/mnemon-harness/config_daemon_test.go index cc65d0f6..0b5582cb 100644 --- a/harness/cmd/mnemon-harness/config_daemon_test.go +++ b/harness/cmd/mnemon-harness/config_daemon_test.go @@ -87,9 +87,8 @@ func TestDaemonStatusShowsConfiguredRoleSummary(t *testing.T) { root := t.TempDir() cfg := productconfig.Default() cfg.Connections.Multica = productconfig.MulticaConnection{Enabled: true, Workspace: "ws-multica", RuntimeBinary: "mnemon-multica-runtime"} - cfg.Connections.GitHub = productconfig.GitHubConnection{Enabled: true, Repo: "mnemon-dev/mnemon-teamwork-example"} cfg.Connections.Mnemonhub = productconfig.MnemonhubConnection{Enabled: true, Endpoint: "https://hub.example.invalid"} - cfg.Daemon.InteractionWatchers = []string{productconfig.ConnectionMultica, productconfig.ConnectionGitHub, productconfig.ConnectionMnemonhub} + cfg.Daemon.InteractionWatchers = []string{productconfig.ConnectionMultica, productconfig.ConnectionMnemonhub} cfg.Daemon.DriveSources = []string{productconfig.DriveManagedLocal} cfg.Daemon.DisplaySurfaces = []string{productconfig.ConnectionMultica} if err := productconfig.Save(productconfig.DefaultPath(root, ""), cfg); err != nil { @@ -106,10 +105,9 @@ func TestDaemonStatusShowsConfiguredRoleSummary(t *testing.T) { got := out.String() for _, want := range []string{ "Harness config: configured", - "Harness daemon roles: watchers=3 drive=1 surfaces=1", + "Harness daemon roles: watchers=2 drive=1 surfaces=1", "Harness daemon role details:", "multica-watch [interaction]: watcher=multica boundary=activation-carrier", - "github-watch [interaction]: watcher=github boundary=external-interaction", "mnemonhub-watch [interaction]: watcher=mnemonhub boundary=remote-exchange", "managed-drive [drive]: drive=managed-local boundary=managed-runtime", "multica-display [surface]: surface=multica boundary=display-surface", @@ -286,19 +284,15 @@ func TestConnectCommandsWriteProductConfig(t *testing.T) { root := t.TempDir() oldRoot, oldPath := connectRoot, connectConfigPath oldWorkspace, oldRuntime := connectMulticaWS, connectMulticaRuntime - oldRepo, oldBranch := connectGitHubRepo, connectGitHubBranch oldEndpoint := connectMnemonhubURL connectRoot = root connectConfigPath = "" connectMulticaWS = "teamwork-grivn" connectMulticaRuntime = "mnemon-multica-runtime" - connectGitHubRepo = "mnemon-dev/mnemon-teamwork-example" - connectGitHubBranch = "mnemond-planner" connectMnemonhubURL = "https://hub.example.invalid" t.Cleanup(func() { connectRoot, connectConfigPath = oldRoot, oldPath connectMulticaWS, connectMulticaRuntime = oldWorkspace, oldRuntime - connectGitHubRepo, connectGitHubBranch = oldRepo, oldBranch connectMnemonhubURL = oldEndpoint }) @@ -306,9 +300,6 @@ func TestConnectCommandsWriteProductConfig(t *testing.T) { if err := runConnectMultica(cmd, nil); err != nil { t.Fatal(err) } - if err := runConnectGitHub(cmd, nil); err != nil { - t.Fatal(err) - } if err := runConnectMnemonhub(cmd, nil); err != nil { t.Fatal(err) } @@ -320,18 +311,15 @@ func TestConnectCommandsWriteProductConfig(t *testing.T) { if got := cfg.Connections.Multica.RuntimeBinary; got != "mnemon-multica-runtime" { t.Fatalf("unexpected runtime binary: %q", got) } - if !cfg.Connections.GitHub.Enabled || cfg.Connections.GitHub.Repo != "mnemon-dev/mnemon-teamwork-example" { - t.Fatalf("github connection not written: %+v", cfg.Connections.GitHub) - } if !cfg.Connections.Mnemonhub.Enabled || cfg.Connections.Mnemonhub.Endpoint != "https://hub.example.invalid" { t.Fatalf("mnemonhub connection not written: %+v", cfg.Connections.Mnemonhub) } - for _, want := range []string{productconfig.ConnectionMultica, productconfig.ConnectionGitHub, productconfig.ConnectionMnemonhub} { + for _, want := range []string{productconfig.ConnectionMultica, productconfig.ConnectionMnemonhub} { if !containsString(cfg.Daemon.InteractionWatchers, want) { t.Fatalf("interaction watcher %q missing: %+v", want, cfg.Daemon.InteractionWatchers) } } - for _, want := range []string{productconfig.ConnectionMultica, productconfig.ConnectionGitHub} { + for _, want := range []string{productconfig.ConnectionMultica} { if !containsString(cfg.Daemon.DisplaySurfaces, want) { t.Fatalf("display surface %q missing: %+v", want, cfg.Daemon.DisplaySurfaces) } diff --git a/harness/cmd/mnemon-harness/connect.go b/harness/cmd/mnemon-harness/connect.go index 760dc2b6..f0cc37dc 100644 --- a/harness/cmd/mnemon-harness/connect.go +++ b/harness/cmd/mnemon-harness/connect.go @@ -13,8 +13,6 @@ var ( connectConfigPath string connectMulticaWS string connectMulticaRuntime string - connectGitHubRepo string - connectGitHubBranch string connectMnemonhubURL string ) @@ -29,12 +27,6 @@ var connectMulticaCmd = &cobra.Command{ RunE: runConnectMultica, } -var connectGitHubCmd = &cobra.Command{ - Use: "github --repo OWNER/REPO", - Short: "Connect GitHub as a harness connection", - RunE: runConnectGitHub, -} - var connectMnemonhubCmd = &cobra.Command{ Use: "mnemonhub --endpoint URL", Short: "Connect mnemonhub as a harness connection", @@ -48,10 +40,8 @@ func init() { connectMulticaCmd.Flags().StringVar(&connectMulticaWS, "workspace", "", "Multica workspace") connectMulticaCmd.Flags().StringVar(&connectMulticaRuntime, "runtime-binary", productconfig.DefaultMulticaRuntimeBinary, "Multica runtime binary") _ = connectMulticaCmd.Flags().MarkHidden("runtime-binary") - connectGitHubCmd.Flags().StringVar(&connectGitHubRepo, "repo", "", "GitHub repository owner/name") - connectGitHubCmd.Flags().StringVar(&connectGitHubBranch, "branch", "", "GitHub publication branch") connectMnemonhubCmd.Flags().StringVar(&connectMnemonhubURL, "endpoint", "", "mnemonhub endpoint") - connectCmd.AddCommand(connectMulticaCmd, connectGitHubCmd, connectMnemonhubCmd) + connectCmd.AddCommand(connectMulticaCmd, connectMnemonhubCmd) connectCmd.GroupID = groupSpine rootCmd.AddCommand(connectCmd) } @@ -83,30 +73,6 @@ func runConnectMultica(cmd *cobra.Command, args []string) error { return nil } -func runConnectGitHub(cmd *cobra.Command, args []string) error { - if strings.TrimSpace(connectGitHubRepo) == "" { - return fmt.Errorf("connect github requires --repo") - } - cfg, _, err := loadHarnessProductConfig(connectRoot, connectConfigPath) - if err != nil { - return err - } - cfg.Connections.GitHub = productconfig.GitHubConnection{ - Enabled: true, - Repo: strings.TrimSpace(connectGitHubRepo), - Branch: strings.TrimSpace(connectGitHubBranch), - } - cfg.Daemon.InteractionWatchers = appendUniqueString(cfg.Daemon.InteractionWatchers, productconfig.ConnectionGitHub) - cfg.Daemon.DisplaySurfaces = appendUniqueString(cfg.Daemon.DisplaySurfaces, productconfig.ConnectionGitHub) - path, err := saveHarnessProductConfig(connectRoot, connectConfigPath, cfg) - if err != nil { - return err - } - fmt.Fprintln(cmd.OutOrStdout(), "Connection: github ready") - fmt.Fprintf(cmd.OutOrStdout(), "Harness config: %s\n", path) - return nil -} - func runConnectMnemonhub(cmd *cobra.Command, args []string) error { if strings.TrimSpace(connectMnemonhubURL) == "" { return fmt.Errorf("connect mnemonhub requires --endpoint") diff --git a/harness/cmd/mnemon-harness/doctor.go b/harness/cmd/mnemon-harness/doctor.go index 00ec894e..efb65e28 100644 --- a/harness/cmd/mnemon-harness/doctor.go +++ b/harness/cmd/mnemon-harness/doctor.go @@ -66,9 +66,6 @@ func doctorConnections(cfg productconfig.Config) string { if cfg.Connections.Multica.Enabled { out = append(out, "multica") } - if cfg.Connections.GitHub.Enabled { - out = append(out, "github") - } if cfg.Connections.Mnemonhub.Enabled { out = append(out, "mnemonhub") } diff --git a/harness/cmd/mnemon-harness/doctor_test.go b/harness/cmd/mnemon-harness/doctor_test.go index 97002a63..d58975b0 100644 --- a/harness/cmd/mnemon-harness/doctor_test.go +++ b/harness/cmd/mnemon-harness/doctor_test.go @@ -36,7 +36,6 @@ func TestDoctorReportsConfiguredProductSurface(t *testing.T) { root := t.TempDir() cfg := productconfig.Default() cfg.Connections.Multica = productconfig.MulticaConnection{Enabled: true, Workspace: "ws-multica", RuntimeBinary: "mnemon-multica-runtime"} - cfg.Connections.GitHub = productconfig.GitHubConnection{Enabled: true, Repo: "mnemon-dev/mnemon-teamwork-example"} cfg.Participants = []productconfig.Participant{{ Principal: "planner@team", HostRuntime: productconfig.HostRuntime{ @@ -72,7 +71,7 @@ func TestDoctorReportsConfiguredProductSurface(t *testing.T) { "- Product config: configured", "- Participants: 1", "- Daemon roles: watchers=1 drive=1 surfaces=1", - "- Connections: multica,github", + "- Connections: multica", "- Local Mnemon config: missing", "- Daemon snapshot: workers=2", } { diff --git a/harness/cmd/mnemon-harness/sync.go b/harness/cmd/mnemon-harness/sync.go index 10084439..09045032 100644 --- a/harness/cmd/mnemon-harness/sync.go +++ b/harness/cmd/mnemon-harness/sync.go @@ -12,7 +12,6 @@ import ( "github.com/mnemon-dev/mnemon/harness/internal/contract" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" - githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" "github.com/mnemon-dev/mnemon/harness/internal/runtime" "github.com/spf13/cobra" ) @@ -28,8 +27,6 @@ var ( syncRemoteToken string syncRemoteTokenFile string syncCAFile string - syncGitHubRepo string - syncGitHubBranch string syncAllowInsecure bool syncOnce bool syncBackground bool @@ -72,14 +69,12 @@ func init() { syncCmd.PersistentFlags().StringVar(&syncStorePath, "store", "", "Local Mnemon store path") syncCmd.PersistentFlags().StringVar(&syncRemotesPath, "remotes", "", "Remote Workspace config path") syncCmd.PersistentFlags().StringVar(&syncRemoteID, "remote", "default", "Remote Workspace id") - syncCmd.PersistentFlags().StringVar(&syncRemoteBackend, "backend", "", "Remote Workspace backend (http or github)") + syncCmd.PersistentFlags().StringVar(&syncRemoteBackend, "backend", "", "Remote Workspace backend (http)") syncCmd.PersistentFlags().StringVar(&syncRemoteDirection, "direction", "", "Remote Workspace direction (bidirectional, publish, or subscribe)") syncCmd.PersistentFlags().StringVar(&syncRemoteURL, "remote-url", "", "Remote Workspace sync endpoint") syncCmd.PersistentFlags().StringVar(&syncRemoteToken, "token", "", "Remote Workspace sync token") syncCmd.PersistentFlags().StringVar(&syncRemoteTokenFile, "token-file", "", "Remote Workspace sync token file") syncCmd.PersistentFlags().StringVar(&syncCAFile, "ca-file", "", "PEM bundle pinning the Remote Workspace TLS root (e.g. the mnemon-hub --dev-selfsigned cert)") - syncCmd.PersistentFlags().StringVar(&syncGitHubRepo, "github-repo", "", "GitHub Remote Workspace repository (owner/name)") - syncCmd.PersistentFlags().StringVar(&syncGitHubBranch, "github-branch", "", "GitHub Remote Workspace publication branch") syncCmd.PersistentFlags().BoolVar(&syncAllowInsecure, "allow-insecure-remote", false, "explicitly allow a plaintext http:// Remote Workspace endpoint with a non-loopback host (T2: fail-closed by default)") _ = syncCmd.PersistentFlags().MarkHidden("store") _ = syncCmd.PersistentFlags().MarkHidden("remotes") @@ -110,7 +105,6 @@ func runSyncConnect(cmd *cobra.Command, args []string) error { if err != nil { return err } - repo, branch := "", "" switch backend { case exchange.RemoteBackendHTTP: if endpoint == "" { @@ -122,15 +116,6 @@ func runSyncConnect(cmd *cobra.Command, args []string) error { if err := access.ValidateSyncEndpoint(endpoint, syncAllowInsecure); err != nil { return err } - case exchange.RemoteBackendGitHub: - repo, err = exchange.NormalizeGitHubRepo(syncGitHubRepo) - if err != nil { - return err - } - branch, err = exchange.NormalizePublicationBranch(syncGitHubBranch) - if err != nil { - return err - } default: return fmt.Errorf("unsupported Remote Workspace backend %q", backend) } @@ -141,7 +126,7 @@ func runSyncConnect(cmd *cobra.Command, args []string) error { if backend == exchange.RemoteBackendHTTP && direction == exchange.RemoteDirectionBidirectional { directionForWrite = "" } - if err := upsertSyncRemote(resolvedSyncRemotesPath(), syncProjectRoot(), workspace, backend, directionForWrite, endpoint, repo, branch, syncRemoteToken, syncRemoteTokenFile, syncCAFile); err != nil { + if err := upsertSyncRemote(resolvedSyncRemotesPath(), syncProjectRoot(), workspace, backend, directionForWrite, endpoint, syncRemoteToken, syncRemoteTokenFile, syncCAFile); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "Remote Workspace: connected %s\n", workspace) @@ -307,8 +292,6 @@ type syncRemoteConfig struct { ID string Backend string Endpoint string - Repo string - Branch string Token string CAFile string } @@ -333,19 +316,6 @@ func syncRemoteWorkspaceFor(remote syncRemoteConfig) (exchange.RemoteWorkspace, CAFile: remote.CAFile, AllowInsecure: syncAllowInsecure, }) - case exchange.RemoteBackendGitHub: - store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ - Repo: remote.Repo, - Token: remote.Token, - }) - if err != nil { - return nil, err - } - return githubbackend.New(githubbackend.Config{ - Store: store, - Repo: remote.Repo, - Branch: remote.Branch, - }) default: return nil, fmt.Errorf("Remote Workspace %q: unsupported backend %q", remote.ID, backend) } @@ -414,7 +384,7 @@ func resolveSyncRemoteEntry(entry exchange.RemoteEntry) (syncRemoteConfig, error if err != nil { return syncRemoteConfig{}, err } - return syncRemoteConfig{ID: entry.ID, Backend: entry.NormalizedBackend(), Endpoint: entry.Endpoint, Repo: entry.Repo, Branch: entry.Branch, Token: token, CAFile: resolvedSyncCAFile(entry.CAFile)}, nil + return syncRemoteConfig{ID: entry.ID, Backend: entry.NormalizedBackend(), Endpoint: entry.Endpoint, Token: token, CAFile: resolvedSyncCAFile(entry.CAFile)}, nil } // resolvedSyncCAFile picks the pinned-root file: the --ca-file flag overrides the remotes.json @@ -430,7 +400,7 @@ func resolvedSyncCAFile(entryCAFile string) string { return resolveSyncPath(caFile) } -func upsertSyncRemote(path, root, id, backend, direction, endpoint, repo, branch, token, tokenFile, caFile string) error { +func upsertSyncRemote(path, root, id, backend, direction, endpoint, token, tokenFile, caFile string) error { doc := exchange.RemotesDoc{SchemaVersion: 1} if raw, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(raw))) > 0 { if err := json.Unmarshal(raw, &doc); err != nil { @@ -446,7 +416,7 @@ func upsertSyncRemote(path, root, id, backend, direction, endpoint, repo, branch if err != nil { return err } - entry := exchange.RemoteEntry{Backend: backend, Direction: direction, ID: id, Endpoint: endpoint, Repo: repo, Branch: branch, CredentialRef: credentialRef, CAFile: normalizeSyncFileRef(caFile)} + entry := exchange.RemoteEntry{Backend: backend, Direction: direction, ID: id, Endpoint: endpoint, CredentialRef: credentialRef, CAFile: normalizeSyncFileRef(caFile)} replaced := false for i := range doc.Remotes { if doc.Remotes[i].ID == id { diff --git a/harness/cmd/mnemon-harness/sync_test.go b/harness/cmd/mnemon-harness/sync_test.go index c3290193..a2ee26ba 100644 --- a/harness/cmd/mnemon-harness/sync_test.go +++ b/harness/cmd/mnemon-harness/sync_test.go @@ -434,53 +434,24 @@ func TestSyncConnectWritesRemoteConfigWithoutLeakingToken(t *testing.T) { } } -func TestSyncConnectWritesGitHubRemoteConfigWithoutLeakingToken(t *testing.T) { +func TestSyncConnectRejectsGitHubBackend(t *testing.T) { restoreSyncFlags(t) root := t.TempDir() syncRoot = root - syncRemoteBackend = exchange.RemoteBackendGitHub - syncRemoteDirection = exchange.RemoteDirectionPublish - syncGitHubRepo = "mnemon-dev/mnemon-teamwork-example" - syncGitHubBranch = "mnemon/mnemond-a" - syncRemoteToken = "secret-github-token" + syncRemoteBackend = "github" + syncRemoteToken = "secret-remote-token" var out bytes.Buffer cmd := mustTestCommand(t) cmd.SetOut(&out) - if err := runSyncConnect(cmd, []string{"self"}); err != nil { - t.Fatalf("sync connect github: %v", err) + err := runSyncConnect(cmd, []string{"self"}) + if err == nil || !strings.Contains(err.Error(), "unsupported Remote Workspace backend") { + t.Fatalf("sync connect github backend must fail closed, got %v", err) } - if strings.Contains(out.String(), "secret-github-token") { + if strings.Contains(out.String(), "secret-remote-token") { t.Fatalf("sync connect output must not expose token:\n%s", out.String()) } - config := string(mustReadCmd(t, filepath.Join(root, ".mnemon", "harness", "sync", "remotes.json"))) - for _, want := range []string{ - `"backend": "github"`, - `"direction": "publish"`, - `"id": "self"`, - `"repo": "mnemon-dev/mnemon-teamwork-example"`, - `"branch": "mnemon/mnemond-a"`, - `"credential_ref": ".mnemon/harness/sync/credentials/self.token"`, - } { - if !strings.Contains(config, want) { - t.Fatalf("sync connect github config missing %q:\n%s", want, config) - } - } - if strings.Contains(config, "secret-github-token") || strings.Contains(config, "endpoint") { - t.Fatalf("github remote config must not leak token or write an endpoint:\n%s", config) - } - syncRemoteBackend = "" - syncRemoteDirection = "" - syncGitHubRepo = "" - syncGitHubBranch = "" - syncRemoteToken = "" - remote, err := resolveSyncRemote() - if err != nil { - t.Fatalf("resolve github remote: %v", err) - } - if remote.ID != "self" || remote.Backend != exchange.RemoteBackendGitHub || - remote.Repo != "mnemon-dev/mnemon-teamwork-example" || remote.Branch != "mnemon/mnemond-a" || - remote.Token != "secret-github-token" { - t.Fatalf("github remote not resolved: %+v", remote) + if _, err := os.Stat(filepath.Join(root, ".mnemon", "harness", "sync", "remotes.json")); !os.IsNotExist(err) { + t.Fatalf("unsupported backend must not write remotes.json, err=%v", err) } } @@ -580,8 +551,6 @@ func restoreSyncFlags(t *testing.T) { oldRemoteToken := syncRemoteToken oldRemoteTokenFile := syncRemoteTokenFile oldCAFile := syncCAFile - oldGitHubRepo := syncGitHubRepo - oldGitHubBranch := syncGitHubBranch oldAllowInsecure := syncAllowInsecure t.Cleanup(func() { syncRoot = oldRoot @@ -594,8 +563,6 @@ func restoreSyncFlags(t *testing.T) { syncRemoteToken = oldRemoteToken syncRemoteTokenFile = oldRemoteTokenFile syncCAFile = oldCAFile - syncGitHubRepo = oldGitHubRepo - syncGitHubBranch = oldGitHubBranch syncAllowInsecure = oldAllowInsecure }) syncRoot = "." @@ -608,8 +575,6 @@ func restoreSyncFlags(t *testing.T) { syncRemoteToken = "" syncRemoteTokenFile = "" syncCAFile = "" - syncGitHubRepo = "" - syncGitHubBranch = "" syncAllowInsecure = false } diff --git a/harness/internal/app/sync_github_live_test.go b/harness/internal/app/sync_github_live_test.go deleted file mode 100644 index 13bdb5c7..00000000 --- a/harness/internal/app/sync_github_live_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package app - -import ( - "context" - "net/http" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/mnemon-dev/mnemon/harness/internal/contract" - "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" - githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" - "github.com/mnemon-dev/mnemon/harness/internal/runtime" -) - -func TestGitHubLivePublishPullImport(t *testing.T) { - cfg := liveGitHubTestConfig(t) - store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ - Repo: cfg.repo, - Token: cfg.token, - HTTPClient: &http.Client{ - Timeout: 30 * time.Second, - }, - }) - if err != nil { - t.Fatalf("github publication store: %v", err) - } - if err := store.EnsureBranches(context.Background(), []string{cfg.branchA, cfg.branchB}, "main"); err != nil { - t.Fatalf("ensure live GitHub branches: %v", err) - } - remoteA := liveGitHubRemote(t, store, cfg.repo, cfg.branchA) - remoteB := liveGitHubRemote(t, store, cfg.repo, cfg.branchB) - - sourcePrincipal := contract.ActorID("codex-github-live-a@project") - targetPrincipal := contract.ActorID("codex-github-live-b@project") - source := openMeshServingRuntime(t, filepath.Join(t.TempDir(), "source"), string(sourcePrincipal)) - target := openMeshServingRuntime(t, filepath.Join(t.TempDir(), "target"), string(targetPrincipal)) - - runID := "github-live-" + time.Now().UTC().Format("20060102T150405.000000000Z") - assignmentID := runID + "-assignment" - assignmentScope := "github-live/publish-pull-import/" + runID - observeLiveAssignment(t, source, sourcePrincipal, assignmentID, assignmentScope, targetPrincipal) - - if err := syncWorkerPush(source, remoteA, "github-live-publish-a"); err != nil { - t.Fatalf("source publish branch %s: %v", cfg.branchA, err) - } - if pending, err := source.PendingSyncedEvents(); err != nil || len(pending) != 0 { - t.Fatalf("source publish must drain pending events, pending=%+v err=%v", pending, err) - } - - if err := syncWorkerPull(target, remoteA, "github-live-subscribe-a", nil); err != nil { - t.Fatalf("target pull branch %s: %v", cfg.branchA, err) - } - assertAssignmentScopeCount(t, target, assignmentScope, 1) - if err := syncWorkerPull(target, remoteA, "github-live-subscribe-a", nil); err != nil { - t.Fatalf("target repeat pull branch %s: %v", cfg.branchA, err) - } - assertAssignmentScopeCount(t, target, assignmentScope, 1) - - progressSummary := runID + " completed assignment " + assignmentID - observeLiveProgress(t, target, targetPrincipal, runID+"-progress", progressSummary) - if err := syncWorkerPush(target, remoteB, "github-live-publish-b"); err != nil { - t.Fatalf("target publish branch %s: %v", cfg.branchB, err) - } - if pending, err := target.PendingSyncedEvents(); err != nil || len(pending) != 0 { - t.Fatalf("target publish must drain pending events, pending=%+v err=%v", pending, err) - } - - if err := syncWorkerPull(source, remoteB, "github-live-subscribe-b", nil); err != nil { - t.Fatalf("source pull branch %s: %v", cfg.branchB, err) - } - assertProgressSummaryCount(t, source, progressSummary, 1) - if err := syncWorkerPull(source, remoteB, "github-live-subscribe-b", nil); err != nil { - t.Fatalf("source repeat pull branch %s: %v", cfg.branchB, err) - } - assertProgressSummaryCount(t, source, progressSummary, 1) -} - -type liveGitHubConfig struct { - repo string - branchA string - branchB string - token string -} - -func liveGitHubTestConfig(t *testing.T) liveGitHubConfig { - t.Helper() - if os.Getenv("MNEMON_GITHUB_LIVE") != "1" { - t.Skip("set MNEMON_GITHUB_LIVE=1 to run the real GitHub publish/pull/import test") - } - token := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) - if tokenFile := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_TOKEN_FILE")); tokenFile != "" { - raw, err := os.ReadFile(tokenFile) - if err != nil { - t.Fatalf("read MNEMON_GITHUB_TOKEN_FILE: %v", err) - } - token = strings.TrimSpace(string(raw)) - } - if token == "" { - t.Skip("GITHUB_TOKEN or MNEMON_GITHUB_TOKEN_FILE is required for live GitHub publish/pull/import test") - } - repo := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_REPO")) - if repo == "" { - repo = "mnemon-dev/mnemon-teamwork-example" - } - branchA := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_BRANCH_A")) - if branchA == "" { - branchA = "mnemon/live/" + time.Now().UTC().Format("20060102T150405.000000000Z") + "/a" - } - branchB := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_BRANCH_B")) - if branchB == "" { - branchB = strings.TrimSuffix(branchA, "/a") + "/b" - } - return liveGitHubConfig{repo: repo, branchA: branchA, branchB: branchB, token: token} -} - -func liveGitHubRemote(t *testing.T, store exchange.PublicationStore, repo, branch string) exchange.RemoteWorkspace { - t.Helper() - remote, err := githubbackend.New(githubbackend.Config{ - Store: store, - Repo: repo, - Branch: branch, - Scopes: []contract.ResourceRef{ - {Kind: "assignment", ID: "project"}, - {Kind: "progress_digest", ID: "project"}, - }, - }) - if err != nil { - t.Fatalf("github remote %s: %v", branch, err) - } - return remote -} - -func observeLiveAssignment(t *testing.T, rt *runtime.Runtime, principal contract.ActorID, assignmentID, scope string, assignee contract.ActorID) { - t.Helper() - if _, _, err := rt.API().Ingest(principal, contract.ObservationEnvelope{ - ExternalID: assignmentID, - Event: contract.Event{Type: "assignment.write_candidate.observed", Payload: r2AssignmentPayload( - map[string]any{"assignment_id": assignmentID, "scope": scope, "ttl": "30m", "assignee": string(assignee)}, - map[string]any{"expected_work": "complete live GitHub Remote Workspace publish/pull/import validation", "expected_feedback": "progress_digest with result evidence"}, - map[string]any{"evidence_refs": []any{"gated live GitHub publication backend test"}}, - )}, - }); err != nil { - t.Fatalf("observe live assignment: %v", err) - } - if _, err := rt.Tick(); err != nil { - t.Fatalf("tick live assignment: %v", err) - } -} - -func observeLiveProgress(t *testing.T, rt *runtime.Runtime, principal contract.ActorID, externalID, summary string) { - t.Helper() - if _, _, err := rt.API().Ingest(principal, contract.ObservationEnvelope{ - ExternalID: externalID, - Event: contract.Event{Type: "progress_digest.write_candidate.observed", Payload: r2Progress(summary)}, - }); err != nil { - t.Fatalf("observe live progress: %v", err) - } - if _, err := rt.Tick(); err != nil { - t.Fatalf("tick live progress: %v", err) - } -} - -func assertAssignmentScopeCount(t *testing.T, rt *runtime.Runtime, scope string, want int) { - t.Helper() - _, fields, err := rt.Resource(contract.ResourceRef{Kind: "assignment", ID: "project"}) - if err != nil { - t.Fatalf("read assignment: %v", err) - } - content, _ := fields["content"].(string) - if got := strings.Count(content, scope); got != want { - t.Fatalf("assignment scope %q count = %d, want %d\n%s", scope, got, want, content) - } -} - -func assertProgressSummaryCount(t *testing.T, rt *runtime.Runtime, summary string, want int) { - t.Helper() - _, fields, err := rt.Resource(contract.ResourceRef{Kind: "progress_digest", ID: "project"}) - if err != nil { - t.Fatalf("read progress_digest: %v", err) - } - content, _ := fields["content"].(string) - if got := strings.Count(content, summary); got != want { - t.Fatalf("progress summary %q count = %d, want %d\n%s", summary, got, want, content) - } -} diff --git a/harness/internal/app/sync_github_mesh_test.go b/harness/internal/app/sync_github_mesh_test.go deleted file mode 100644 index 0f9d0488..00000000 --- a/harness/internal/app/sync_github_mesh_test.go +++ /dev/null @@ -1,278 +0,0 @@ -package app - -import ( - "fmt" - "path/filepath" - "testing" - - "github.com/mnemon-dev/mnemon/harness/internal/contract" - "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" - "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" - githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" - "github.com/mnemon-dev/mnemon/harness/internal/runtime" -) - -func TestSyncGitHubFakeFiveMnemondPublicationMesh(t *testing.T) { - ids := []string{"agent-a", "agent-b", "agent-c", "agent-d", "agent-e"} - branches := make([]string, 0, len(ids)) - for _, id := range ids { - branches = append(branches, "mnemon/"+id) - } - store, err := exchange.NewMemoryPublicationStore(branches...) - if err != nil { - t.Fatal(err) - } - type node struct { - id string - branch string - principal string - rt *runtime.Runtime - } - nodes := make([]node, 0, len(ids)) - for i, id := range ids { - root := t.TempDir() - principal := "codex-" + id + "@project" - rt := openMeshServingRuntime(t, root, principal) - observeMeshAssignment(t, rt, principal, id) - nodes = append(nodes, node{id: id, branch: branches[i], principal: principal, rt: rt}) - } - - for _, n := range nodes { - remote := githubFakeRemote(t, store, n.branch) - if err := syncWorkerPush(n.rt, remote, "publish-"+n.id); err != nil { - t.Fatalf("%s publish: %v", n.id, err) - } - if pending, err := n.rt.PendingSyncedEvents(); err != nil || len(pending) != 0 { - t.Fatalf("%s publish must drain pending events, pending=%+v err=%v", n.id, pending, err) - } - } - - for _, n := range nodes { - for _, source := range nodes { - if source.id == n.id { - continue - } - remote := githubFakeRemote(t, store, source.branch) - state, err := exchange.ReadPullState(n.rt, "subscribe-"+source.id) - if err != nil { - t.Fatalf("%s read pull state %s: %v", n.id, source.id, err) - } - probe, err := remote.SyncPull(contract.SyncPullRequest{ReplicaID: state.ReplicaID, RemoteCursor: state.RemoteCursor}) - if err != nil { - t.Fatalf("%s probe %s: %v", n.id, source.id, err) - } - if len(probe.Diagnostics) > 0 || len(probe.Events) == 0 { - t.Fatalf("%s probe %s returned events=%d diagnostics=%+v", n.id, source.id, len(probe.Events), probe.Diagnostics) - } - if err := syncWorkerPull(n.rt, remote, "subscribe-"+source.id, nil); err != nil { - t.Fatalf("%s subscribe %s: %v", n.id, source.id, err) - } - } - } - - assignmentRef := contract.ResourceRef{Kind: "assignment", ID: "project"} - for _, n := range nodes { - _, fields, err := n.rt.Resource(assignmentRef) - if err != nil { - t.Fatalf("%s read assignments: %v", n.id, err) - } - items, _ := fields["items"].([]any) - scopes := map[string]bool{} - for _, item := range items { - m, _ := item.(map[string]any) - scopes[towerItemString(m, "scope")] = true - } - for _, source := range nodes { - want := meshAssignmentScope(source.id) - if !scopes[want] { - t.Fatalf("%s assignments missing %q in %+v", n.id, want, items) - } - } - } -} - -func TestSyncGitHubFakePublicationMeshJoinLeaveReassignment(t *testing.T) { - ids := []string{"agent-a", "agent-b", "agent-c", "agent-d", "agent-e", "agent-f", "agent-g"} - branches := make([]string, 0, len(ids)) - for _, id := range ids { - branches = append(branches, "mnemon/"+id) - } - store, err := exchange.NewMemoryPublicationStore(branches...) - if err != nil { - t.Fatal(err) - } - - nodes := make([]meshTestNode, 0, len(ids)) - for _, id := range ids[:5] { - node := newMeshTestNode(t, id) - observeMeshAssignment(t, node.rt, node.principal, id) - publishMeshNode(t, store, node) - nodes = append(nodes, node) - } - pullMeshAllSources(t, store, nodes[:5], nodes[:5]) - - for _, id := range ids[5:] { - node := newMeshTestNode(t, id) - observeMeshAssignment(t, node.rt, node.principal, id) - publishMeshNode(t, store, node) - nodes = append(nodes, node) - } - offline := nodes[3] - active := append([]meshTestNode{}, nodes[:3]...) - active = append(active, nodes[4:]...) - reassignScope := "agent-d down; reassign delayed work to agent-f" - observeMeshAssignmentWithScope(t, nodes[0].rt, nodes[0].principal, "reassign-agent-d", reassignScope, "codex@agent-f") - publishMeshNode(t, store, nodes[0]) - - pullMeshAllSources(t, store, active, nodes) - assertMeshScopes(t, active, []string{ - meshAssignmentScope("agent-a"), - meshAssignmentScope("agent-b"), - meshAssignmentScope("agent-c"), - meshAssignmentScope("agent-d"), - meshAssignmentScope("agent-e"), - meshAssignmentScope("agent-f"), - meshAssignmentScope("agent-g"), - reassignScope, - }) - assertMeshScopesAbsent(t, []meshTestNode{offline}, []string{meshAssignmentScope("agent-f"), meshAssignmentScope("agent-g"), reassignScope}) - - pullMeshAllSources(t, store, []meshTestNode{offline}, nodes) - assertMeshScopes(t, []meshTestNode{offline}, []string{meshAssignmentScope("agent-f"), meshAssignmentScope("agent-g"), reassignScope}) -} - -type meshTestNode struct { - id string - branch string - principal string - rt *runtime.Runtime -} - -func newMeshTestNode(t *testing.T, id string) meshTestNode { - t.Helper() - root := t.TempDir() - principal := "codex-" + id + "@project" - return meshTestNode{ - id: id, - branch: "mnemon/" + id, - principal: principal, - rt: openMeshServingRuntime(t, root, principal), - } -} - -func publishMeshNode(t *testing.T, store exchange.PublicationStore, node meshTestNode) { - t.Helper() - remote := githubFakeRemote(t, store, node.branch) - if err := syncWorkerPush(node.rt, remote, "publish-"+node.id); err != nil { - t.Fatalf("%s publish: %v", node.id, err) - } - if pending, err := node.rt.PendingSyncedEvents(); err != nil || len(pending) != 0 { - t.Fatalf("%s publish must drain pending events, pending=%+v err=%v", node.id, pending, err) - } -} - -func pullMeshAllSources(t *testing.T, store exchange.PublicationStore, targets, sources []meshTestNode) { - t.Helper() - for _, target := range targets { - for _, source := range sources { - if source.id == target.id { - continue - } - remote := githubFakeRemote(t, store, source.branch) - if err := syncWorkerPull(target.rt, remote, "subscribe-"+source.id, nil); err != nil { - t.Fatalf("%s subscribe %s: %v", target.id, source.id, err) - } - } - } -} - -func assertMeshScopes(t *testing.T, nodes []meshTestNode, scopes []string) { - t.Helper() - for _, node := range nodes { - got := meshScopes(t, node) - for _, want := range scopes { - if !got[want] { - t.Fatalf("%s assignments missing %q in %+v", node.id, want, got) - } - } - } -} - -func assertMeshScopesAbsent(t *testing.T, nodes []meshTestNode, scopes []string) { - t.Helper() - for _, node := range nodes { - got := meshScopes(t, node) - for _, want := range scopes { - if got[want] { - t.Fatalf("%s assignments unexpectedly contain %q in %+v", node.id, want, got) - } - } - } -} - -func meshScopes(t *testing.T, node meshTestNode) map[string]bool { - t.Helper() - assignmentRef := contract.ResourceRef{Kind: "assignment", ID: "project"} - _, fields, err := node.rt.Resource(assignmentRef) - if err != nil { - t.Fatalf("%s read assignments: %v", node.id, err) - } - items, _ := fields["items"].([]any) - scopes := map[string]bool{} - for _, item := range items { - m, _ := item.(map[string]any) - scopes[towerItemString(m, "scope")] = true - } - return scopes -} - -func openMeshServingRuntime(t *testing.T, root, principal string) *runtime.Runtime { - t.Helper() - refs := []contract.ResourceRef{{Kind: "progress_digest", ID: "project"}, {Kind: "assignment", ID: "project"}} - b := access.HostAgentBinding(contract.ActorID(principal), "http://127.0.0.1:8787", refs) - rt, err := OpenLocalRuntime(filepath.Join(root, runtime.DefaultStorePath), access.LoadedBindings{Bindings: []access.ChannelBinding{b}}, nil, nil) - if err != nil { - t.Fatalf("open serving runtime: %v", err) - } - t.Cleanup(func() { _ = rt.Close() }) - return rt -} - -func observeMeshAssignment(t *testing.T, rt *runtime.Runtime, principal, id string) { - t.Helper() - observeMeshAssignmentWithScope(t, rt, principal, id, meshAssignmentScope(id), "codex@"+id) -} - -func observeMeshAssignmentWithScope(t *testing.T, rt *runtime.Runtime, principal, id, scope, assignee string) { - t.Helper() - if _, _, err := rt.API().Ingest(contract.ActorID(principal), contract.ObservationEnvelope{ - ExternalID: "github-mesh-assignment-" + id, - Event: contract.Event{Type: "assignment.write_candidate.observed", Payload: r2AssignmentPayload( - map[string]any{"assignment_id": "mesh-" + id, "scope": scope, "ttl": "2h", "assignee": assignee}, - map[string]any{"expected_work": "complete deterministic publication mesh validation for " + id, "expected_feedback": "progress_digest"}, - map[string]any{"evidence_refs": []any{"deterministic fake GitHub publication mesh test"}}, - )}, - }); err != nil { - t.Fatalf("observe assignment: %v", err) - } - if _, err := rt.Tick(); err != nil { - t.Fatalf("tick assignment: %v", err) - } -} - -func meshAssignmentScope(id string) string { - return fmt.Sprintf("%s publication mesh assignment", id) -} - -func githubFakeRemote(t *testing.T, store exchange.PublicationStore, branch string) exchange.RemoteWorkspace { - t.Helper() - remote, err := githubbackend.New(githubbackend.Config{ - Store: store, - Repo: "mnemon-dev/mnemon-teamwork-example", - Branch: branch, - }) - if err != nil { - t.Fatal(err) - } - return remote -} diff --git a/harness/internal/app/sync_worker.go b/harness/internal/app/sync_worker.go index 6cc0e6ac..d0cb6d07 100644 --- a/harness/internal/app/sync_worker.go +++ b/harness/internal/app/sync_worker.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "net/http" "os" "path/filepath" "strconv" @@ -15,7 +14,6 @@ import ( "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/policy" "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" - githubbackend "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github" "github.com/mnemon-dev/mnemon/harness/internal/runtime" ) @@ -150,15 +148,12 @@ func syncWorkerPass(rt *runtime.Runtime, opts SyncWorkerOptions) error { return nil } -// syncWorkerRemote builds the selected Remote Workspace backend from the remote entry. Today only -// the first-party HTTP mnemon-hub backend is implemented; the sync loop above depends only on the -// exchange.RemoteWorkspace ABI so a future GitHub publication mesh does not touch runtime import. +// syncWorkerRemote builds the selected Remote Workspace backend from the remote entry. The +// first-party backend is the HTTP MnemonHub wire; product surfaces must not bypass local import. func syncWorkerRemote(entry exchange.RemoteEntry, opts SyncWorkerOptions) (exchange.RemoteWorkspace, error) { switch entry.NormalizedBackend() { case exchange.RemoteBackendHTTP: return syncWorkerHTTPRemote(entry, opts) - case exchange.RemoteBackendGitHub: - return syncWorkerGitHubRemote(entry, opts) default: return nil, fmt.Errorf("Remote Workspace %q: unsupported backend %q", entry.ID, entry.NormalizedBackend()) } @@ -195,49 +190,6 @@ func syncWorkerHTTPRemote(entry exchange.RemoteEntry, opts SyncWorkerOptions) (e }) } -func syncWorkerGitHubRemote(entry exchange.RemoteEntry, opts SyncWorkerOptions) (exchange.RemoteWorkspace, error) { - token, err := syncWorkerRemoteToken(entry, opts) - if err != nil { - return nil, err - } - timeout := opts.Timeout - if timeout <= 0 { - timeout = access.DefaultSyncTimeout - } - store, err := githubbackend.NewPublicationStore(githubbackend.PublicationStoreConfig{ - Repo: entry.Repo, - Token: token, - HTTPClient: &http.Client{Timeout: timeout}, - }) - if err != nil { - return nil, err - } - return githubbackend.New(githubbackend.Config{ - Store: store, - Repo: entry.Repo, - Branch: entry.Branch, - }) -} - -func syncWorkerRemoteToken(entry exchange.RemoteEntry, opts SyncWorkerOptions) (string, error) { - if strings.TrimSpace(entry.CredentialRef) == "" { - return "", fmt.Errorf("Remote Workspace %q has no credential_ref", entry.ID) - } - tokPath := entry.CredentialRef - if !filepath.IsAbs(tokPath) { - tokPath = filepath.Join(opts.ProjectRoot, tokPath) - } - raw, err := os.ReadFile(tokPath) - if err != nil { - return "", fmt.Errorf("read Remote Workspace token file: %w", err) - } - token := strings.TrimSpace(string(raw)) - if token == "" { - return "", fmt.Errorf("Remote Workspace token file %s is empty", entry.CredentialRef) - } - return token, nil -} - // syncWorkerPush pushes the pending batch (if any) and mirrors the hub's per-event verdicts into // the local ledger — both through the live handle. func syncWorkerPush(rt *runtime.Runtime, remote exchange.RemoteWorkspace, remoteID string) error { diff --git a/harness/internal/app/sync_worker_test.go b/harness/internal/app/sync_worker_test.go index 58dcb1bb..191c66d4 100644 --- a/harness/internal/app/sync_worker_test.go +++ b/harness/internal/app/sync_worker_test.go @@ -184,19 +184,19 @@ func TestSyncWorkerSurvivesUnreachableRemote(t *testing.T) { } } -func TestSyncWorkerBacksOffGitHubRateLimit(t *testing.T) { +func TestSyncWorkerBacksOffRemoteRateLimit(t *testing.T) { now := time.Unix(100, 0) - err := fmt.Errorf("sync pull failed: github api status 403: API rate limit exceeded (rate_limit_remaining=0, rate_limit_reset=160)") + err := fmt.Errorf("sync pull failed: remote status 429: rate limit exceeded (rate_limit_remaining=0, rate_limit_reset=160)") if got := syncWorkerErrorBackoff(err, now); got != 61*time.Second { t.Fatalf("rate-limit reset backoff = %v, want 61s", got) } - err = fmt.Errorf("sync pull failed: github api status 403: secondary limit (retry_after=12, rate_limit_remaining=0, rate_limit_reset=160)") + err = fmt.Errorf("sync pull failed: remote status 429: secondary limit (retry_after=12, rate_limit_remaining=0, rate_limit_reset=160)") if got := syncWorkerErrorBackoff(err, now); got != 12*time.Second { t.Fatalf("retry-after backoff = %v, want 12s", got) } - err = fmt.Errorf("sync pull failed: github api status 500") + err = fmt.Errorf("sync pull failed: remote status 500") if got := syncWorkerErrorBackoff(err, now); got != 0 { t.Fatalf("non-rate-limit error backoff = %v, want zero", got) } diff --git a/harness/internal/coreguard/github_remote_workspace_guard_test.go b/harness/internal/coreguard/github_remote_workspace_guard_test.go deleted file mode 100644 index ccc45a2d..00000000 --- a/harness/internal/coreguard/github_remote_workspace_guard_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package coreguard - -import ( - "go/ast" - "go/parser" - "go/token" - "io/fs" - "path/filepath" - "strconv" - "strings" - "testing" -) - -var githubRemoteWorkspaceCorePackages = []string{ - "runtime", - "mnemond/state", - "mnemond/admission", - "mnemond/presentation", - "hostagent", -} - -var forbiddenGitHubBackendTerms = []string{ - "github issue", - "github issues", - "github pr", - "github pull request", - "github action", - "github actions", - "issue-based", - "pr-based", - "action-based", - "p2p discovery", - "peer discovery", - "node discovery", - "gossip", - "dht", - "routing table", - "nat traversal", - "overlay network", -} - -func TestGitHubRemoteWorkspaceGuardLogicIsNotVacuous(t *testing.T) { - if !isGitHubRemoteBackendImportPath("github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange/backend/github") { - t.Fatal("GitHub backend import matcher must flag the planned exchange/backend/github package") - } - if isGitHubRemoteBackendImportPath("github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange") { - t.Fatal("plain exchange package must not be treated as GitHub backend") - } - if !isAllowedGitHubBackendDir(filepath.FromSlash("mnemonhub/exchange/backend/github")) { - t.Fatal("planned GitHub backend directory should be allowed under mnemonhub/exchange/backend") - } - if isAllowedGitHubBackendDir(filepath.FromSlash("runtime/github")) { - t.Fatal("GitHub backend outside mnemonhub/exchange/backend must not be allowed") - } - if !hasForbiddenGitHubBackendTerm("use GitHub Issues as assignments") { - t.Fatal("forbidden GitHub teamwork semantic matcher should flag Issue/PR/Actions concepts") - } - if hasForbiddenGitHubBackendTerm("publication branch enumeration") { - t.Fatal("publication terminology should remain allowed") - } -} - -func TestGitHubRemoteWorkspaceBackendDoesNotLeakIntoCore(t *testing.T) { - for _, pkg := range githubRemoteWorkspaceCorePackages { - _, files := packageFiles(t, pkg) - for _, file := range files { - for _, imp := range file.Imports { - path := strings.Trim(imp.Path.Value, `"`) - if isGitHubRemoteBackendImportPath(path) { - t.Errorf("core package %q imports GitHub Remote Workspace backend %q; GitHub must stay below mnemonhub/exchange/backend", pkg, path) - } - } - } - } -} - -func TestGitHubRemoteWorkspaceBackendLocationAndNaming(t *testing.T) { - root := filepath.Clean("..") - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() { - if strings.HasPrefix(entry.Name(), ".") { - return filepath.SkipDir - } - return nil - } - name := entry.Name() - if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { - return nil - } - relFile, err := filepath.Rel(root, path) - if err != nil { - return err - } - relDir := filepath.Dir(relFile) - if pathMentionsGitHubBackend(relDir) && !isAllowedGitHubBackendDir(relDir) { - t.Errorf("GitHub backend source %s is outside mnemonhub/exchange/backend", relFile) - } - if isAllowedGitHubBackendDir(relDir) { - assertGitHubBackendFileUsesPublicationVocabulary(t, path) - } - return nil - }) - if err != nil { - t.Fatalf("walk harness/internal: %v", err) - } -} - -func isGitHubRemoteBackendImportPath(path string) bool { - rel := strings.TrimPrefix(path, "github.com/mnemon-dev/mnemon/") - if !strings.HasPrefix(rel, "harness/internal/mnemonhub/exchange/") { - return false - } - tail := strings.TrimPrefix(rel, "harness/internal/mnemonhub/exchange/") - for _, segment := range strings.Split(tail, "/") { - if strings.Contains(strings.ToLower(segment), "github") { - return true - } - } - return false -} - -func isAllowedGitHubBackendDir(relDir string) bool { - clean := filepath.ToSlash(filepath.Clean(relDir)) - return clean == "mnemonhub/exchange/backend" || - strings.HasPrefix(clean, "mnemonhub/exchange/backend/") -} - -func pathMentionsGitHubBackend(relDir string) bool { - for _, segment := range strings.Split(filepath.ToSlash(relDir), "/") { - if strings.Contains(strings.ToLower(segment), "github") { - return true - } - } - return false -} - -func assertGitHubBackendFileUsesPublicationVocabulary(t *testing.T, path string) { - t.Helper() - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) - if err != nil { - t.Fatalf("parse %s: %v", path, err) - } - ast.Inspect(file, func(node ast.Node) bool { - switch n := node.(type) { - case *ast.Ident: - if hasForbiddenGitHubBackendTerm(splitIdentifierWords(n.Name)) { - t.Errorf("%s uses forbidden GitHub/P2P backend term %q; use repo-mediated publication vocabulary", fset.Position(n.Pos()), n.Name) - } - case *ast.BasicLit: - if n.Kind != token.STRING { - return true - } - value, err := strconv.Unquote(n.Value) - if err != nil { - value = strings.Trim(n.Value, "`\"") - } - if hasForbiddenGitHubBackendTerm(value) { - t.Errorf("%s uses forbidden GitHub/P2P backend literal %q; use repo-mediated publication vocabulary", fset.Position(n.Pos()), value) - } - } - return true - }) -} - -func splitIdentifierWords(name string) string { - var b strings.Builder - var prevLower bool - for _, r := range name { - if r == '_' || r == '-' { - b.WriteByte(' ') - prevLower = false - continue - } - if r >= 'A' && r <= 'Z' { - if prevLower { - b.WriteByte(' ') - } - b.WriteRune(r + ('a' - 'A')) - prevLower = false - continue - } - b.WriteRune(r) - prevLower = r >= 'a' && r <= 'z' - } - return b.String() -} - -func hasForbiddenGitHubBackendTerm(text string) bool { - normalized := strings.ToLower(strings.Join(strings.Fields(strings.ReplaceAll(text, "_", " ")), " ")) - for _, term := range forbiddenGitHubBackendTerms { - if strings.Contains(normalized, term) { - return true - } - } - return false -} diff --git a/harness/internal/coreguard/remote_workspace_no_github_guard_test.go b/harness/internal/coreguard/remote_workspace_no_github_guard_test.go new file mode 100644 index 00000000..ff7f880f --- /dev/null +++ b/harness/internal/coreguard/remote_workspace_no_github_guard_test.go @@ -0,0 +1,126 @@ +package coreguard + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +var forbiddenFirstPartyGitHubTerms = []string{ + "RemoteBackendGitHub", + "ConnectionGitHub", + "GitHubConnection", + "runConnectGitHub", + "syncGitHub", + "GitHubMesh", + "GitHub Mesh", + "github mesh", + "GitHub Remote Workspace", + "GitHub-backed", + "github-repo", + "github-token-file", + "github-branch", + "backend/github", +} + +func TestNoFirstPartyGitHubRemoteWorkspaceBackendPackage(t *testing.T) { + root := filepath.Clean("..") + backendRoot := filepath.Join(root, "mnemonhub", "exchange", "backend") + if _, err := os.Stat(backendRoot); os.IsNotExist(err) { + return + } + err := filepath.WalkDir(backendRoot, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + for _, segment := range strings.Split(filepath.ToSlash(path), "/") { + if strings.EqualFold(segment, "github") { + t.Fatalf("GitHub must not be implemented as a first-party Remote Workspace backend: %s", path) + } + } + return nil + }) + if err != nil { + t.Fatalf("walk Remote Workspace backends: %v", err) + } +} + +func TestNoFirstPartyGitHubConnectionOrBackendSymbols(t *testing.T) { + for _, root := range []string{filepath.Clean(".."), filepath.Clean("../../cmd")} { + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if strings.HasPrefix(entry.Name(), ".") { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { + return nil + } + assertNoFirstPartyGitHubTerms(t, path) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + } +} + +func assertNoFirstPartyGitHubTerms(t *testing.T, path string) { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, parser.ParseComments|parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + ast.Inspect(file, func(node ast.Node) bool { + switch n := node.(type) { + case *ast.Ident: + if term := firstForbiddenGitHubTerm(n.Name); term != "" { + t.Errorf("%s uses removed first-party GitHub symbol %q", fset.Position(n.Pos()), term) + } + case *ast.BasicLit: + if n.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(n.Value) + if err != nil { + value = strings.Trim(n.Value, "`\"") + } + if isAllowedModuleImport(value) { + return true + } + if term := firstForbiddenGitHubTerm(value); term != "" { + t.Errorf("%s uses removed first-party GitHub literal %q", fset.Position(n.Pos()), term) + } + case *ast.Comment: + if term := firstForbiddenGitHubTerm(n.Text); term != "" { + t.Errorf("%s documents removed first-party GitHub behavior %q", fset.Position(n.Pos()), term) + } + } + return true + }) +} + +func firstForbiddenGitHubTerm(text string) string { + normalized := strings.ToLower(text) + for _, term := range forbiddenFirstPartyGitHubTerms { + if strings.Contains(normalized, strings.ToLower(term)) { + return term + } + } + return "" +} + +func isAllowedModuleImport(value string) bool { + return strings.HasPrefix(value, "github.com/mnemon-dev/mnemon/") +} diff --git a/harness/internal/daemon/configured_test.go b/harness/internal/daemon/configured_test.go index d3fd5166..49eaa7e7 100644 --- a/harness/internal/daemon/configured_test.go +++ b/harness/internal/daemon/configured_test.go @@ -82,7 +82,7 @@ func TestRoleDetailsDescribeDaemonBoundaries(t *testing.T) { func TestRoleSummaryReflectsConfiguredDaemonRoles(t *testing.T) { cfg := productconfig.Default() - cfg.Daemon.InteractionWatchers = []string{productconfig.ConnectionMultica, productconfig.ConnectionGitHub} + cfg.Daemon.InteractionWatchers = []string{productconfig.ConnectionMultica, productconfig.ConnectionMnemonhub} cfg.Daemon.DriveSources = []string{productconfig.DriveManagedLocal} cfg.Daemon.DisplaySurfaces = []string{productconfig.ConnectionMultica} diff --git a/harness/internal/mnemonhub/exchange/backend/github/backend.go b/harness/internal/mnemonhub/exchange/backend/github/backend.go deleted file mode 100644 index e706aecc..00000000 --- a/harness/internal/mnemonhub/exchange/backend/github/backend.go +++ /dev/null @@ -1,232 +0,0 @@ -package githubbackend - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/mnemon-dev/mnemon/harness/internal/contract" - eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" - "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" -) - -type Config struct { - Store exchange.PublicationStore - Repo string - Branch string - Scopes []contract.ResourceRef -} - -type Backend struct { - store exchange.PublicationStore - repo string - branch string - scopes []contract.ResourceRef -} - -func New(cfg Config) (*Backend, error) { - if cfg.Store == nil { - return nil, fmt.Errorf("publication store is required") - } - repo := strings.TrimSpace(cfg.Repo) - if repo == "" { - return nil, fmt.Errorf("publication repo is required") - } - branch, err := exchange.NormalizePublicationBranch(cfg.Branch) - if err != nil { - return nil, err - } - return &Backend{ - store: cfg.Store, - repo: repo, - branch: branch, - scopes: append([]contract.ResourceRef(nil), cfg.Scopes...), - }, nil -} - -func (b *Backend) SyncPush(req contract.SyncPushRequest) (contract.SyncPushResponse, error) { - replicaID := strings.TrimSpace(req.ReplicaID) - if replicaID == "" { - return contract.SyncPushResponse{}, fmt.Errorf("sync push requires replica_id") - } - var resp contract.SyncPushResponse - for _, env := range req.Events { - material, diagnostic := b.syncedEventMaterial(env) - if diagnostic != "" { - resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", diagnostic)) - continue - } - if material.OriginReplicaID != replicaID { - return contract.SyncPushResponse{}, fmt.Errorf("sync push replica_id %q does not match event origin %q", replicaID, material.OriginReplicaID) - } - if diagnostic := validateSyncedMaterial(material); diagnostic != "" { - resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", diagnostic)) - continue - } - if diagnostic := b.scopeDiagnostic(material.ResourceRef); diagnostic != "" { - resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", diagnostic)) - continue - } - path, err := exchange.PublicationEventPath(env) - if err != nil { - resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", err.Error())) - continue - } - body, err := json.Marshal(env) - if err != nil { - return contract.SyncPushResponse{}, err - } - put, err := b.store.PutEvent(context.Background(), b.branch, path, body) - if err != nil { - return contract.SyncPushResponse{}, err - } - switch { - case put.Created, put.ExistsSame: - resp.Accepted = append(resp.Accepted, eventExchangeResult(env, "accepted", "")) - case put.Conflict: - resp.Conflicts = append(resp.Conflicts, eventExchangeResult(env, "conflict", "publication event path already exists with different content")) - default: - resp.Rejected = append(resp.Rejected, eventExchangeResult(env, "rejected", "publication store returned no put verdict")) - } - } - return resp, nil -} - -func (b *Backend) SyncPull(req contract.SyncPullRequest) (contract.SyncPullResponse, error) { - replicaID := strings.TrimSpace(req.ReplicaID) - if replicaID == "" { - return contract.SyncPullResponse{}, fmt.Errorf("sync pull requires replica_id") - } - scopes := append([]contract.ResourceRef(nil), req.Scopes...) - if len(b.scopes) > 0 { - var err error - scopes, err = contract.ClampRefs(contract.ActorID("github-publication"), b.scopes, req.Scopes) - if err != nil { - return contract.SyncPullResponse{}, fmt.Errorf("sync scope: %w", err) - } - } - list, err := b.store.ListEvents(context.Background(), b.branch, exchange.PublicationEventRoot, req.RemoteCursor) - if err != nil { - return contract.SyncPullResponse{}, err - } - resp := contract.SyncPullResponse{NextCursor: localStateCursor(req.RemoteCursor, list.NextCursor)} - for _, stored := range list.Events { - env, result, ok := decodeStoredEvent(stored) - if !ok { - resp.Diagnostics = append(resp.Diagnostics, result) - continue - } - material, diagnostic := b.syncedEventMaterial(env) - if diagnostic != "" { - resp.Diagnostics = append(resp.Diagnostics, eventExchangeResult(env, "invalid", diagnostic)) - continue - } - if material.OriginReplicaID == replicaID { - continue - } - if diagnostic := validateSyncedMaterial(material); diagnostic != "" { - resp.Diagnostics = append(resp.Diagnostics, eventExchangeResult(env, "invalid", diagnostic)) - continue - } - if len(scopes) > 0 && !refAllowed(scopes, material.ResourceRef) { - resp.Diagnostics = append(resp.Diagnostics, eventExchangeResult(env, "rejected", fmt.Sprintf("ref %s/%s is outside configured publication scope", material.ResourceRef.Kind, material.ResourceRef.ID))) - continue - } - resp.Events = append(resp.Events, env) - } - return resp, nil -} - -func localStateCursor(previous, storeCursor string) string { - storeCursor = strings.TrimSpace(storeCursor) - if storeCursor == "" { - return "" - } - if _, err := strconv.ParseInt(storeCursor, 10, 64); err == nil { - return storeCursor - } - previous = strings.TrimSpace(previous) - if _, err := strconv.ParseInt(previous, 10, 64); err == nil && previous != "" { - return previous - } - return "1" -} - -func (b *Backend) SyncStatus() (contract.SyncStatusResponse, error) { - return contract.SyncStatusResponse{RemoteWorkspace: b.repo + ":" + b.branch}, nil -} - -func (b *Backend) syncedEventMaterial(env eventmodel.EventEnvelope) (contract.SyncedEventMaterial, string) { - material, err := contract.SyncedEventMaterialFromEnvelope(env) - if err != nil { - return contract.SyncedEventMaterial{}, err.Error() - } - return material, "" -} - -func (b *Backend) scopeDiagnostic(ref contract.ResourceRef) string { - if len(b.scopes) == 0 || refAllowed(b.scopes, ref) { - return "" - } - return fmt.Sprintf("ref %s/%s is outside configured publication scope", ref.Kind, ref.ID) -} - -func validateSyncedMaterial(material contract.SyncedEventMaterial) string { - switch { - case strings.TrimSpace(material.OriginReplicaID) == "": - return "origin_replica_id is required" - case strings.TrimSpace(material.LocalDecisionID) == "": - return "local_decision_id is required" - case strings.TrimSpace(string(material.Actor)) == "": - return "actor is required" - case strings.TrimSpace(string(material.ResourceRef.Kind)) == "" || strings.TrimSpace(string(material.ResourceRef.ID)) == "": - return "resource_ref is required" - case material.Fields == nil: - return "fields are required" - case strings.TrimSpace(material.FieldsDigest) == "": - return "fields_digest is required" - case material.FieldsDigest != syncedFieldsDigest(material.Fields): - return "fields_digest does not match fields" - default: - return "" - } -} - -func decodeStoredEvent(stored exchange.PublicationStoredEvent) (eventmodel.EventEnvelope, contract.EventExchangeResult, bool) { - var env eventmodel.EventEnvelope - if err := json.Unmarshal(stored.Body, &env); err != nil { - return eventmodel.EventEnvelope{}, contract.EventExchangeResult{ - EventID: stored.Path, - Status: "invalid", - Diagnostic: "publication event json is invalid: " + err.Error(), - }, false - } - return env, contract.EventExchangeResult{}, true -} - -func eventExchangeResult(env eventmodel.EventEnvelope, status, diagnostic string) contract.EventExchangeResult { - result := contract.EventExchangeResultFromEnvelope(env, status, diagnostic) - if strings.TrimSpace(result.EventID) == "" { - result.EventID = env.Event.ID - } - return result -} - -func refAllowed(scopes []contract.ResourceRef, ref contract.ResourceRef) bool { - for _, scope := range scopes { - if scope == ref { - return true - } - } - return false -} - -func syncedFieldsDigest(fields map[string]any) string { - b, _ := json.Marshal(fields) - sum := sha256.Sum256(b) - return hex.EncodeToString(sum[:]) -} diff --git a/harness/internal/mnemonhub/exchange/backend/github/backend_test.go b/harness/internal/mnemonhub/exchange/backend/github/backend_test.go deleted file mode 100644 index 2dc2e7ca..00000000 --- a/harness/internal/mnemonhub/exchange/backend/github/backend_test.go +++ /dev/null @@ -1,246 +0,0 @@ -package githubbackend - -import ( - "context" - "encoding/json" - "strings" - "testing" - - "github.com/mnemon-dev/mnemon/harness/internal/contract" - eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" - "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" -) - -var progressRef = contract.ResourceRef{Kind: "progress_digest", ID: "project"} - -func TestGitHubBackendFakePushPublishesSyncedEnvelope(t *testing.T) { - store, backend := newFakeBackend(t, "mnemon/mnemond-a", progressRef) - env := githubBackendTestEnvelope(t, "replica-a", "dec-a", progressRef, map[string]any{"content": "published progress"}) - - resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}) - if err != nil { - t.Fatalf("sync push: %v", err) - } - if len(resp.Accepted) != 1 || len(resp.Rejected) != 0 || len(resp.Conflicts) != 0 { - t.Fatalf("push resp = %+v, want one accepted", resp) - } - list, err := store.ListEvents(context.Background(), "mnemon/mnemond-a", exchange.PublicationEventRoot, "") - if err != nil { - t.Fatalf("list publication events: %v", err) - } - if len(list.Events) != 1 { - t.Fatalf("publication store events = %+v, want one", list.Events) - } - - replayed, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}) - if err != nil { - t.Fatalf("repeat sync push: %v", err) - } - if len(replayed.Accepted) != 1 || len(replayed.Conflicts) != 0 { - t.Fatalf("repeat push must be idempotent accepted, got %+v", replayed) - } -} - -func TestGitHubBackendFakePushRejectsInvalidPhase(t *testing.T) { - _, backend := newFakeBackend(t, "mnemon/mnemond-a", progressRef) - env := githubBackendTestEnvelope(t, "replica-a", "dec-a", progressRef, map[string]any{"content": "bad phase"}) - env.Phase = eventmodel.PhaseAccepted - - resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}) - if err != nil { - t.Fatalf("sync push invalid phase: %v", err) - } - if len(resp.Rejected) != 1 || !strings.Contains(resp.Rejected[0].Diagnostic, "phase") { - t.Fatalf("invalid phase must be rejected with diagnostic, got %+v", resp) - } -} - -func TestGitHubBackendFakePushDetectsSameKeyDifferentBody(t *testing.T) { - _, backend := newFakeBackend(t, "mnemon/mnemond-a", progressRef) - env := githubBackendTestEnvelope(t, "replica-a", "dec-a", progressRef, map[string]any{"content": "same key"}) - if resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-a", Events: []eventmodel.EventEnvelope{env}}); err != nil || len(resp.Accepted) != 1 { - t.Fatalf("seed push: resp=%+v err=%v", resp, err) - } - changedBody := env - changedBody.Event.CorrelationID = "changed-body" - resp, err := backend.SyncPush(contract.SyncPushRequest{ReplicaID: "replica-a", BatchID: "batch-b", Events: []eventmodel.EventEnvelope{changedBody}}) - if err != nil { - t.Fatalf("conflict push: %v", err) - } - if len(resp.Conflicts) != 1 || !strings.Contains(resp.Conflicts[0].Diagnostic, "different content") { - t.Fatalf("same key different body must conflict, got %+v", resp) - } -} - -func TestGitHubBackendFakePullReturnsValidEventsAndSkipsOwnOrigin(t *testing.T) { - store, backend := newFakeBackend(t, "mnemon/mnemond-b", progressRef) - foreign := githubBackendTestEnvelope(t, "replica-b", "dec-foreign", progressRef, map[string]any{"content": "foreign progress"}) - own := githubBackendTestEnvelope(t, "replica-a", "dec-own", progressRef, map[string]any{"content": "own progress"}) - putStoredEvent(t, store, "mnemon/mnemond-b", foreign) - putStoredEvent(t, store, "mnemon/mnemond-b", own) - - resp, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a"}) - if err != nil { - t.Fatalf("sync pull: %v", err) - } - if len(resp.Events) != 1 || resp.Events[0].Event.ID != foreign.Event.ID || len(resp.Diagnostics) != 0 || resp.NextCursor != "2" { - t.Fatalf("pull resp = %+v, want only foreign event and cursor 2", resp) - } - again, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a", RemoteCursor: resp.NextCursor}) - if err != nil { - t.Fatalf("sync pull after cursor: %v", err) - } - if len(again.Events) != 0 || len(again.Diagnostics) != 0 || again.NextCursor != "2" { - t.Fatalf("cursor pull must be empty at cursor 2, got %+v", again) - } -} - -func TestGitHubBackendFakePullReturnsDiagnostics(t *testing.T) { - store, backend := newFakeBackend(t, "mnemon/mnemond-b", progressRef) - invalidPhase := githubBackendTestEnvelope(t, "replica-b", "dec-invalid-phase", progressRef, map[string]any{"content": "invalid phase"}) - invalidPhase.Phase = eventmodel.PhaseAccepted - badDigest := githubBackendTestEnvelope(t, "replica-b", "dec-bad-digest", progressRef, map[string]any{"content": "bad digest"}) - badDigest.Meta["digest"] = "wrong" - outOfScope := githubBackendTestEnvelope(t, "replica-b", "dec-out-scope", contract.ResourceRef{Kind: "assignment", ID: "project"}, map[string]any{"content": "assignment"}) - putStoredEvent(t, store, "mnemon/mnemond-b", invalidPhase) - putStoredEvent(t, store, "mnemon/mnemond-b", badDigest) - putStoredEvent(t, store, "mnemon/mnemond-b", outOfScope) - - resp, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a"}) - if err != nil { - t.Fatalf("sync pull diagnostics: %v", err) - } - if len(resp.Events) != 0 || len(resp.Diagnostics) != 3 { - t.Fatalf("pull diagnostics resp = %+v, want three diagnostics", resp) - } - joined := diagnosticsText(resp.Diagnostics) - for _, want := range []string{"phase", "fields_digest", "outside configured publication scope"} { - if !strings.Contains(joined, want) { - t.Fatalf("diagnostics missing %q in %s", want, joined) - } - } -} - -func TestGitHubBackendPullNormalizesOpaquePublicationCursor(t *testing.T) { - env := githubBackendTestEnvelope(t, "replica-b", "dec-foreign", progressRef, map[string]any{"content": "foreign progress"}) - body, err := json.Marshal(env) - if err != nil { - t.Fatal(err) - } - store := opaqueCursorStore{ - events: []exchange.PublicationStoredEvent{{ - Path: "events/replica-b/foreign.json", - Body: body, - Cursor: "head-abc", - }}, - } - backend, err := New(Config{Store: store, Repo: "mnemon-dev/mnemon-teamwork-example", Branch: "mnemon/mnemond-b", Scopes: []contract.ResourceRef{progressRef}}) - if err != nil { - t.Fatal(err) - } - - resp, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a"}) - if err != nil { - t.Fatalf("sync pull: %v", err) - } - if resp.NextCursor != "1" { - t.Fatalf("opaque publication cursor must normalize to local numeric cursor, got %q", resp.NextCursor) - } - if len(resp.Events) != 1 || resp.Events[0].Event.ID != env.Event.ID { - t.Fatalf("sync pull events = %+v, want foreign event", resp.Events) - } - - again, err := backend.SyncPull(contract.SyncPullRequest{ReplicaID: "replica-a", RemoteCursor: resp.NextCursor}) - if err != nil { - t.Fatalf("sync pull with local cursor: %v", err) - } - if again.NextCursor != resp.NextCursor || len(again.Events) != 1 { - t.Fatalf("opaque cursor pull should keep local cursor and rely on import idempotency, got %+v", again) - } -} - -func newFakeBackend(t *testing.T, branch string, scopes ...contract.ResourceRef) (*exchange.MemoryPublicationStore, *Backend) { - t.Helper() - store, err := exchange.NewMemoryPublicationStore(branch) - if err != nil { - t.Fatal(err) - } - backend, err := New(Config{Store: store, Repo: "mnemon-dev/mnemon-teamwork-example", Branch: branch, Scopes: scopes}) - if err != nil { - t.Fatal(err) - } - return store, backend -} - -type opaqueCursorStore struct { - events []exchange.PublicationStoredEvent -} - -func (s opaqueCursorStore) PutEvent(context.Context, string, string, []byte) (exchange.PublicationPutResult, error) { - return exchange.PublicationPutResult{}, nil -} - -func (s opaqueCursorStore) ListEvents(context.Context, string, string, string) (exchange.PublicationListResult, error) { - return exchange.PublicationListResult{Events: append([]exchange.PublicationStoredEvent(nil), s.events...), NextCursor: "head-abc"}, nil -} - -func (s opaqueCursorStore) ReadFile(context.Context, string, string) ([]byte, error) { - return nil, nil -} - -func (s opaqueCursorStore) WriteFile(context.Context, string, string, []byte) error { - return nil -} - -func putStoredEvent(t *testing.T, store *exchange.MemoryPublicationStore, branch string, env eventmodel.EventEnvelope) { - t.Helper() - path, err := exchange.PublicationEventPath(githubBackendPathEnvelope(env)) - if err != nil { - t.Fatalf("publication path: %v", err) - } - body, err := json.Marshal(env) - if err != nil { - t.Fatal(err) - } - if _, err := store.PutEvent(context.Background(), branch, path, body); err != nil { - t.Fatalf("put stored event: %v", err) - } -} - -func githubBackendPathEnvelope(env eventmodel.EventEnvelope) eventmodel.EventEnvelope { - if env.Phase == eventmodel.PhaseSynced { - return env - } - out := env - out.Phase = eventmodel.PhaseSynced - return out -} - -func githubBackendTestEnvelope(t *testing.T, origin, decisionID string, ref contract.ResourceRef, fields map[string]any) eventmodel.EventEnvelope { - t.Helper() - env, err := contract.SyncedEventEnvelopeFromMaterial(contract.SyncedEventMaterial{ - OriginReplicaID: origin, - LocalDecisionID: decisionID, - LocalIngestSeq: 7, - Actor: "codex@project", - ResourceRef: ref, - ResourceVersion: 1, - FieldsDigest: syncedFieldsDigest(fields), - Fields: fields, - DecidedAt: "2026-06-12T00:00:00Z", - Status: "pending", - }) - if err != nil { - t.Fatalf("synced event envelope: %v", err) - } - return env -} - -func diagnosticsText(items []contract.EventExchangeResult) string { - var b strings.Builder - for _, item := range items { - b.WriteString(item.Diagnostic) - b.WriteByte('\n') - } - return b.String() -} diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store.go deleted file mode 100644 index 58b97c28..00000000 --- a/harness/internal/mnemonhub/exchange/backend/github/publication_store.go +++ /dev/null @@ -1,616 +0,0 @@ -package githubbackend - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - pathpkg "path" - "sort" - "strings" - "sync" - "time" - - "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" -) - -const githubAPIVersion = "2022-11-28" - -type PublicationStoreConfig struct { - Repo string - Token string - BaseURL string - UserAgent string - HTTPClient *http.Client - MutativeDelay time.Duration -} - -type GitHubPublicationStore struct { - owner string - repo string - token string - baseURL string - userAgent string - client *http.Client - mutativeDelay time.Duration - writeMu sync.Mutex - lastWrite time.Time -} - -func NewPublicationStore(cfg PublicationStoreConfig) (*GitHubPublicationStore, error) { - repo, err := exchange.NormalizeGitHubRepo(cfg.Repo) - if err != nil { - return nil, err - } - owner, name, _ := strings.Cut(repo, "/") - baseURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") - if baseURL == "" { - baseURL = "https://api.github.com" - } - client := cfg.HTTPClient - if client == nil { - client = &http.Client{Timeout: 30 * time.Second} - } - userAgent := strings.TrimSpace(cfg.UserAgent) - if userAgent == "" { - userAgent = "mnemon-harness" - } - return &GitHubPublicationStore{ - owner: owner, - repo: name, - token: strings.TrimSpace(cfg.Token), - baseURL: baseURL, - userAgent: userAgent, - client: client, - mutativeDelay: cfg.MutativeDelay, - }, nil -} - -func (s *GitHubPublicationStore) PutEvent(ctx context.Context, branch string, path string, body []byte) (exchange.PublicationPutResult, error) { - branch, path, err := normalizeGitHubPublicationEventRef(branch, path) - if err != nil { - return exchange.PublicationPutResult{}, err - } - s.writeMu.Lock() - defer s.writeMu.Unlock() - existing, found, err := s.readFileWithSHA(ctx, branch, path) - if err != nil { - return exchange.PublicationPutResult{}, err - } - if found { - if bytes.Equal(existing.body, body) { - return exchange.PublicationPutResult{ExistsSame: true}, nil - } - return exchange.PublicationPutResult{Conflict: true}, nil - } - if err := s.pauseBeforeMutation(ctx); err != nil { - return exchange.PublicationPutResult{}, err - } - if err := s.putFile(ctx, branch, path, body, ""); err != nil { - if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusConflict { - return exchange.PublicationPutResult{Conflict: true}, nil - } - return exchange.PublicationPutResult{}, err - } - s.lastWrite = time.Now() - return exchange.PublicationPutResult{Created: true}, nil -} - -func (s *GitHubPublicationStore) ListEvents(ctx context.Context, branch string, prefix string, cursor string) (exchange.PublicationListResult, error) { - branch, err := exchange.NormalizePublicationBranch(branch) - if err != nil { - return exchange.PublicationListResult{}, err - } - prefix, err = normalizeGitHubPublicationEventPrefix(prefix) - if err != nil { - return exchange.PublicationListResult{}, err - } - head, err := s.branchHead(ctx, branch) - if err != nil { - return exchange.PublicationListResult{}, err - } - if strings.TrimSpace(cursor) == head { - return exchange.PublicationListResult{NextCursor: head}, nil - } - paths, err := s.listChangedEventPaths(ctx, head, prefix, cursor) - if err != nil { - return exchange.PublicationListResult{}, err - } - events := make([]exchange.PublicationStoredEvent, 0, len(paths)) - for _, path := range paths { - file, found, err := s.readFileWithSHA(ctx, head, path) - if err != nil { - return exchange.PublicationListResult{}, err - } - if !found { - continue - } - events = append(events, exchange.PublicationStoredEvent{Path: path, Body: file.body, Cursor: head}) - } - return exchange.PublicationListResult{Events: events, NextCursor: head}, nil -} - -func (s *GitHubPublicationStore) ReadFile(ctx context.Context, branch string, path string) ([]byte, error) { - branch, path, err := normalizeGitHubPublicationFileRef(branch, path) - if err != nil { - return nil, err - } - file, found, err := s.readFileWithSHA(ctx, branch, path) - if err != nil { - return nil, err - } - if !found { - return nil, fmt.Errorf("publication file %s:%s not found", branch, path) - } - return append([]byte(nil), file.body...), nil -} - -func (s *GitHubPublicationStore) WriteFile(ctx context.Context, branch string, path string, body []byte) error { - branch, path, err := normalizeGitHubPublicationFileRef(branch, path) - if err != nil { - return err - } - if strings.HasPrefix(path, exchange.PublicationEventRoot+"/") { - return fmt.Errorf("publication event path %q must be written with PutEvent", path) - } - s.writeMu.Lock() - defer s.writeMu.Unlock() - existing, found, err := s.readFileWithSHA(ctx, branch, path) - if err != nil { - return err - } - sha := "" - if found { - sha = existing.sha - } - if err := s.pauseBeforeMutation(ctx); err != nil { - return err - } - if err := s.putFile(ctx, branch, path, body, sha); err != nil { - return err - } - s.lastWrite = time.Now() - return nil -} - -func (s *GitHubPublicationStore) EnsureBranch(ctx context.Context, branch string, baseBranch string) error { - return s.EnsureBranches(ctx, []string{branch}, baseBranch) -} - -func (s *GitHubPublicationStore) EnsureBranches(ctx context.Context, branches []string, baseBranch string) error { - baseBranch, err := normalizeGitHubBranchName(baseBranch) - if err != nil { - return fmt.Errorf("base branch: %w", err) - } - if baseBranch == "" { - baseBranch = "main" - } - normalized := make([]string, 0, len(branches)) - seen := map[string]bool{} - for _, branch := range branches { - branch, err := exchange.NormalizePublicationBranch(branch) - if err != nil { - return err - } - if seen[branch] { - continue - } - seen[branch] = true - normalized = append(normalized, branch) - } - if len(normalized) == 0 { - return nil - } - var missing []string - for _, branch := range normalized { - if _, err := s.branchHead(ctx, branch); err == nil { - continue - } else if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusNotFound { - missing = append(missing, branch) - continue - } else { - return err - } - } - if len(missing) == 0 { - return nil - } - baseSHA, err := s.branchHead(ctx, baseBranch) - if err != nil { - return fmt.Errorf("read base branch %q: %w", baseBranch, err) - } - s.writeMu.Lock() - defer s.writeMu.Unlock() - for _, branch := range missing { - if err := s.pauseBeforeMutation(ctx); err != nil { - return err - } - req := githubCreateRefRequest{ - Ref: "refs/heads/" + branch, - SHA: baseSHA, - } - status, err := s.do(ctx, http.MethodPost, "/repos/"+s.owner+"/"+s.repo+"/git/refs", nil, req, nil) - if err != nil { - if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusUnprocessableEntity { - if _, headErr := s.branchHead(ctx, branch); headErr == nil { - continue - } - } - return fmt.Errorf("create branch %q: %w", branch, err) - } - if status != http.StatusCreated { - return fmt.Errorf("create branch %q returned status %d", branch, status) - } - s.lastWrite = time.Now() - } - return nil -} - -type githubFile struct { - body []byte - sha string -} - -type githubContentResponse struct { - Type string `json:"type"` - Path string `json:"path"` - SHA string `json:"sha"` - Encoding string `json:"encoding"` - Content string `json:"content"` -} - -type githubContentEntry struct { - Type string `json:"type"` - Path string `json:"path"` -} - -type githubCompareResponse struct { - Files []struct { - Filename string `json:"filename"` - Status string `json:"status"` - } `json:"files"` -} - -type githubPutFileRequest struct { - Message string `json:"message"` - Content string `json:"content"` - SHA string `json:"sha,omitempty"` - Branch string `json:"branch"` -} - -type githubCreateRefRequest struct { - Ref string `json:"ref"` - SHA string `json:"sha"` -} - -type githubRefResponse struct { - Object struct { - SHA string `json:"sha"` - } `json:"object"` -} - -type githubAPIError struct { - Status int - Message string - RetryAfter string - RateLimitRemaining string - RateLimitReset string -} - -func (e *githubAPIError) Error() string { - base := "" - if strings.TrimSpace(e.Message) == "" { - base = fmt.Sprintf("github api status %d", e.Status) - } else { - base = fmt.Sprintf("github api status %d: %s", e.Status, e.Message) - } - var hints []string - if strings.TrimSpace(e.RetryAfter) != "" { - hints = append(hints, "retry_after="+strings.TrimSpace(e.RetryAfter)) - } - if strings.TrimSpace(e.RateLimitRemaining) != "" { - hints = append(hints, "rate_limit_remaining="+strings.TrimSpace(e.RateLimitRemaining)) - } - if strings.TrimSpace(e.RateLimitReset) != "" { - hints = append(hints, "rate_limit_reset="+strings.TrimSpace(e.RateLimitReset)) - } - if len(hints) == 0 { - return base - } - return base + " (" + strings.Join(hints, ", ") + ")" -} - -func (s *GitHubPublicationStore) readFileWithSHA(ctx context.Context, branch, path string) (githubFile, bool, error) { - var out githubContentResponse - status, err := s.do(ctx, http.MethodGet, "/repos/"+s.owner+"/"+s.repo+"/contents/"+escapeGitHubPath(path), url.Values{"ref": []string{branch}}, nil, &out) - if err != nil { - if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusNotFound { - return githubFile{}, false, nil - } - return githubFile{}, false, err - } - if status != http.StatusOK { - return githubFile{}, false, fmt.Errorf("github content read returned status %d", status) - } - body, err := decodeGitHubContent(out) - if err != nil { - return githubFile{}, false, err - } - return githubFile{body: body, sha: out.SHA}, true, nil -} - -func (s *GitHubPublicationStore) putFile(ctx context.Context, branch, path string, body []byte, sha string) error { - req := githubPutFileRequest{ - Message: "mnemon publication update " + path, - Content: base64.StdEncoding.EncodeToString(body), - SHA: sha, - Branch: branch, - } - status, err := s.do(ctx, http.MethodPut, "/repos/"+s.owner+"/"+s.repo+"/contents/"+escapeGitHubPath(path), nil, req, nil) - if err != nil { - return err - } - if status != http.StatusOK && status != http.StatusCreated { - return fmt.Errorf("github content write returned status %d", status) - } - return nil -} - -func (s *GitHubPublicationStore) branchHead(ctx context.Context, branch string) (string, error) { - var out githubRefResponse - status, err := s.do(ctx, http.MethodGet, "/repos/"+s.owner+"/"+s.repo+"/git/ref/heads/"+escapeGitHubPath(branch), nil, nil, &out) - if err != nil { - return "", err - } - if status != http.StatusOK || strings.TrimSpace(out.Object.SHA) == "" { - return "", fmt.Errorf("github branch ref %q returned status %d without sha", branch, status) - } - return strings.TrimSpace(out.Object.SHA), nil -} - -func (s *GitHubPublicationStore) listChangedEventPaths(ctx context.Context, head, prefix, cursor string) ([]string, error) { - cursor = strings.TrimSpace(cursor) - if cursor != "" { - paths, ok, err := s.compareEventPaths(ctx, cursor, head, prefix) - if err != nil { - return nil, err - } - if ok { - return paths, nil - } - } - return s.listEventPaths(ctx, head, prefix) -} - -func (s *GitHubPublicationStore) compareEventPaths(ctx context.Context, base, head, prefix string) ([]string, bool, error) { - if strings.TrimSpace(base) == "" || strings.TrimSpace(head) == "" || base == head { - return nil, true, nil - } - var out githubCompareResponse - status, err := s.do(ctx, http.MethodGet, "/repos/"+s.owner+"/"+s.repo+"/compare/"+escapeGitHubPath(base)+"..."+escapeGitHubPath(head), nil, nil, &out) - if err != nil { - if apiErr, ok := err.(*githubAPIError); ok && (apiErr.Status == http.StatusNotFound || apiErr.Status == http.StatusUnprocessableEntity) { - return nil, false, nil - } - return nil, false, err - } - if status != http.StatusOK { - return nil, false, fmt.Errorf("github compare returned status %d", status) - } - seen := map[string]bool{} - var paths []string - for _, file := range out.Files { - path := strings.TrimSpace(file.Filename) - if path == "" || !strings.HasPrefix(path, prefix) || seen[path] { - continue - } - switch strings.TrimSpace(file.Status) { - case "removed", "deleted": - continue - } - seen[path] = true - paths = append(paths, path) - } - sort.Strings(paths) - return paths, true, nil -} - -func (s *GitHubPublicationStore) listEventPaths(ctx context.Context, branch, prefix string) ([]string, error) { - entries, err := s.listDir(ctx, branch, prefix) - if err != nil { - if apiErr, ok := err.(*githubAPIError); ok && apiErr.Status == http.StatusNotFound { - return nil, nil - } - return nil, err - } - var paths []string - for _, entry := range entries { - switch entry.Type { - case "dir": - nested, err := s.listEventPaths(ctx, branch, entry.Path) - if err != nil { - return nil, err - } - paths = append(paths, nested...) - case "file": - if strings.HasPrefix(entry.Path, prefix) { - paths = append(paths, entry.Path) - } - } - } - sort.Strings(paths) - return paths, nil -} - -func (s *GitHubPublicationStore) listDir(ctx context.Context, branch, path string) ([]githubContentEntry, error) { - var entries []githubContentEntry - status, err := s.do(ctx, http.MethodGet, "/repos/"+s.owner+"/"+s.repo+"/contents/"+escapeGitHubPath(path), url.Values{"ref": []string{branch}}, nil, &entries) - if err != nil { - return nil, err - } - if status != http.StatusOK { - return nil, fmt.Errorf("github directory list returned status %d", status) - } - return entries, nil -} - -func (s *GitHubPublicationStore) do(ctx context.Context, method, apiPath string, query url.Values, in any, out any) (int, error) { - var body io.Reader - if in != nil { - data, err := json.Marshal(in) - if err != nil { - return 0, err - } - body = bytes.NewReader(data) - } - u := s.baseURL + apiPath - if len(query) > 0 { - u += "?" + query.Encode() - } - req, err := http.NewRequestWithContext(ctx, method, u, body) - if err != nil { - return 0, err - } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("X-GitHub-Api-Version", githubAPIVersion) - req.Header.Set("User-Agent", s.userAgent) - if in != nil { - req.Header.Set("Content-Type", "application/json") - } - if s.token != "" { - req.Header.Set("Authorization", "Bearer "+s.token) - } - resp, err := s.client.Do(req) - if err != nil { - return 0, err - } - defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) - if err != nil { - return resp.StatusCode, err - } - if resp.StatusCode >= 300 { - var apiErr struct { - Message string `json:"message"` - } - _ = json.Unmarshal(data, &apiErr) - return resp.StatusCode, &githubAPIError{ - Status: resp.StatusCode, - Message: apiErr.Message, - RetryAfter: resp.Header.Get("Retry-After"), - RateLimitRemaining: resp.Header.Get("X-RateLimit-Remaining"), - RateLimitReset: resp.Header.Get("X-RateLimit-Reset"), - } - } - if out != nil && len(data) > 0 { - if err := json.Unmarshal(data, out); err != nil { - return resp.StatusCode, err - } - } - return resp.StatusCode, nil -} - -func (s *GitHubPublicationStore) pauseBeforeMutation(ctx context.Context) error { - if s.mutativeDelay <= 0 || s.lastWrite.IsZero() { - return nil - } - wait := s.mutativeDelay - time.Since(s.lastWrite) - if wait <= 0 { - return nil - } - timer := time.NewTimer(wait) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} - -func decodeGitHubContent(resp githubContentResponse) ([]byte, error) { - if resp.Type != "" && resp.Type != "file" { - return nil, fmt.Errorf("github content %q is not a file", resp.Path) - } - if resp.Encoding != "base64" { - return nil, fmt.Errorf("github content %q has unsupported encoding %q", resp.Path, resp.Encoding) - } - content := strings.ReplaceAll(resp.Content, "\n", "") - body, err := base64.StdEncoding.DecodeString(content) - if err != nil { - return nil, err - } - return body, nil -} - -func normalizeGitHubPublicationEventRef(branch, path string) (string, string, error) { - branch, path, err := normalizeGitHubPublicationFileRef(branch, path) - if err != nil { - return "", "", err - } - if !strings.HasPrefix(path, exchange.PublicationEventRoot+"/") { - return "", "", fmt.Errorf("publication event path %q must be under %s", path, exchange.PublicationEventRoot) - } - return branch, path, nil -} - -func normalizeGitHubPublicationFileRef(branch, path string) (string, string, error) { - branch, err := exchange.NormalizePublicationBranch(branch) - if err != nil { - return "", "", err - } - path, err = exchange.NormalizePublicationPath(path) - if err != nil { - return "", "", err - } - return branch, path, nil -} - -func normalizeGitHubBranchName(branch string) (string, error) { - branch = strings.TrimSpace(branch) - if branch == "" { - return "", nil - } - if strings.Contains(branch, "\\") || strings.HasPrefix(branch, "/") { - return "", fmt.Errorf("branch %q is invalid", branch) - } - for _, part := range strings.Split(branch, "/") { - if part == "" || part == "." || part == ".." { - return "", fmt.Errorf("branch %q is invalid", branch) - } - } - if pathpkg.Clean(branch) != branch { - return "", fmt.Errorf("branch %q is invalid", branch) - } - return branch, nil -} - -func normalizeGitHubPublicationEventPrefix(prefix string) (string, error) { - prefix, err := exchange.NormalizePublicationPath(prefix) - if err != nil { - return "", err - } - if prefix == exchange.PublicationEventRoot { - return prefix, nil - } - if !strings.HasPrefix(prefix, exchange.PublicationEventRoot+"/") { - return "", fmt.Errorf("publication event prefix %q must be under %s", prefix, exchange.PublicationEventRoot) - } - return prefix, nil -} - -func escapeGitHubPath(path string) string { - clean := pathpkg.Clean(strings.Trim(path, "/")) - if clean == "." { - return "" - } - parts := strings.Split(clean, "/") - for i, part := range parts { - parts[i] = url.PathEscape(part) - } - return strings.Join(parts, "/") -} diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go deleted file mode 100644 index 675f8622..00000000 --- a/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go +++ /dev/null @@ -1,489 +0,0 @@ -package githubbackend - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - - "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" -) - -func TestGitHubPublicationStorePutEventCreateAndIdempotent(t *testing.T) { - fake := newFakeGitHubPublicationAPI(t) - store, err := NewPublicationStore(PublicationStoreConfig{ - Repo: "mnemon-dev/mnemon-teamwork-example", - Token: "secret-token", - BaseURL: fake.server.URL, - HTTPClient: fake.server.Client(), - }) - if err != nil { - t.Fatal(err) - } - path := exchange.PublicationEventRoot + "/replica-a/progress_digest/project/000000000001-dec-a.json" - - first, err := store.PutEvent(context.Background(), "mnemon/mnemond-a", path, []byte(`{"id":"a"}`)) - if err != nil { - t.Fatalf("put event create: %v", err) - } - if !first.Created || fake.puts != 1 { - t.Fatalf("first put = %+v, puts=%d; want created with one PUT", first, fake.puts) - } - same, err := store.PutEvent(context.Background(), "mnemon/mnemond-a", path, []byte(`{"id":"a"}`)) - if err != nil { - t.Fatalf("put event same: %v", err) - } - if !same.ExistsSame || same.Conflict || fake.puts != 1 { - t.Fatalf("same put = %+v puts=%d; want idempotent without PUT", same, fake.puts) - } - conflict, err := store.PutEvent(context.Background(), "mnemon/mnemond-a", path, []byte(`{"id":"b"}`)) - if err != nil { - t.Fatalf("put event conflict: %v", err) - } - if !conflict.Conflict || fake.puts != 1 { - t.Fatalf("conflict put = %+v puts=%d; want conflict without overwrite", conflict, fake.puts) - } -} - -func TestGitHubPublicationStoreWriteFileUpdatesWithSHA(t *testing.T) { - fake := newFakeGitHubPublicationAPI(t) - fake.files["mnemon/team:.mnemon/team.json"] = fakeGitHubFile{body: []byte(`{"schema_version":1}`), sha: "sha-existing"} - store, err := NewPublicationStore(PublicationStoreConfig{ - Repo: "mnemon-dev/mnemon-teamwork-example", - BaseURL: fake.server.URL, - HTTPClient: fake.server.Client(), - }) - if err != nil { - t.Fatal(err) - } - - if err := store.WriteFile(context.Background(), "mnemon/team", ".mnemon/team.json", []byte(`{"schema_version":2}`)); err != nil { - t.Fatalf("write team manifest: %v", err) - } - if fake.lastSHA != "sha-existing" { - t.Fatalf("write file must include existing sha, got %q", fake.lastSHA) - } - body, err := store.ReadFile(context.Background(), "mnemon/team", ".mnemon/team.json") - if err != nil { - t.Fatalf("read team manifest: %v", err) - } - if string(body) != `{"schema_version":2}` { - t.Fatalf("read body = %s", body) - } -} - -func TestGitHubPublicationStoreListEventsUsesBranchHeadCursor(t *testing.T) { - fake := newFakeGitHubPublicationAPI(t) - fake.head = "head-2" - fake.files["head-2:"+exchange.PublicationEventRoot+"/replica-b/progress_digest/project/000000000001-dec-b.json"] = fakeGitHubFile{body: []byte(`{"id":"b"}`), sha: "sha-b"} - fake.files["head-2:"+exchange.PublicationEventRoot+"/replica-c/progress_digest/project/000000000001-dec-c.json"] = fakeGitHubFile{body: []byte(`{"id":"c"}`), sha: "sha-c"} - store, err := NewPublicationStore(PublicationStoreConfig{ - Repo: "mnemon-dev/mnemon-teamwork-example", - BaseURL: fake.server.URL, - HTTPClient: fake.server.Client(), - }) - if err != nil { - t.Fatal(err) - } - - list, err := store.ListEvents(context.Background(), "mnemon/mnemond-b", exchange.PublicationEventRoot, "") - if err != nil { - t.Fatalf("list events: %v", err) - } - if len(list.Events) != 2 || list.NextCursor != "head-2" { - t.Fatalf("list = %+v, want two events at head-2", list) - } - if !stringSliceContains(fake.contentRefs, "head-2") { - t.Fatalf("list events must read contents at branch head sha, refs=%v", fake.contentRefs) - } - again, err := store.ListEvents(context.Background(), "mnemon/mnemond-b", exchange.PublicationEventRoot, list.NextCursor) - if err != nil { - t.Fatalf("list after head cursor: %v", err) - } - if len(again.Events) != 0 || again.NextCursor != "head-2" { - t.Fatalf("list after head cursor = %+v, want empty", again) - } -} - -func TestGitHubPublicationStoreListEventsUsesCompareAfterCursor(t *testing.T) { - fake := newFakeGitHubPublicationAPI(t) - fake.head = "head-2" - oldPath := exchange.PublicationEventRoot + "/replica-b/progress_digest/project/000000000001-dec-b.json" - newPath := exchange.PublicationEventRoot + "/replica-b/progress_digest/project/000000000002-dec-b.json" - fake.files["head-1:"+oldPath] = fakeGitHubFile{body: []byte(`{"id":"old"}`), sha: "sha-old"} - fake.files["head-2:"+oldPath] = fakeGitHubFile{body: []byte(`{"id":"old"}`), sha: "sha-old"} - fake.files["head-2:"+newPath] = fakeGitHubFile{body: []byte(`{"id":"new"}`), sha: "sha-new"} - fake.files["head-2:README.md"] = fakeGitHubFile{body: []byte(`# ignored`), sha: "sha-readme"} - store, err := NewPublicationStore(PublicationStoreConfig{ - Repo: "mnemon-dev/mnemon-teamwork-example", - BaseURL: fake.server.URL, - HTTPClient: fake.server.Client(), - }) - if err != nil { - t.Fatal(err) - } - - list, err := store.ListEvents(context.Background(), "mnemon/mnemond-b", exchange.PublicationEventRoot, "head-1") - if err != nil { - t.Fatalf("list events after cursor: %v", err) - } - if len(list.Events) != 1 || list.Events[0].Path != newPath || string(list.Events[0].Body) != `{"id":"new"}` || list.NextCursor != "head-2" { - t.Fatalf("list after cursor = %+v, want only changed publication event", list) - } - if len(fake.compareRefs) != 1 || fake.compareRefs[0] != "head-1...head-2" { - t.Fatalf("compare refs = %v, want head-1...head-2", fake.compareRefs) - } - if fake.dirLists != 0 { - t.Fatalf("incremental list should not scan directories, dirLists=%d", fake.dirLists) - } -} - -func TestGitHubPublicationStoreEnsureBranchCreatesMissingBranchFromMain(t *testing.T) { - fake := newFakeGitHubPublicationAPI(t) - fake.refs["main"] = "main-sha" - fake.missingRefs["mnemon/mnemond-run-1-a"] = true - store, err := NewPublicationStore(PublicationStoreConfig{ - Repo: "mnemon-dev/mnemon-teamwork-example", - BaseURL: fake.server.URL, - HTTPClient: fake.server.Client(), - }) - if err != nil { - t.Fatal(err) - } - - if err := store.EnsureBranch(context.Background(), "mnemon/mnemond-run-1-a", "main"); err != nil { - t.Fatalf("ensure branch: %v", err) - } - if fake.creates != 1 { - t.Fatalf("creates = %d, want one branch create", fake.creates) - } - if got := fake.refs["mnemon/mnemond-run-1-a"]; got != "main-sha" { - t.Fatalf("created branch sha = %q, want main-sha", got) - } - if err := store.EnsureBranch(context.Background(), "mnemon/mnemond-run-1-a", "main"); err != nil { - t.Fatalf("ensure branch again: %v", err) - } - if fake.creates != 1 { - t.Fatalf("idempotent ensure must not recreate branch, creates=%d", fake.creates) - } -} - -func TestGitHubPublicationStoreEnsureBranchesReadsBaseOnce(t *testing.T) { - fake := newFakeGitHubPublicationAPI(t) - fake.refs["main"] = "main-sha" - fake.missingRefs["mnemon/mnemond-run-a"] = true - fake.missingRefs["mnemon/mnemond-run-b"] = true - store, err := NewPublicationStore(PublicationStoreConfig{ - Repo: "mnemon-dev/mnemon-teamwork-example", - BaseURL: fake.server.URL, - HTTPClient: fake.server.Client(), - }) - if err != nil { - t.Fatal(err) - } - - err = store.EnsureBranches(context.Background(), []string{ - "mnemon/mnemond-run-a", - "mnemon/mnemond-run-b", - "mnemon/mnemond-run-b", - }, "main") - if err != nil { - t.Fatalf("ensure branches: %v", err) - } - if fake.creates != 2 { - t.Fatalf("creates = %d, want two branch creates", fake.creates) - } - if got := fake.refReads["main"]; got != 1 { - t.Fatalf("main ref reads = %d, want one", got) - } - for _, branch := range []string{"mnemon/mnemond-run-a", "mnemon/mnemond-run-b"} { - if got := fake.refs[branch]; got != "main-sha" { - t.Fatalf("created branch %s sha = %q, want main-sha", branch, got) - } - } -} - -func TestGitHubPublicationStoreRateLimitErrorIncludesRetryHints(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Retry-After", "120") - w.Header().Set("X-RateLimit-Remaining", "0") - w.Header().Set("X-RateLimit-Reset", "1782416790") - writeJSON(w, http.StatusForbidden, map[string]any{"message": "API rate limit exceeded"}) - })) - t.Cleanup(server.Close) - store, err := NewPublicationStore(PublicationStoreConfig{ - Repo: "mnemon-dev/mnemon-teamwork-example", - BaseURL: server.URL, - HTTPClient: server.Client(), - }) - if err != nil { - t.Fatal(err) - } - - err = store.EnsureBranch(context.Background(), "mnemon/mnemond-run-a", "main") - if err == nil { - t.Fatal("ensure branch should return rate-limit error") - } - msg := err.Error() - for _, want := range []string{"API rate limit exceeded", "retry_after=120", "rate_limit_remaining=0", "rate_limit_reset=1782416790"} { - if !strings.Contains(msg, want) { - t.Fatalf("error %q does not include %q", msg, want) - } - } -} - -func TestGitHubPublicationStoreLiveGated(t *testing.T) { - if os.Getenv("MNEMON_GITHUB_LIVE") != "1" { - t.Skip("set MNEMON_GITHUB_LIVE=1 to run the real GitHub publication store smoke test") - } - token := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) - if token == "" { - t.Skip("GITHUB_TOKEN is required for live GitHub publication store smoke test") - } - repo := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_REPO")) - if repo == "" { - repo = "mnemon-dev/mnemon-teamwork-example" - } - branch := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_BRANCH")) - if branch == "" { - branch = "mnemon/mnemond-a" - } - store, err := NewPublicationStore(PublicationStoreConfig{Repo: repo, Token: token}) - if err != nil { - t.Fatal(err) - } - if err := store.EnsureBranch(context.Background(), branch, "main"); err != nil { - t.Fatalf("ensure live branch: %v", err) - } - path := exchange.PublicationEventRoot + "/live-smoke/progress_digest/project/000000000001-live-smoke.json" - body := []byte(`{"schema_version":1,"source":"mnemon live smoke"}`) - res, err := store.PutEvent(context.Background(), branch, path, body) - if err != nil { - t.Fatalf("live put event: %v", err) - } - if !res.Created && !res.ExistsSame { - t.Fatalf("live put result = %+v, want created or exists_same", res) - } - list, err := store.ListEvents(context.Background(), branch, exchange.PublicationEventRoot, "") - if err != nil { - t.Fatalf("live list events: %v", err) - } - if list.NextCursor == "" { - t.Fatalf("live list must return a branch-head cursor: %+v", list) - } -} - -type fakeGitHubPublicationAPI struct { - server *httptest.Server - files map[string]fakeGitHubFile - refs map[string]string - missingRefs map[string]bool - refReads map[string]int - contentRefs []string - compareRefs []string - head string - dirLists int - puts int - creates int - lastSHA string -} - -type fakeGitHubFile struct { - body []byte - sha string -} - -func newFakeGitHubPublicationAPI(t *testing.T) *fakeGitHubPublicationAPI { - t.Helper() - fake := &fakeGitHubPublicationAPI{ - files: map[string]fakeGitHubFile{}, - refs: map[string]string{}, - missingRefs: map[string]bool{}, - refReads: map[string]int{}, - head: "head-1", - } - fake.server = httptest.NewServer(http.HandlerFunc(fake.handle)) - t.Cleanup(fake.server.Close) - return fake -} - -func (f *fakeGitHubPublicationAPI) handle(w http.ResponseWriter, r *http.Request) { - if token := r.Header.Get("Authorization"); strings.Contains(token, "secret-token") && token != "Bearer secret-token" { - http.Error(w, `{"message":"bad token"}`, http.StatusUnauthorized) - return - } - prefix := "/repos/mnemon-dev/mnemon-teamwork-example/" - if !strings.HasPrefix(r.URL.Path, prefix) { - http.NotFound(w, r) - return - } - tail := strings.TrimPrefix(r.URL.Path, prefix) - switch { - case r.Method == http.MethodGet && strings.HasPrefix(tail, "git/ref/heads/"): - branch := strings.TrimPrefix(tail, "git/ref/heads/") - f.refReads[branch]++ - if f.missingRefs[branch] { - writeJSON(w, http.StatusNotFound, map[string]any{"message": "ref not found"}) - return - } - sha := f.refs[branch] - if sha == "" { - sha = f.head - } - writeJSON(w, http.StatusOK, map[string]any{"object": map[string]any{"sha": sha}}) - case r.Method == http.MethodPost && tail == "git/refs": - var req struct { - Ref string `json:"ref"` - SHA string `json:"sha"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"message": err.Error()}) - return - } - branch := strings.TrimPrefix(req.Ref, "refs/heads/") - if branch == req.Ref || branch == "" || req.SHA == "" { - writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": "invalid ref"}) - return - } - delete(f.missingRefs, branch) - f.refs[branch] = req.SHA - f.creates++ - writeJSON(w, http.StatusCreated, map[string]any{"ref": req.Ref, "object": map[string]any{"sha": req.SHA}}) - case r.Method == http.MethodGet && strings.HasPrefix(tail, "compare/"): - ref := strings.TrimPrefix(tail, "compare/") - f.compareRefs = append(f.compareRefs, ref) - base, head, ok := strings.Cut(ref, "...") - if !ok || base == "" || head == "" { - writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": "invalid compare"}) - return - } - writeJSON(w, http.StatusOK, map[string]any{"files": f.compareFiles(base, head)}) - case strings.HasPrefix(tail, "contents/"): - path := strings.TrimPrefix(tail, "contents/") - branch := r.URL.Query().Get("ref") - f.contentRefs = append(f.contentRefs, branch) - f.handleContents(w, r, branch, path) - default: - http.NotFound(w, r) - } -} - -func stringSliceContains(values []string, want string) bool { - for _, value := range values { - if value == want { - return true - } - } - return false -} - -func (f *fakeGitHubPublicationAPI) handleContents(w http.ResponseWriter, r *http.Request, branch, path string) { - switch r.Method { - case http.MethodGet: - key := branch + ":" + path - if file, ok := f.files[key]; ok { - writeJSON(w, http.StatusOK, map[string]any{ - "type": "file", - "path": path, - "sha": file.sha, - "encoding": "base64", - "content": base64.StdEncoding.EncodeToString(file.body), - }) - return - } - entries := f.dirEntries(branch, path) - if len(entries) > 0 { - f.dirLists++ - writeJSON(w, http.StatusOK, entries) - return - } - writeJSON(w, http.StatusNotFound, map[string]any{"message": "not found"}) - case http.MethodPut: - var req struct { - Content string `json:"content"` - SHA string `json:"sha"` - Branch string `json:"branch"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"message": err.Error()}) - return - } - body, err := base64.StdEncoding.DecodeString(req.Content) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"message": err.Error()}) - return - } - if branch == "" { - branch = req.Branch - } - key := branch + ":" + path - if existing, ok := f.files[key]; ok && req.SHA != existing.sha { - writeJSON(w, http.StatusConflict, map[string]any{"message": "sha mismatch"}) - return - } - f.puts++ - f.lastSHA = req.SHA - f.files[key] = fakeGitHubFile{body: body, sha: "sha-new"} - status := http.StatusCreated - if req.SHA != "" { - status = http.StatusOK - } - writeJSON(w, status, map[string]any{"content": map[string]any{"sha": "sha-new"}}) - default: - http.NotFound(w, r) - } -} - -func (f *fakeGitHubPublicationAPI) compareFiles(base, head string) []map[string]any { - prefix := head + ":" - var out []map[string]any - for key, file := range f.files { - if !strings.HasPrefix(key, prefix) { - continue - } - path := strings.TrimPrefix(key, prefix) - status := "added" - if previous, ok := f.files[base+":"+path]; ok { - if bytes.Equal(previous.body, file.body) { - continue - } - status = "modified" - } - out = append(out, map[string]any{"filename": path, "status": status}) - } - return out -} - -func (f *fakeGitHubPublicationAPI) dirEntries(branch, dir string) []map[string]any { - prefix := branch + ":" + strings.TrimSuffix(dir, "/") + "/" - seen := map[string]map[string]any{} - for key := range f.files { - if !strings.HasPrefix(key, prefix) { - continue - } - rel := strings.TrimPrefix(key, prefix) - head, _, hasRest := strings.Cut(rel, "/") - path := strings.TrimSuffix(dir, "/") + "/" + head - typ := "file" - if hasRest { - typ = "dir" - } - seen[path] = map[string]any{"type": typ, "path": path} - } - out := make([]map[string]any, 0, len(seen)) - for _, entry := range seen { - out = append(out, entry) - } - return out -} - -func writeJSON(w http.ResponseWriter, status int, value any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(value) -} diff --git a/harness/internal/mnemonhub/exchange/publication_store.go b/harness/internal/mnemonhub/exchange/publication_store.go index d5959b82..d3c48c17 100644 --- a/harness/internal/mnemonhub/exchange/publication_store.go +++ b/harness/internal/mnemonhub/exchange/publication_store.go @@ -17,8 +17,8 @@ import ( const PublicationEventRoot = "mnemon-publications/v1/events" // PublicationStore is the storage seam under a Remote Workspace publication backend. It is -// intentionally repository-shaped but not tied to any GitHub client, so exchange semantics can be -// tested against a deterministic fake before a real API adapter exists. +// intentionally repository-shaped but not tied to any specific repository host, so exchange +// semantics can be tested against a deterministic fake before a real API adapter exists. type PublicationStore interface { PutEvent(ctx context.Context, branch string, path string, body []byte) (PublicationPutResult, error) ListEvents(ctx context.Context, branch string, prefix string, cursor string) (PublicationListResult, error) diff --git a/harness/internal/mnemonhub/exchange/remote_workspace.go b/harness/internal/mnemonhub/exchange/remote_workspace.go index c4a2e9b5..9449eb0f 100644 --- a/harness/internal/mnemonhub/exchange/remote_workspace.go +++ b/harness/internal/mnemonhub/exchange/remote_workspace.go @@ -6,8 +6,8 @@ import "github.com/mnemon-dev/mnemon/harness/internal/contract" // // The method names intentionally match the existing wire verbs so the current // HTTP mnemon-hub client satisfies this interface without a wrapper. Future -// backends, such as a GitHub publication mesh, must present the same accepted -// synced-envelope behavior to the local sync loop. +// backends must present the same accepted synced-envelope behavior to the local +// sync loop. type RemoteWorkspace interface { SyncPush(contract.SyncPushRequest) (contract.SyncPushResponse, error) SyncPull(contract.SyncPullRequest) (contract.SyncPullResponse, error) diff --git a/harness/internal/mnemonhub/exchange/remotes.go b/harness/internal/mnemonhub/exchange/remotes.go index 1c8d8b12..70dbdf36 100644 --- a/harness/internal/mnemonhub/exchange/remotes.go +++ b/harness/internal/mnemonhub/exchange/remotes.go @@ -18,8 +18,7 @@ type RemotesDoc struct { } const ( - RemoteBackendHTTP = "http" - RemoteBackendGitHub = "github" + RemoteBackendHTTP = "http" ) const ( @@ -37,8 +36,6 @@ type RemoteEntry struct { Direction string `json:"direction,omitempty"` ID string `json:"id"` Endpoint string `json:"endpoint,omitempty"` - Repo string `json:"repo,omitempty"` - Branch string `json:"branch,omitempty"` CredentialRef string `json:"credential_ref"` // CAFile optionally pins the remote's TLS root (PEM bundle) — the client trusts exactly it // (sync-abi-v1 §8). Empty = the system roots. @@ -84,10 +81,10 @@ func NormalizeRemoteDirection(direction string) (string, error) { func validateRemoteBackend(backend string) error { switch backend { - case RemoteBackendHTTP, RemoteBackendGitHub: + case RemoteBackendHTTP: return nil default: - return fmt.Errorf("unsupported Remote Workspace backend %q (supported: %s, %s)", backend, RemoteBackendHTTP, RemoteBackendGitHub) + return fmt.Errorf("unsupported Remote Workspace backend %q (supported: %s)", backend, RemoteBackendHTTP) } } @@ -181,54 +178,12 @@ func normalizeRemoteEntry(remote RemoteEntry) (RemoteEntry, error) { if err != nil { return RemoteEntry{}, err } - switch remote.Backend { - case RemoteBackendHTTP: - if strings.TrimSpace(remote.Endpoint) == "" { - return RemoteEntry{}, fmt.Errorf("has no endpoint") - } - case RemoteBackendGitHub: - remote.Repo, err = NormalizeGitHubRepo(remote.Repo) - if err != nil { - return RemoteEntry{}, err - } - remote.Branch, err = NormalizePublicationBranch(remote.Branch) - if err != nil { - return RemoteEntry{}, err - } + if remote.Backend == RemoteBackendHTTP && strings.TrimSpace(remote.Endpoint) == "" { + return RemoteEntry{}, fmt.Errorf("has no endpoint") } return remote, nil } -func NormalizeGitHubRepo(repo string) (string, error) { - repo = strings.TrimSpace(repo) - if repo == "" { - return "", fmt.Errorf("github repo is required") - } - parts := strings.Split(repo, "/") - if len(parts) != 2 { - return "", fmt.Errorf("github repo %q must be owner/name", repo) - } - for _, part := range parts { - if !validGitHubRepoSegment(part) { - return "", fmt.Errorf("github repo %q is invalid", repo) - } - } - return repo, nil -} - -func validGitHubRepoSegment(segment string) bool { - if segment == "" || segment == "." || segment == ".." { - return false - } - for _, r := range segment { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { - continue - } - return false - } - return true -} - func findRemoteEntry(doc RemotesDoc, id string) (RemoteEntry, bool) { for _, remote := range doc.Remotes { if remote.ID == id { diff --git a/harness/internal/mnemonhub/exchange/remotes_test.go b/harness/internal/mnemonhub/exchange/remotes_test.go index d6c069d1..e5d8b03f 100644 --- a/harness/internal/mnemonhub/exchange/remotes_test.go +++ b/harness/internal/mnemonhub/exchange/remotes_test.go @@ -52,7 +52,7 @@ func TestLoadRemoteEntryRejectsUnsupportedBackend(t *testing.T) { } } -func TestLoadRemoteEntryAcceptsGitHubPublicationConfig(t *testing.T) { +func TestLoadRemoteEntryRejectsGitHubBackend(t *testing.T) { path := filepath.Join(t.TempDir(), "remotes.json") if err := os.WriteFile(path, []byte(`{ "schema_version": 1, @@ -69,60 +69,9 @@ func TestLoadRemoteEntryAcceptsGitHubPublicationConfig(t *testing.T) { t.Fatal(err) } - remote, err := LoadRemoteEntry(path, "default") - if err != nil { - t.Fatalf("load github remote: %v", err) - } - if remote.Backend != RemoteBackendGitHub || remote.Direction != RemoteDirectionPublish || - remote.Repo != "mnemon-dev/mnemon-teamwork-example" || remote.Branch != "mnemon/agent-a" { - t.Fatalf("github remote not normalized: %+v", remote) - } -} - -func TestLoadRemoteEntryRejectsInvalidGitHubPublicationConfig(t *testing.T) { - cases := []struct { - name string - body string - want string - }{{ - name: "missing repo", - body: `{ - "schema_version": 1, - "remotes": [{ - "id": "self", - "backend": "github", - "direction": "publish", - "branch": "mnemon/agent-a", - "credential_ref": ".mnemon/harness/sync/credentials/self.token" - }] - }`, - want: "github repo is required", - }, { - name: "bad branch", - body: `{ - "schema_version": 1, - "remotes": [{ - "id": "self", - "backend": "github", - "direction": "publish", - "repo": "mnemon-dev/mnemon-teamwork-example", - "branch": "main", - "credential_ref": ".mnemon/harness/sync/credentials/self.token" - }] - }`, - want: "outside the mnemon namespace", - }} - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "remotes.json") - if err := os.WriteFile(path, []byte(tc.body+"\n"), 0o600); err != nil { - t.Fatal(err) - } - _, err := LoadRemoteEntry(path, "self") - if err == nil || !strings.Contains(err.Error(), tc.want) { - t.Fatalf("invalid github remote must fail with %q, got %v", tc.want, err) - } - }) + _, err := LoadRemoteEntry(path, "default") + if err == nil || !strings.Contains(err.Error(), "unsupported Remote Workspace backend") { + t.Fatalf("github backend must fail closed, got %v", err) } } diff --git a/harness/internal/productconfig/config.go b/harness/internal/productconfig/config.go index 16b97814..f6202d73 100644 --- a/harness/internal/productconfig/config.go +++ b/harness/internal/productconfig/config.go @@ -24,7 +24,6 @@ const ( RuntimeModeManagedOrHost = "managed-or-attached" ConnectionMultica = "multica" - ConnectionGitHub = "github" ConnectionMnemonhub = "mnemonhub" DefaultMulticaRuntimeBinary = multicasurface.MulticaRuntimeCommandName @@ -63,7 +62,6 @@ type HostRuntime struct { type Connections struct { Multica MulticaConnection `json:"multica,omitempty"` - GitHub GitHubConnection `json:"github,omitempty"` Mnemonhub MnemonhubConnection `json:"mnemonhub,omitempty"` } @@ -73,12 +71,6 @@ type MulticaConnection struct { RuntimeBinary string `json:"runtime_binary,omitempty"` } -type GitHubConnection struct { - Enabled bool `json:"enabled,omitempty"` - Repo string `json:"repo,omitempty"` - Branch string `json:"branch,omitempty"` -} - type MnemonhubConnection struct { Enabled bool `json:"enabled,omitempty"` Endpoint string `json:"endpoint,omitempty"` @@ -177,11 +169,6 @@ func (cfg Config) Validate() error { return fmt.Errorf("multica connection workspace is required when enabled") } } - if cfg.Connections.GitHub.Enabled { - if strings.TrimSpace(cfg.Connections.GitHub.Repo) == "" { - return fmt.Errorf("github connection repo is required when enabled") - } - } if cfg.Connections.Mnemonhub.Enabled { if strings.TrimSpace(cfg.Connections.Mnemonhub.Endpoint) == "" { return fmt.Errorf("mnemonhub connection endpoint is required when enabled") @@ -285,10 +272,6 @@ func validateCarrier(label, carrier string, cfg Config) error { if !cfg.Connections.Multica.Enabled { return fmt.Errorf("%s %q is not enabled", label, carrier) } - case ConnectionGitHub: - if !cfg.Connections.GitHub.Enabled { - return fmt.Errorf("%s %q is not enabled", label, carrier) - } case ConnectionMnemonhub: if !cfg.Connections.Mnemonhub.Enabled { return fmt.Errorf("%s %q is not enabled", label, carrier) @@ -315,8 +298,6 @@ type legacyRemoteEntry struct { Backend string `json:"backend,omitempty"` ID string `json:"id"` Endpoint string `json:"endpoint,omitempty"` - Repo string `json:"repo,omitempty"` - Branch string `json:"branch,omitempty"` } func FromLegacy(root string) (Config, bool, error) { @@ -355,12 +336,6 @@ func FromLegacy(root string) (Config, bool, error) { if strings.TrimSpace(cfg.Connections.Mnemonhub.Endpoint) == "" { cfg.Connections.Mnemonhub.Endpoint = strings.TrimSpace(remote.Endpoint) } - case ConnectionGitHub: - cfg.Connections.GitHub.Enabled = true - if strings.TrimSpace(cfg.Connections.GitHub.Repo) == "" { - cfg.Connections.GitHub.Repo = strings.TrimSpace(remote.Repo) - cfg.Connections.GitHub.Branch = strings.TrimSpace(remote.Branch) - } } } } @@ -377,10 +352,6 @@ func FromLegacy(root string) (Config, bool, error) { if cfg.Connections.Mnemonhub.Enabled { cfg.Daemon.InteractionWatchers = appendCarrier(cfg.Daemon.InteractionWatchers, ConnectionMnemonhub) } - if cfg.Connections.GitHub.Enabled { - cfg.Daemon.InteractionWatchers = appendCarrier(cfg.Daemon.InteractionWatchers, ConnectionGitHub) - cfg.Daemon.DisplaySurfaces = appendCarrier(cfg.Daemon.DisplaySurfaces, ConnectionGitHub) - } if len(cfg.Participants) > 0 { cfg.Daemon.DriveSources = appendCarrier(cfg.Daemon.DriveSources, DriveManagedLocal) } diff --git a/harness/internal/productconfig/config_test.go b/harness/internal/productconfig/config_test.go index e8c7baa3..7032e08d 100644 --- a/harness/internal/productconfig/config_test.go +++ b/harness/internal/productconfig/config_test.go @@ -184,9 +184,6 @@ func TestFromLegacyBridgesLocalAndRemoteConfigs(t *testing.T) { if len(cfg.Participants) != 1 || cfg.Participants[0].Principal != "planner@team" { t.Fatalf("participant bridge mismatch: %+v", cfg.Participants) } - if !cfg.Connections.GitHub.Enabled || cfg.Connections.GitHub.Repo != "mnemon-dev/mnemon-teamwork-example" { - t.Fatalf("github bridge mismatch: %+v", cfg.Connections.GitHub) - } if !cfg.Connections.Mnemonhub.Enabled || cfg.Connections.Mnemonhub.Endpoint != "https://hub.example" { t.Fatalf("mnemonhub bridge mismatch: %+v", cfg.Connections.Mnemonhub) } @@ -196,6 +193,9 @@ func TestFromLegacyBridgesLocalAndRemoteConfigs(t *testing.T) { if stringSliceContains(cfg.Daemon.DisplaySurfaces, ConnectionMnemonhub) { t.Fatalf("mnemonhub must not be bridged as a display surface: %+v", cfg.Daemon.DisplaySurfaces) } + if stringSliceContains(cfg.Daemon.InteractionWatchers, "github") || stringSliceContains(cfg.Daemon.DisplaySurfaces, "github") { + t.Fatalf("legacy github remotes must not bridge into product config: %+v", cfg.Daemon) + } if len(cfg.Daemon.DriveSources) != 1 || cfg.Daemon.DriveSources[0] != DriveManagedLocal { t.Fatalf("drive sources mismatch: %+v", cfg.Daemon.DriveSources) } From a3a4f9c02cb76bead061c1157f08a4fab98c2d83 Mon Sep 17 00:00:00 2001 From: Grivn Date: Thu, 2 Jul 2026 02:18:02 +0800 Subject: [PATCH 4/9] feat: add Cloudflare MnemonHub local backend Add a harness-scoped Cloudflare Worker and Durable Object implementation for the MnemonHub sync ABI, with dependency-free Node contract tests over the shared sync fixture corpus. The Worker keeps the same push, pull, status, auth, scope clamp, replay, and conflict semantics as the Go hub. Validation: npm test in harness/cloudflare/mnemonhub; harness/scripts/r3-1/run.sh --phase cloudflare-local; go test ./harness/internal/mnemonhub/... ./harness/internal/coreguard ./harness/internal/productconfig. --- harness/cloudflare/mnemonhub/package.json | 11 + harness/cloudflare/mnemonhub/src/auth.mjs | 50 ++++ harness/cloudflare/mnemonhub/src/contract.mjs | 251 ++++++++++++++++++ harness/cloudflare/mnemonhub/src/http.mjs | 97 +++++++ harness/cloudflare/mnemonhub/src/hub.mjs | 189 +++++++++++++ harness/cloudflare/mnemonhub/src/index.mjs | 35 +++ harness/cloudflare/mnemonhub/src/json.mjs | 25 ++ harness/cloudflare/mnemonhub/src/storage.mjs | 44 +++ .../mnemonhub/test/fixtures.test.mjs | 193 ++++++++++++++ harness/cloudflare/mnemonhub/wrangler.toml | 16 ++ harness/scripts/r3-1/cloudflare-local/run.sh | 15 +- .../scripts/r3-1/cloudflare-local/verify.py | 58 ++++ 12 files changed, 983 insertions(+), 1 deletion(-) create mode 100644 harness/cloudflare/mnemonhub/package.json create mode 100644 harness/cloudflare/mnemonhub/src/auth.mjs create mode 100644 harness/cloudflare/mnemonhub/src/contract.mjs create mode 100644 harness/cloudflare/mnemonhub/src/http.mjs create mode 100644 harness/cloudflare/mnemonhub/src/hub.mjs create mode 100644 harness/cloudflare/mnemonhub/src/index.mjs create mode 100644 harness/cloudflare/mnemonhub/src/json.mjs create mode 100644 harness/cloudflare/mnemonhub/src/storage.mjs create mode 100644 harness/cloudflare/mnemonhub/test/fixtures.test.mjs create mode 100644 harness/cloudflare/mnemonhub/wrangler.toml create mode 100644 harness/scripts/r3-1/cloudflare-local/verify.py diff --git a/harness/cloudflare/mnemonhub/package.json b/harness/cloudflare/mnemonhub/package.json new file mode 100644 index 00000000..f5a2d140 --- /dev/null +++ b/harness/cloudflare/mnemonhub/package.json @@ -0,0 +1,11 @@ +{ + "name": "@mnemon/harness-cloudflare-mnemonhub", + "private": true, + "type": "module", + "scripts": { + "test": "node --test test/*.test.mjs" + }, + "devDependencies": { + "wrangler": "^4.0.0" + } +} diff --git a/harness/cloudflare/mnemonhub/src/auth.mjs b/harness/cloudflare/mnemonhub/src/auth.mjs new file mode 100644 index 00000000..8793f934 --- /dev/null +++ b/harness/cloudflare/mnemonhub/src/auth.mjs @@ -0,0 +1,50 @@ +export class StaticAuthenticator { + constructor(tokens = {}) { + this.tokens = tokens; + } + + authenticate(request) { + const header = request.headers.get("authorization") ?? ""; + const token = header.trim().replace(/^Bearer\s+/i, "").trim(); + if (!token || !Object.prototype.hasOwnProperty.call(this.tokens, token)) { + throw new Error("unrecognized bearer token"); + } + const principal = String(this.tokens[token] ?? "").trim(); + if (!principal) { + throw new Error("unrecognized bearer token"); + } + return principal; + } +} + +export function authFromEnv(env = {}) { + return new StaticAuthenticator(parseJSONRecord(env.MNEMON_HUB_TOKENS_JSON)); +} + +export function grantsFromEnv(env = {}) { + const raw = parseJSONRecord(env.MNEMON_HUB_GRANTS_JSON); + const out = {}; + for (const [principal, value] of Object.entries(raw)) { + if (Array.isArray(value)) { + out[principal] = { principal, scopes: value }; + } else if (value && typeof value === "object" && Array.isArray(value.scopes)) { + out[principal] = { principal: value.principal || principal, scopes: value.scopes }; + } + } + return out; +} + +function parseJSONRecord(value) { + if (!value) { + return {}; + } + if (typeof value === "object") { + return value; + } + try { + const parsed = JSON.parse(String(value)); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} diff --git a/harness/cloudflare/mnemonhub/src/contract.mjs b/harness/cloudflare/mnemonhub/src/contract.mjs new file mode 100644 index 00000000..9e37f3b4 --- /dev/null +++ b/harness/cloudflare/mnemonhub/src/contract.mjs @@ -0,0 +1,251 @@ +import { sha256Hex } from "./json.mjs"; + +export const SyncVerbPush = "sync.push"; +export const SyncVerbPull = "sync.pull"; +export const SyncVerbStatus = "sync.status"; + +const phaseMeta = { + synced: { + origin_mnemond: "string", + cursor: "string", + digest: "string", + synced_at: "string" + } +}; + +const payloadSections = new Set(["rule", "narrative", "refs"]); +const phaseMetaKeys = new Set(["external_id", "host", "lifecycle", "decision_id", "ingest_seq", "accepted_at", "accepted_by", "origin_mnemond", "cursor", "digest", "synced_at", "derived_at", "expires_at", "presentation_hint", "suggested_event_types"]); + +export function subject(kind, id) { + kind = String(kind ?? "").trim(); + id = String(id ?? "").trim(); + if (!kind || !id) { + return ""; + } + return `${kind}/${id}`; +} + +export function resourceRefFromSubject(value) { + const [kind, id, extra] = String(value ?? "").split("/"); + if (!kind || !id || extra !== undefined) { + throw new BadRequestError(`event subject ${JSON.stringify(value)} is not kind/id`); + } + return { kind, id }; +} + +export function localDecisionID(eventID) { + return String(eventID ?? "").trim().split(":", 1)[0]; +} + +export async function fieldsDigest(fields) { + return sha256Hex(fields ?? {}); +} + +export async function syncedEventEnvelopeFromMaterial(material) { + const ref = material.resource_ref; + const eventSubject = subject(ref?.kind, ref?.id); + const decidedAt = String(material.decided_at ?? ""); + const event = { + schema_version: 2, + id: `${String(material.local_decision_id || "decision").trim()}:${eventSubject}`, + type: `${ref.kind}.accepted`, + subject: eventSubject, + actor: String(material.actor ?? ""), + audience: "", + payload: { + rule: { + resource_version: Number(material.resource_version ?? 0), + fields: cloneFields(material.fields) + } + }, + caused_by: [], + correlation_id: String(material.correlation_id ?? ""), + ttl: "", + created_at: decidedAt + }; + const env = { + schema_version: 2, + phase: "synced", + event, + meta: { + origin_mnemond: String(material.origin_replica_id ?? "").trim(), + cursor: String(material.local_ingest_seq ?? ""), + digest: String(material.fields_digest ?? "").trim(), + synced_at: decidedAt + } + }; + validateEnvelope(env); + return env; +} + +export function syncedEventMaterialFromEnvelope(env) { + validateEnvelope(env); + if (env.phase !== "synced") { + throw new Error(`sync event must have phase "synced", got ${JSON.stringify(env.phase)}`); + } + const ref = resourceRefFromSubject(env.event.subject); + const decisionID = localDecisionID(env.event.id); + if (!decisionID) { + throw new Error(`synced event ${JSON.stringify(env.event.id)} does not carry a decision identity`); + } + const rule = env.event.payload?.rule; + const resourceVersion = Number(rule?.resource_version); + if (!Number.isFinite(resourceVersion)) { + throw new Error(`synced event ${JSON.stringify(env.event.id)} has invalid resource_version`); + } + if (!isPlainObject(rule?.fields)) { + throw new Error(`synced event ${JSON.stringify(env.event.id)} payload.rule.fields must be an object`); + } + return { + origin_replica_id: String(env.meta.origin_mnemond ?? "").trim(), + local_decision_id: decisionID, + local_ingest_seq: parseInt(String(env.meta.cursor ?? "0"), 10) || 0, + actor: String(env.event.actor ?? ""), + correlation_id: String(env.event.correlation_id ?? ""), + resource_ref: ref, + resource_version: resourceVersion, + fields_digest: String(env.meta.digest ?? "").trim(), + fields: cloneFields(rule.fields), + decided_at: String(env.meta.synced_at ?? "").trim(), + status: "pending" + }; +} + +export function eventExchangeResultFromEnvelope(env, status, diagnostic = "") { + return { + origin_mnemond: String(env?.meta?.origin_mnemond ?? "").trim(), + event_id: String(env?.event?.id ?? ""), + subject: String(env?.event?.subject ?? ""), + status, + diagnostic + }; +} + +export async function validateSyncedEventMaterial(material) { + if (!String(material.origin_replica_id ?? "").trim()) { + return "origin_replica_id is required"; + } + if (!String(material.local_decision_id ?? "").trim()) { + return "local_decision_id is required"; + } + if (!String(material.actor ?? "").trim()) { + return "actor is required"; + } + if (!String(material.resource_ref?.kind ?? "").trim() || !String(material.resource_ref?.id ?? "").trim()) { + return "resource_ref is required"; + } + if (!isPlainObject(material.fields)) { + return "fields are required"; + } + if (!String(material.fields_digest ?? "").trim()) { + return "fields_digest is required"; + } + const digest = await fieldsDigest(material.fields); + if (material.fields_digest !== digest) { + return "fields_digest does not match fields"; + } + return ""; +} + +export function clampRefs(principal, grantScopes, requestedScopes = []) { + const grant = Array.isArray(grantScopes) ? grantScopes : []; + const requested = Array.isArray(requestedScopes) && requestedScopes.length > 0 ? requestedScopes : grant; + const allowed = new Set(grant.map(refKey)); + for (const ref of requested) { + if (!allowed.has(refKey(ref))) { + throw new Error(`principal ${JSON.stringify(principal)} is not granted access to ${refKey(ref)}`); + } + } + return requested.map((ref) => ({ kind: String(ref.kind), id: String(ref.id) })); +} + +export function refKey(ref) { + return `${String(ref?.kind ?? "")}/${String(ref?.id ?? "")}`; +} + +export class BadRequestError extends Error { + constructor(message) { + super(message); + this.name = "BadRequestError"; + } +} + +export function validateEnvelope(env) { + if (!isPlainObject(env)) { + throw new Error("event envelope must be an object"); + } + if (!(Number(env.schema_version) > 0)) { + throw new Error("event envelope schema_version must be positive"); + } + if (env.phase !== "synced") { + throw new Error(`unknown event envelope phase ${JSON.stringify(env.phase)}`); + } + if (!isPlainObject(env.event)) { + throw new Error("event is required"); + } + validateEvent(env.event); + validateMeta(env.phase, env.meta); +} + +function validateEvent(event) { + if (event.schema_version !== 2) { + throw new Error("event schema_version must be 2"); + } + for (const key of ["id", "type", "subject", "actor"]) { + if (!String(event[key] ?? "").trim()) { + throw new Error(`event ${key} is required`); + } + } + validatePayload(event.payload); +} + +function validatePayload(payload) { + if (!isPlainObject(payload)) { + throw new Error("event payload must be an object"); + } + for (const [key, value] of Object.entries(payload)) { + if (phaseMetaKeys.has(key)) { + throw new Error(`event payload must not carry phase meta key ${JSON.stringify(key)}`); + } + if (!payloadSections.has(key)) { + throw new Error(`event payload must not carry business key ${JSON.stringify(key)} outside rule/narrative/refs`); + } + if (!isPlainObject(value)) { + throw new Error(`event payload section ${JSON.stringify(key)} must be an object`); + } + for (const sectionKey of Object.keys(value)) { + if (phaseMetaKeys.has(sectionKey)) { + throw new Error(`event payload section ${JSON.stringify(key)} must not carry phase meta key ${JSON.stringify(sectionKey)}`); + } + } + } +} + +function validateMeta(phase, meta) { + if (!isPlainObject(meta)) { + throw new Error(`${phase} event envelope meta is required`); + } + const schema = phaseMeta[phase]; + for (const key of Object.keys(meta)) { + if (!(key in schema)) { + throw new Error(`${phase} event envelope meta has unknown key ${JSON.stringify(key)}`); + } + } + for (const key of Object.keys(schema)) { + const value = meta[key]; + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${phase} event envelope meta key ${JSON.stringify(key)} must be a non-empty string`); + } + } +} + +export function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function cloneFields(fields) { + if (!isPlainObject(fields)) { + return {}; + } + return JSON.parse(JSON.stringify(fields)); +} diff --git a/harness/cloudflare/mnemonhub/src/http.mjs b/harness/cloudflare/mnemonhub/src/http.mjs new file mode 100644 index 00000000..41aa61b7 --- /dev/null +++ b/harness/cloudflare/mnemonhub/src/http.mjs @@ -0,0 +1,97 @@ +import { BadRequestError, SyncVerbPull, SyncVerbPush, SyncVerbStatus } from "./contract.mjs"; + +const maxBodyBytes = 8 << 20; + +export async function handleSyncRequest(request, hub, authenticator, audit = null) { + const url = new URL(request.url); + if (url.pathname === "/sync/push") { + const method = allowMethod(request, ["POST"], SyncVerbPush, audit); + if (method) return method; + const auth = authenticate(request, authenticator, SyncVerbPush, audit); + if (auth.response) return auth.response; + const decoded = await decodeJSON(request, SyncVerbPush, auth.principal, audit); + if (decoded.response) return decoded.response; + return respond(await hubCall(() => hub.push(auth.principal, decoded.value), auth.principal, SyncVerbPush, audit)); + } + if (url.pathname === "/sync/pull") { + const method = allowMethod(request, ["POST"], SyncVerbPull, audit); + if (method) return method; + const auth = authenticate(request, authenticator, SyncVerbPull, audit); + if (auth.response) return auth.response; + const decoded = await decodeJSON(request, SyncVerbPull, auth.principal, audit); + if (decoded.response) return decoded.response; + return respond(await hubCall(() => hub.pull(auth.principal, decoded.value), auth.principal, SyncVerbPull, audit)); + } + if (url.pathname === "/sync/status") { + const method = allowMethod(request, ["GET", "POST"], SyncVerbStatus, audit); + if (method) return method; + const auth = authenticate(request, authenticator, SyncVerbStatus, audit); + if (auth.response) return auth.response; + return respond(await hubCall(() => hub.status(auth.principal), auth.principal, SyncVerbStatus, audit)); + } + return new Response("not found\n", { status: 404 }); +} + +function allowMethod(request, allowed, verb, audit) { + if (allowed.includes(request.method)) { + return null; + } + auditLine(audit, "-", verb, "bad_request"); + return new Response("method not allowed\n", { + status: 405, + headers: { allow: allowed.join(", ") } + }); +} + +function authenticate(request, authenticator, verb, audit) { + try { + return { principal: authenticator.authenticate(request) }; + } catch (error) { + auditLine(audit, "-", verb, "unauthorized"); + return { response: new Response(`${error.message}\n`, { status: 401 }) }; + } +} + +async function decodeJSON(request, verb, principal, audit) { + try { + const text = await request.text(); + if (text.length > maxBodyBytes) { + throw new BadRequestError("request body too large"); + } + return { value: text.trim() ? JSON.parse(text) : {} }; + } catch (error) { + auditLine(audit, principal, verb, "bad_request"); + return { response: new Response(`${error.message}\n`, { status: 400 }) }; + } +} + +async function hubCall(call, principal, verb, audit) { + try { + const value = await call(); + auditLine(audit, principal, verb, "ok"); + return { value }; + } catch (error) { + if (error instanceof BadRequestError) { + auditLine(audit, principal, verb, "bad_request"); + return { response: new Response(`${error.message}\n`, { status: 400 }) }; + } + auditLine(audit, principal, verb, "denied"); + return { response: new Response(`${error.message}\n`, { status: 403 }) }; + } +} + +function respond(result) { + if (result.response) { + return result.response; + } + return new Response(JSON.stringify(result.value) + "\n", { + headers: { "content-type": "application/json" } + }); +} + +function auditLine(audit, principal, verb, result) { + if (!audit || typeof audit.push !== "function") { + return; + } + audit.push(`${new Date().toISOString()} principal=${principal} verb=${verb} result=${result}`); +} diff --git a/harness/cloudflare/mnemonhub/src/hub.mjs b/harness/cloudflare/mnemonhub/src/hub.mjs new file mode 100644 index 00000000..4b0cb9bf --- /dev/null +++ b/harness/cloudflare/mnemonhub/src/hub.mjs @@ -0,0 +1,189 @@ +import { + BadRequestError, + SyncVerbPull, + SyncVerbPush, + SyncVerbStatus, + clampRefs, + eventExchangeResultFromEnvelope, + refKey, + syncedEventEnvelopeFromMaterial, + syncedEventMaterialFromEnvelope, + validateSyncedEventMaterial +} from "./contract.mjs"; + +export class MnemonHubCore { + constructor({ grants = {}, store, now = () => new Date().toISOString() } = {}) { + this.grants = grants; + this.store = store; + this.now = now; + } + + async push(principal, request) { + const grant = this.grant(principal, SyncVerbPush); + if (!grant) { + throw new Error(`principal ${JSON.stringify(principal)} has no replica grant for ${SyncVerbPush}`); + } + const replicaID = String(request?.replica_id ?? "").trim(); + if (!replicaID) { + throw new BadRequestError("sync push requires replica_id"); + } + const response = { accepted: [], rejected: [], conflicts: [] }; + for (const envelope of Array.isArray(request?.events) ? request.events : []) { + let material; + try { + material = syncedEventMaterialFromEnvelope(envelope); + } catch (error) { + response.rejected.push(eventExchangeResultFromEnvelope(envelope, "rejected", error.message)); + continue; + } + if (material.origin_replica_id !== replicaID) { + throw new BadRequestError(`sync push replica_id ${JSON.stringify(replicaID)} does not match event origin ${JSON.stringify(material.origin_replica_id)}`); + } + const diagnostic = await validateSyncedEventMaterial(material); + if (diagnostic) { + response.rejected.push(eventExchangeResultFromEnvelope(envelope, "rejected", diagnostic)); + continue; + } + try { + clampRefs(principal, grant.scopes, [material.resource_ref]); + } catch (error) { + response.rejected.push(eventExchangeResultFromEnvelope(envelope, "rejected", error.message)); + continue; + } + const record = await this.recordRemoteSyncedEvent(String(principal), material); + switch (record.status) { + case "accepted": { + const acceptedEnvelope = await syncedEventEnvelopeFromMaterial(record.material); + acceptedEnvelope.meta.cursor = String(record.remote_seq); + response.accepted.push(eventExchangeResultFromEnvelope(acceptedEnvelope, "accepted", "")); + response.next_cursor = String(record.remote_seq); + break; + } + case "conflict": + response.conflicts.push(eventExchangeResultFromEnvelope(envelope, "conflict", record.diagnostic)); + break; + default: + response.rejected.push(eventExchangeResultFromEnvelope(envelope, record.status, record.diagnostic ?? "")); + } + } + return response; + } + + async pull(principal, request) { + const grant = this.grant(principal, SyncVerbPull); + if (!grant) { + throw new Error(`principal ${JSON.stringify(principal)} has no replica grant for ${SyncVerbPull}`); + } + const replicaID = String(request?.replica_id ?? "").trim(); + if (!replicaID) { + throw new BadRequestError("sync pull requires replica_id"); + } + let cursor = 0; + if (String(request?.remote_cursor ?? "").trim()) { + cursor = Number.parseInt(String(request.remote_cursor), 10); + if (!Number.isFinite(cursor)) { + throw new BadRequestError(`parse remote_cursor: invalid syntax`); + } + } + const scopes = clampRefs(principal, grant.scopes, request?.scopes ?? []); + const scopeSet = new Set(scopes.map(refKey)); + const state = await this.store.read(); + const records = state.events + .filter((record) => record.remote_seq > cursor) + .filter((record) => record.status === "accepted") + .filter((record) => record.material.origin_replica_id !== replicaID) + .filter((record) => scopeSet.has(refKey(record.material.resource_ref))) + .sort((a, b) => a.remote_seq - b.remote_seq) + .slice(0, 100); + let next = cursor; + const events = []; + for (const record of records) { + if (record.remote_seq > next) { + next = record.remote_seq; + } + const env = await syncedEventEnvelopeFromMaterial(record.material); + env.meta.cursor = String(record.remote_seq); + events.push(env); + } + if (records.length > 0) { + state.served_total += records.length; + } + state.serve_cursors[String(principal)] = next; + await this.store.write(state); + return { next_cursor: String(next), events }; + } + + async status(principal) { + if (!this.grant(principal, SyncVerbStatus)) { + throw new Error(`principal ${JSON.stringify(principal)} has no replica grant for ${SyncVerbStatus}`); + } + const state = await this.store.read(); + const cursors = {}; + for (const [principalID, cursor] of Object.entries(state.serve_cursors)) { + cursors[principalID] = String(cursor); + } + const response = { + principal, + remote_workspace: "connected", + hub_events_received: state.events.filter((record) => record.status === "accepted").length, + hub_events_served: state.served_total + }; + if (Object.keys(cursors).length > 0) { + response.hub_replica_cursors = cursors; + } + return response; + } + + grant(principal, verb) { + if (![SyncVerbPush, SyncVerbPull, SyncVerbStatus].includes(verb)) { + return null; + } + const grant = this.grants[String(principal)]; + if (!grant) { + return null; + } + return { principal: grant.principal || String(principal), scopes: Array.isArray(grant.scopes) ? grant.scopes : [] }; + } + + async recordRemoteSyncedEvent(remotePeerID, material) { + const state = await this.store.read(); + const existing = state.events.find((record) => + record.remote_peer_id === remotePeerID && + record.material.origin_replica_id === material.origin_replica_id && + record.material.local_decision_id === material.local_decision_id + ); + if (existing) { + if (sameSyncedEventMaterial(existing.material, material)) { + return existing; + } + return { + remote_peer_id: remotePeerID, + material, + status: "conflict", + diagnostic: "sync idempotency key reused with different event" + }; + } + const accepted = JSON.parse(JSON.stringify(material)); + accepted.status = "accepted"; + const record = { + remote_seq: state.next_seq++, + remote_peer_id: remotePeerID, + material: accepted, + received_at: this.now(), + status: "accepted", + diagnostic: "" + }; + state.events.push(record); + await this.store.write(state); + return record; + } +} + +function sameSyncedEventMaterial(a, b) { + return Number(a.local_ingest_seq) === Number(b.local_ingest_seq) && + String(a.actor ?? "") === String(b.actor ?? "") && + String(a.correlation_id ?? "") === String(b.correlation_id ?? "") && + refKey(a.resource_ref) === refKey(b.resource_ref) && + Number(a.resource_version) === Number(b.resource_version) && + String(a.fields_digest ?? "") === String(b.fields_digest ?? ""); +} diff --git a/harness/cloudflare/mnemonhub/src/index.mjs b/harness/cloudflare/mnemonhub/src/index.mjs new file mode 100644 index 00000000..c8191755 --- /dev/null +++ b/harness/cloudflare/mnemonhub/src/index.mjs @@ -0,0 +1,35 @@ +import { StaticAuthenticator, authFromEnv, grantsFromEnv } from "./auth.mjs"; +import { MnemonHubCore } from "./hub.mjs"; +import { DurableObjectHubStore } from "./storage.mjs"; +import { handleSyncRequest } from "./http.mjs"; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + const workspace = url.searchParams.get("workspace") || env.MNEMON_HUB_WORKSPACE || "default"; + const id = env.MNEMON_HUB.idFromName(workspace); + return env.MNEMON_HUB.get(id).fetch(request); + } +}; + +export class MnemonHubDO { + constructor(ctx, env) { + this.ctx = ctx; + this.env = env; + this.audit = []; + } + + async fetch(request) { + const hub = new MnemonHubCore({ + grants: grantsFromEnv(this.env), + store: new DurableObjectHubStore(this.ctx.storage) + }); + return handleSyncRequest(request, hub, authFromEnv(this.env), this.audit); + } +} + +export function createMemoryHandler({ grants, tokens, store, audit }) { + const hub = new MnemonHubCore({ grants, store }); + const auth = new StaticAuthenticator(tokens); + return (request) => handleSyncRequest(request, hub, auth, audit); +} diff --git a/harness/cloudflare/mnemonhub/src/json.mjs b/harness/cloudflare/mnemonhub/src/json.mjs new file mode 100644 index 00000000..0a101ef3 --- /dev/null +++ b/harness/cloudflare/mnemonhub/src/json.mjs @@ -0,0 +1,25 @@ +const textEncoder = new TextEncoder(); + +export function stableStringify(value) { + return JSON.stringify(sortJSON(value)); +} + +function sortJSON(value) { + if (Array.isArray(value)) { + return value.map(sortJSON); + } + if (value && typeof value === "object") { + const out = {}; + for (const key of Object.keys(value).sort()) { + out[key] = sortJSON(value[key]); + } + return out; + } + return value; +} + +export async function sha256Hex(value) { + const data = typeof value === "string" ? value : stableStringify(value); + const digest = await globalThis.crypto.subtle.digest("SHA-256", textEncoder.encode(data)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/harness/cloudflare/mnemonhub/src/storage.mjs b/harness/cloudflare/mnemonhub/src/storage.mjs new file mode 100644 index 00000000..ed8b774c --- /dev/null +++ b/harness/cloudflare/mnemonhub/src/storage.mjs @@ -0,0 +1,44 @@ +const stateKey = "mnemonhub:v1"; + +export class MemoryHubStore { + constructor(initialState) { + this.state = normalizeState(initialState); + } + + async read() { + return normalizeState(this.state); + } + + async write(state) { + this.state = normalizeState(state); + } +} + +export class DurableObjectHubStore { + constructor(storage) { + this.storage = storage; + } + + async read() { + const state = await this.storage.get(stateKey); + return normalizeState(state); + } + + async write(state) { + await this.storage.put(stateKey, normalizeState(state)); + } +} + +export function normalizeState(state = {}) { + return { + next_seq: Number.isInteger(state.next_seq) && state.next_seq > 0 ? state.next_seq : 1, + events: Array.isArray(state.events) ? JSON.parse(JSON.stringify(state.events)) : [], + serve_cursors: isRecord(state.serve_cursors) ? { ...state.serve_cursors } : {}, + served_total: Number.isInteger(state.served_total) && state.served_total >= 0 ? state.served_total : 0, + audit_records: Array.isArray(state.audit_records) ? JSON.parse(JSON.stringify(state.audit_records)) : [] + }; +} + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/harness/cloudflare/mnemonhub/test/fixtures.test.mjs b/harness/cloudflare/mnemonhub/test/fixtures.test.mjs new file mode 100644 index 00000000..1d71d95f --- /dev/null +++ b/harness/cloudflare/mnemonhub/test/fixtures.test.mjs @@ -0,0 +1,193 @@ +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; + +import { fieldsDigest, syncedEventEnvelopeFromMaterial } from "../src/contract.mjs"; +import { MnemonHubCore } from "../src/hub.mjs"; +import { createMemoryHandler } from "../src/index.mjs"; +import { MemoryHubStore } from "../src/storage.mjs"; + +const fixtureRoot = resolve("../../testdata/mnemonhub/sync-abi"); + +for (const fixture of await loadFixtures()) { + test(`sync ABI: ${fixture.name}`, async () => { + switch (fixture.kind) { + case "push": + await runPushFixture(fixture); + break; + case "push_replay": + await runReplayFixture(fixture); + break; + case "push_conflict": + await runConflictFixture(fixture); + break; + case "pull": + await runPullFixture(fixture); + break; + case "status": + await runStatusFixture(fixture); + break; + case "http_auth": + await runHTTPAuthFixture(fixture); + break; + default: + throw new Error(`unknown fixture kind ${fixture.kind}`); + } + }); +} + +async function loadFixtures() { + const names = (await readdir(fixtureRoot)).filter((name) => name.endsWith(".json")).sort(); + const fixtures = []; + for (const name of names) { + fixtures.push(JSON.parse(await readFile(join(fixtureRoot, name), "utf8"))); + } + assert.ok(fixtures.length > 0, "expected sync ABI fixtures"); + return fixtures; +} + +function openFixtureHub(fixture) { + const grants = {}; + if (fixture.principal) { + grants[fixture.principal] = { principal: fixture.principal, scopes: fixture.grant_scopes ?? [] }; + } + if (fixture.seed_principal) { + grants[fixture.seed_principal] = { principal: fixture.seed_principal, scopes: fixture.grant_scopes ?? [] }; + } + const store = new MemoryHubStore(); + return { hub: new MnemonHubCore({ grants, store, now: () => "2026-06-12T00:00:00Z" }), store }; +} + +async function runPushFixture(fixture) { + const { hub } = openFixtureHub(fixture); + const response = await hub.push(fixture.principal, { + replica_id: fixture.replica_id, + batch_id: fixture.name, + events: await fixtureEvents(fixture.events ?? []) + }); + assertCounts(fixture, response); +} + +async function runReplayFixture(fixture) { + const { hub, store } = openFixtureHub(fixture); + const request = { + replica_id: fixture.replica_id, + batch_id: fixture.name, + events: await fixtureEvents(fixture.events ?? []) + }; + const first = await hub.push(fixture.principal, request); + const second = await hub.push(fixture.principal, request); + assertCounts(fixture, second); + assert.deepEqual(second.accepted, first.accepted); + assert.equal((await store.read()).events.length, fixture.want.stored_events); +} + +async function runConflictFixture(fixture) { + const { hub } = openFixtureHub(fixture); + await hub.push(fixture.principal, { + replica_id: fixture.replica_id, + batch_id: `${fixture.name}-first`, + events: await fixtureEvents((fixture.events ?? []).slice(0, 1)) + }); + const response = await hub.push(fixture.principal, { + replica_id: fixture.replica_id, + batch_id: `${fixture.name}-second`, + events: await fixtureEvents((fixture.events ?? []).slice(1)) + }); + assertCounts(fixture, response); +} + +async function runPullFixture(fixture) { + const { hub } = openFixtureHub(fixture); + await seedFixture(hub, fixture); + const response = await hub.pull(fixture.principal, { replica_id: fixture.replica_id }); + assert.equal(response.events.length, fixture.want.events); + if (fixture.want.after_cursor_events >= 0 && response.next_cursor) { + const after = await hub.pull(fixture.principal, { + replica_id: fixture.replica_id, + remote_cursor: response.next_cursor + }); + assert.equal(after.events.length, fixture.want.after_cursor_events); + } +} + +async function runStatusFixture(fixture) { + const { hub } = openFixtureHub(fixture); + await seedFixture(hub, fixture); + const response = await hub.status(fixture.principal); + assert.equal(response.hub_events_received, fixture.want.hub_events_received); +} + +async function runHTTPAuthFixture(fixture) { + const memory = { kind: "memory", id: "project" }; + const handler = createMemoryHandler({ + grants: { "replica-a@team": { principal: "replica-a@team", scopes: [memory] } }, + tokens: { "tok-a": "replica-a@team" }, + store: new MemoryHubStore(), + audit: [] + }); + const body = JSON.stringify({ + replica_id: "local-a", + batch_id: fixture.name, + events: await fixtureEvents([{ + origin_replica_id: "local-a", + local_decision_id: "dec-auth", + resource_ref: memory, + fields: { content: "auth" } + }]) + }); + const request = new Request(`https://mnemon.test${fixture.route}`, { + method: fixture.method, + headers: fixture.token ? { authorization: `Bearer ${fixture.token}` } : {}, + body + }); + const response = await handler(request); + assert.equal(response.status, fixture.want.status_code); +} + +async function seedFixture(hub, fixture) { + if (!fixture.seed_events?.length) { + return; + } + await hub.push(fixture.seed_principal, { + replica_id: fixture.seed_replica_id, + batch_id: `${fixture.name}-seed`, + events: await fixtureEvents(fixture.seed_events) + }); +} + +async function fixtureEvents(fixtures) { + const events = []; + for (const fixture of fixtures) { + const material = await fixtureMaterial(fixture); + if (fixture.corrupt_digest) { + material.fields_digest = "corrupt"; + } + events.push(await syncedEventEnvelopeFromMaterial(material)); + } + return events; +} + +async function fixtureMaterial(fixture) { + const fields = fixture.fields ?? {}; + return { + origin_replica_id: fixture.origin_replica_id, + local_decision_id: fixture.local_decision_id, + local_ingest_seq: 1, + actor: "codex@project", + correlation_id: "", + resource_ref: fixture.resource_ref, + resource_version: 1, + fields_digest: await fieldsDigest(fields), + fields, + decided_at: "2026-06-12T00:00:00Z", + status: "pending" + }; +} + +function assertCounts(fixture, response) { + assert.equal(response.accepted?.length ?? 0, fixture.want.accepted); + assert.equal(response.rejected?.length ?? 0, fixture.want.rejected); + assert.equal(response.conflicts?.length ?? 0, fixture.want.conflicts); +} diff --git a/harness/cloudflare/mnemonhub/wrangler.toml b/harness/cloudflare/mnemonhub/wrangler.toml new file mode 100644 index 00000000..0d90d0c1 --- /dev/null +++ b/harness/cloudflare/mnemonhub/wrangler.toml @@ -0,0 +1,16 @@ +name = "mnemon-r3-1-hub" +main = "src/index.mjs" +compatibility_date = "2026-07-01" + +[[durable_objects.bindings]] +name = "MNEMON_HUB" +class_name = "MnemonHubDO" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["MnemonHubDO"] + +[vars] +MNEMON_HUB_WORKSPACE = "default" +MNEMON_HUB_TOKENS_JSON = "{}" +MNEMON_HUB_GRANTS_JSON = "{}" diff --git a/harness/scripts/r3-1/cloudflare-local/run.sh b/harness/scripts/r3-1/cloudflare-local/run.sh index 89222652..8b698272 100755 --- a/harness/scripts/r3-1/cloudflare-local/run.sh +++ b/harness/scripts/r3-1/cloudflare-local/run.sh @@ -4,4 +4,17 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/common.sh" PHASE="cloudflare-local" PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" -python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Cloudflare DO implementation is not present yet" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +LOG="$PHASE_DIR/npm-test.log" +cd "$MNEMON_R3_ROOT/harness/cloudflare/mnemonhub" +set +e +npm test >"$LOG" 2>&1 +STATUS=$? +set -e + +python3 "$SCRIPT_DIR/verify.py" \ + --summary "$PHASE_DIR/summary.json" \ + --log "$LOG" \ + --exit-code "$STATUS" +exit "$STATUS" diff --git a/harness/scripts/r3-1/cloudflare-local/verify.py b/harness/scripts/r3-1/cloudflare-local/verify.py new file mode 100644 index 00000000..77aad0a6 --- /dev/null +++ b/harness/scripts/r3-1/cloudflare-local/verify.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--log", required=True) + parser.add_argument("--exit-code", required=True, type=int) + args = parser.parse_args() + + log_text = Path(args.log).read_text(encoding="utf-8", errors="replace") + failures = [] + if args.exit_code != 0: + failures.append({ + "category": "protocol_failure", + "name": "cloudflare mnemonhub local contract", + "detail": f"npm test exited {args.exit_code}; see {args.log}", + }) + + assertions = [ + {"name": "npm test passes Cloudflare MnemonHub sync ABI fixtures", "passed": args.exit_code == 0}, + {"name": "Cloudflare local log does not mention API token", "passed": "CLOUDFLARE_API_TOKEN=" not in log_text}, + {"name": "Cloudflare local log does not mention bearer secret literals", "passed": "cfat_" not in log_text}, + ] + status = "ok" if not failures and all(item["passed"] for item in assertions) else "failed" + summary = { + "schema_version": 1, + "phase": "cloudflare-local", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": [{ + "name": "cd harness/cloudflare/mnemonhub && npm test", + "status": "ok" if args.exit_code == 0 else "failed", + "exit_code": args.exit_code, + "log": args.log, + }], + "assertions": assertions, + "skipped": [], + "failures": failures, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + raise SystemExit(0 if status == "ok" else 1) + + +if __name__ == "__main__": + main() From f02236db135ed627f95c3edb3130ec93c48bb15d Mon Sep 17 00:00:00 2001 From: Grivn Date: Thu, 2 Jul 2026 02:26:27 +0800 Subject: [PATCH 5/9] feat: bootstrap Cloudflare MnemonHub from harness Add mnemon-harness hub bootstrap cloudflare and hub doctor. The bootstrap path reads a private Cloudflare env file, deploys the Cloudflare Worker/Durable Object through wrangler or npx, writes Worker secrets for replica auth, runs sync smoke, and stores only the local replica sync token under .mnemon. Validation: go test ./harness/cmd/mnemon-harness ./harness/internal/productconfig ./harness/internal/mnemonhub/... ./harness/internal/coreguard; harness/scripts/r3-1/run.sh --phase mnemonhub-contract; harness/scripts/r3-1/run.sh --phase cloudflare-local; harness/scripts/r3-1/run.sh --phase cloudflare-live skipped because the Cloudflare account has no workers.dev subdomain yet (API code 10063). --- harness/cmd/mnemon-harness/hub.go | 619 ++++++++++++++++++ harness/cmd/mnemon-harness/hub_test.go | 121 ++++ harness/cmd/mnemon-harness/root_test.go | 4 +- harness/scripts/r3-1/cloudflare-live/run.sh | 43 +- .../scripts/r3-1/cloudflare-live/verify.py | 71 ++ 5 files changed, 855 insertions(+), 3 deletions(-) create mode 100644 harness/cmd/mnemon-harness/hub.go create mode 100644 harness/cmd/mnemon-harness/hub_test.go create mode 100644 harness/scripts/r3-1/cloudflare-live/verify.py diff --git a/harness/cmd/mnemon-harness/hub.go b/harness/cmd/mnemon-harness/hub.go new file mode 100644 index 00000000..41135b92 --- /dev/null +++ b/harness/cmd/mnemon-harness/hub.go @@ -0,0 +1,619 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" + "github.com/mnemon-dev/mnemon/harness/internal/productconfig" + "github.com/spf13/cobra" +) + +const defaultCloudflareEnvPath = "~/.mnemon/cloudflare-bootstrap.env" + +var ( + hubRoot string + hubConfigPath string + hubCloudflareEnvFile string + hubCloudflareWorkerName string + hubCloudflareAccountID string + hubCloudflarePrincipal string + hubCloudflareReplicaID string + hubCloudflareScope []string + hubCloudflareRemoteID string + hubCloudflareTimeout time.Duration + hubCloudflareNoDeploy bool +) + +var hubCmd = &cobra.Command{ + Use: "hub", + Short: "Manage harness MnemonHub connections", +} + +var hubBootstrapCmd = &cobra.Command{ + Use: "bootstrap", + Short: "Bootstrap a managed MnemonHub backend", +} + +var hubBootstrapCloudflareCmd = &cobra.Command{ + Use: "cloudflare", + Short: "Deploy and connect a Cloudflare Durable Object MnemonHub", + RunE: runHubBootstrapCloudflare, +} + +var hubDoctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Report harness MnemonHub connection readiness", + RunE: runHubDoctor, +} + +type commandRunner func(context.Context, commandInvocation) (commandResult, error) + +type commandInvocation struct { + Dir string + Env []string + Name string + Args []string + Stdin string + Redact []string +} + +type commandResult struct { + Stdout string + Stderr string +} + +type cloudflareBootstrapPlan struct { + Root string + ConfigPath string + EnvFile string + APIToken string + AccountID string + WorkerName string + Principal string + ReplicaID string + ReplicaToken string + RemoteID string + Scopes []contract.ResourceRef + ProjectDir string + Endpoint string + Timeout time.Duration + NoDeploy bool + CommandRunner commandRunner +} + +type cloudflareBootstrapResult struct { + Endpoint string + ConfigPath string + TokenRef string + Principal string + ReplicaID string + SmokePushOK bool + SmokePullOK bool + SmokeStatusOK bool + CloudflareTokenKeptLocal bool +} + +func init() { + hubCmd.PersistentFlags().StringVar(&hubRoot, "root", ".", "project root") + hubCmd.PersistentFlags().StringVar(&hubConfigPath, "config", "", "harness product config path") + _ = hubCmd.PersistentFlags().MarkHidden("config") + hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflareEnvFile, "env-file", "", "Cloudflare bootstrap env file") + hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflareWorkerName, "worker-name", "", "Cloudflare Worker name") + hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflareAccountID, "account-id", "", "Cloudflare account id") + hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflarePrincipal, "principal", "mnemon-replica@team", "local replica principal for MnemonHub sync") + hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflareReplicaID, "replica-id", "", "local replica id; empty generates one") + hubBootstrapCloudflareCmd.Flags().StringArrayVar(&hubCloudflareScope, "scope", []string{"memory/project"}, "granted resource scope kind/id; repeatable") + hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflareRemoteID, "remote", "cloudflare", "local Remote Workspace id") + hubBootstrapCloudflareCmd.Flags().DurationVar(&hubCloudflareTimeout, "timeout", 2*time.Minute, "bootstrap command timeout") + hubBootstrapCloudflareCmd.Flags().BoolVar(&hubCloudflareNoDeploy, "no-deploy", false, "prepare local config without running wrangler deploy or smoke") + hubBootstrapCmd.AddCommand(hubBootstrapCloudflareCmd) + hubCmd.AddCommand(hubBootstrapCmd, hubDoctorCmd) + hubCmd.GroupID = groupSpine + rootCmd.AddCommand(hubCmd) +} + +func runHubBootstrapCloudflare(cmd *cobra.Command, args []string) error { + plan, err := buildCloudflareBootstrapPlan(hubRoot, hubConfigPath) + if err != nil { + return err + } + result, err := bootstrapCloudflareHub(cmd.Context(), plan) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "MnemonHub connected") + fmt.Fprintf(cmd.OutOrStdout(), "Endpoint: %s\n", result.Endpoint) + fmt.Fprintf(cmd.OutOrStdout(), "Principal: %s\n", result.Principal) + fmt.Fprintf(cmd.OutOrStdout(), "Replica ID: %s\n", result.ReplicaID) + fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", result.ConfigPath) + fmt.Fprintf(cmd.OutOrStdout(), "Token file: %s\n", result.TokenRef) + if !plan.NoDeploy { + fmt.Fprintf(cmd.OutOrStdout(), "Smoke: push=%s pull=%s status=%s\n", okWord(result.SmokePushOK), okWord(result.SmokePullOK), okWord(result.SmokeStatusOK)) + } + fmt.Fprintln(cmd.OutOrStdout(), "Security: Cloudflare bootstrap token was not persisted by Mnemon.") + return nil +} + +func runHubDoctor(cmd *cobra.Command, args []string) error { + root := strings.TrimSpace(hubRoot) + if root == "" { + root = "." + } + cfg, status, detail := doctorProductConfig(root) + fmt.Fprintln(cmd.OutOrStdout(), "MnemonHub doctor") + fmt.Fprintf(cmd.OutOrStdout(), "- Product config: %s", status) + if detail != "" { + fmt.Fprintf(cmd.OutOrStdout(), " (%s)", detail) + } + fmt.Fprintln(cmd.OutOrStdout()) + if cfg.Connections.Mnemonhub.Enabled { + fmt.Fprintf(cmd.OutOrStdout(), "- Endpoint: %s\n", cfg.Connections.Mnemonhub.Endpoint) + } else { + fmt.Fprintln(cmd.OutOrStdout(), "- Endpoint: missing") + } + remotesPath := filepath.Join(root, ".mnemon", "harness", "sync", "remotes.json") + if remote, err := exchange.LoadRemoteEntry(remotesPath, "default"); err == nil { + fmt.Fprintf(cmd.OutOrStdout(), "- Remote Workspace: %s backend=%s credential_ref=%s\n", remote.ID, remote.NormalizedBackend(), remote.CredentialRef) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "- Remote Workspace: missing (%v)\n", err) + } + fmt.Fprintln(cmd.OutOrStdout(), "- Free-plan estimate: 5 agents at 30s sync interval ~= 14,400 pulls/day before push/status overhead") + return nil +} + +func buildCloudflareBootstrapPlan(root, configPath string) (cloudflareBootstrapPlan, error) { + root = filepath.Clean(root) + env, envFile, err := loadCloudflareBootstrapEnv(hubCloudflareEnvFile) + if err != nil { + return cloudflareBootstrapPlan{}, err + } + apiToken := strings.TrimSpace(env["CLOUDFLARE_API_TOKEN"]) + if apiToken == "" && !hubCloudflareNoDeploy { + return cloudflareBootstrapPlan{}, fmt.Errorf("CLOUDFLARE_API_TOKEN is required") + } + accountID := firstNonEmpty(hubCloudflareAccountID, env["CLOUDFLARE_ACCOUNT_ID"]) + if strings.TrimSpace(accountID) == "" && !hubCloudflareNoDeploy { + return cloudflareBootstrapPlan{}, fmt.Errorf("CLOUDFLARE_ACCOUNT_ID is required") + } + workerName := firstNonEmpty(hubCloudflareWorkerName, env["MNEMON_CLOUDFLARE_WORKER_NAME"], "mnemon-r3-1-hub") + if err := validateCloudflareWorkerName(workerName); err != nil { + return cloudflareBootstrapPlan{}, err + } + replicaID := strings.TrimSpace(hubCloudflareReplicaID) + if replicaID == "" { + replicaID = "local-" + randomSuffix(6) + } + scopes, err := parseScopeRefs(hubCloudflareScope) + if err != nil { + return cloudflareBootstrapPlan{}, err + } + replicaToken, err := randomHex(32) + if err != nil { + return cloudflareBootstrapPlan{}, err + } + return cloudflareBootstrapPlan{ + Root: root, + ConfigPath: configPath, + EnvFile: envFile, + APIToken: apiToken, + AccountID: strings.TrimSpace(accountID), + WorkerName: workerName, + Principal: strings.TrimSpace(hubCloudflarePrincipal), + ReplicaID: replicaID, + ReplicaToken: replicaToken, + RemoteID: firstNonEmpty(hubCloudflareRemoteID, "cloudflare"), + Scopes: scopes, + ProjectDir: filepath.Join(repoRootForHarness(), "harness", "cloudflare", "mnemonhub"), + Timeout: hubCloudflareTimeout, + NoDeploy: hubCloudflareNoDeploy, + CommandRunner: defaultCommandRunner, + }, nil +} + +func bootstrapCloudflareHub(ctx context.Context, plan cloudflareBootstrapPlan) (cloudflareBootstrapResult, error) { + if strings.TrimSpace(plan.Principal) == "" { + return cloudflareBootstrapResult{}, fmt.Errorf("--principal is required") + } + if strings.TrimSpace(plan.ReplicaID) == "" { + return cloudflareBootstrapResult{}, fmt.Errorf("replica id is required") + } + if plan.CommandRunner == nil { + plan.CommandRunner = defaultCommandRunner + } + endpoint := plan.Endpoint + if !plan.NoDeploy { + deployCtx := ctx + var cancel context.CancelFunc + if plan.Timeout > 0 { + deployCtx, cancel = context.WithTimeout(ctx, plan.Timeout) + defer cancel() + } + out, err := deployCloudflareWorker(deployCtx, plan) + if err != nil { + return cloudflareBootstrapResult{}, err + } + endpoint = cloudflareEndpointFromWranglerOutput(out.Stdout + "\n" + out.Stderr) + if endpoint == "" { + return cloudflareBootstrapResult{}, fmt.Errorf("wrangler deploy did not report a workers.dev endpoint") + } + if err := putCloudflareSecret(deployCtx, plan, "MNEMON_HUB_TOKENS_JSON", cloudflareTokensJSON(plan)); err != nil { + return cloudflareBootstrapResult{}, err + } + if err := putCloudflareSecret(deployCtx, plan, "MNEMON_HUB_GRANTS_JSON", cloudflareGrantsJSON(plan)); err != nil { + return cloudflareBootstrapResult{}, err + } + if err := smokeCloudflareHub(deployCtx, endpoint, plan); err != nil { + return cloudflareBootstrapResult{}, err + } + } else if endpoint == "" { + endpoint = fmt.Sprintf("https://%s.example.invalid", plan.WorkerName) + } + tokenRef, err := writeCloudflareLocalSyncConfig(plan, endpoint) + if err != nil { + return cloudflareBootstrapResult{}, err + } + cfgPath, err := writeCloudflareProductConfig(plan, endpoint) + if err != nil { + return cloudflareBootstrapResult{}, err + } + return cloudflareBootstrapResult{ + Endpoint: endpoint, + ConfigPath: cfgPath, + TokenRef: tokenRef, + Principal: plan.Principal, + ReplicaID: plan.ReplicaID, + SmokePushOK: !plan.NoDeploy, + SmokePullOK: !plan.NoDeploy, + SmokeStatusOK: !plan.NoDeploy, + CloudflareTokenKeptLocal: true, + }, nil +} + +func putCloudflareSecret(ctx context.Context, plan cloudflareBootstrapPlan, name, value string) error { + cmd := cloudflareWranglerCommand(ctx, plan, "secret", "put", name, "--name", plan.WorkerName) + cmd.Stdin = value + "\n" + _, err := plan.CommandRunner(ctx, cmd) + return err +} + +func deployCloudflareWorker(ctx context.Context, plan cloudflareBootstrapPlan) (commandResult, error) { + return plan.CommandRunner(ctx, cloudflareWranglerCommand(ctx, plan, "deploy", "--name", plan.WorkerName)) +} + +func smokeCloudflareHub(ctx context.Context, endpoint string, plan cloudflareBootstrapPlan) error { + client := access.NewClientWithToken(endpoint, plan.ReplicaToken) + ref := contract.ResourceRef{Kind: "memory", ID: "project"} + fields := map[string]any{"content": "cloudflare smoke"} + fieldsJSON, _ := json.Marshal(fields) + sum := sha256.Sum256(fieldsJSON) + env, err := contract.SyncedEventEnvelopeFromMaterial(contract.SyncedEventMaterial{ + OriginReplicaID: plan.ReplicaID, + LocalDecisionID: "cloudflare-smoke", + LocalIngestSeq: 1, + Actor: contract.ActorID(plan.Principal), + ResourceRef: ref, + ResourceVersion: 1, + FieldsDigest: hex.EncodeToString(sum[:]), + Fields: fields, + DecidedAt: time.Now().UTC().Format(time.RFC3339), + Status: "pending", + }) + if err != nil { + return err + } + var lastErr error + for i := 0; i < 6; i++ { + if ctx.Err() != nil { + return ctx.Err() + } + push, err := client.SyncPush(contract.SyncPushRequest{ReplicaID: plan.ReplicaID, BatchID: "cloudflare-smoke", Events: []eventmodel.EventEnvelope{env}}) + if err == nil && len(push.Accepted) == 1 { + if _, err = client.SyncPull(contract.SyncPullRequest{ReplicaID: plan.ReplicaID}); err != nil { + return err + } + if _, err = client.SyncStatus(); err != nil { + return err + } + return nil + } + if err != nil { + lastErr = err + } else { + lastErr = fmt.Errorf("smoke push accepted %d events", len(push.Accepted)) + } + time.Sleep(time.Duration(i+1) * time.Second) + } + return fmt.Errorf("cloudflare smoke failed: %w", lastErr) +} + +func writeCloudflareLocalSyncConfig(plan cloudflareBootstrapPlan, endpoint string) (string, error) { + remoteID := strings.TrimSpace(plan.RemoteID) + if remoteID == "" { + remoteID = "cloudflare" + } + if err := upsertSyncRemote(filepath.Join(plan.Root, ".mnemon", "harness", "sync", "remotes.json"), plan.Root, remoteID, exchange.RemoteBackendHTTP, "", endpoint, plan.ReplicaToken, "", ""); err != nil { + return "", err + } + return filepath.ToSlash(filepath.Join(".mnemon", "harness", "sync", "credentials", remoteID+".token")), nil +} + +func writeCloudflareProductConfig(plan cloudflareBootstrapPlan, endpoint string) (string, error) { + cfg, _, err := loadHarnessProductConfig(plan.Root, plan.ConfigPath) + if err != nil { + return "", err + } + cfg.Connections.Mnemonhub = productconfig.MnemonhubConnection{Enabled: true, Endpoint: endpoint} + cfg.Daemon.InteractionWatchers = appendUniqueString(cfg.Daemon.InteractionWatchers, productconfig.ConnectionMnemonhub) + return saveHarnessProductConfig(plan.Root, plan.ConfigPath, cfg) +} + +func cloudflareWranglerCommand(ctx context.Context, plan cloudflareBootstrapPlan, args ...string) commandInvocation { + name, baseArgs := resolveWranglerCommand() + env := append(os.Environ(), + "CLOUDFLARE_API_TOKEN="+plan.APIToken, + "CLOUDFLARE_ACCOUNT_ID="+plan.AccountID, + ) + allArgs := append(baseArgs, args...) + return commandInvocation{ + Dir: plan.ProjectDir, + Env: env, + Name: name, + Args: allArgs, + Redact: []string{plan.APIToken, plan.ReplicaToken}, + } +} + +func resolveWranglerCommand() (string, []string) { + if path, err := exec.LookPath("wrangler"); err == nil { + return path, nil + } + if runtime.GOOS == "windows" { + return "npx.cmd", []string{"--yes", "wrangler"} + } + return "npx", []string{"--yes", "wrangler"} +} + +func defaultCommandRunner(ctx context.Context, inv commandInvocation) (commandResult, error) { + cmd := exec.CommandContext(ctx, inv.Name, inv.Args...) + cmd.Dir = inv.Dir + cmd.Env = inv.Env + if inv.Stdin != "" { + cmd.Stdin = strings.NewReader(inv.Stdin) + } + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + result := commandResult{Stdout: redact(stdout.String(), inv.Redact), Stderr: redact(stderr.String(), inv.Redact)} + if err != nil { + return result, fmt.Errorf("%s %s failed: %w\n%s", filepath.Base(inv.Name), strings.Join(inv.Args, " "), err, result.Stderr) + } + return result, nil +} + +func cloudflareTokensJSON(plan cloudflareBootstrapPlan) string { + raw, _ := json.Marshal(map[string]string{plan.ReplicaToken: plan.Principal}) + return string(raw) +} + +func cloudflareGrantsJSON(plan cloudflareBootstrapPlan) string { + raw, _ := json.Marshal(map[string]map[string]any{ + plan.Principal: { + "principal": plan.Principal, + "scopes": plan.Scopes, + }, + }) + return string(raw) +} + +func cloudflareEndpointFromWranglerOutput(text string) string { + re := regexp.MustCompile(`https://[A-Za-z0-9][A-Za-z0-9.-]*\.workers\.dev`) + matches := re.FindAllString(text, -1) + if len(matches) == 0 { + return "" + } + return matches[len(matches)-1] +} + +func loadCloudflareBootstrapEnv(explicit string) (map[string]string, string, error) { + out := map[string]string{} + for _, key := range []string{"CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "MNEMON_CLOUDFLARE_WORKER_NAME"} { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + out[key] = value + } + } + for _, path := range []string{explicit, expandHome(defaultCloudflareEnvPath)} { + path = strings.TrimSpace(path) + if path == "" { + continue + } + values, ok, err := readCloudflareEnvFile(path) + if err != nil { + return nil, "", err + } + if !ok { + continue + } + for key, value := range values { + if strings.TrimSpace(out[key]) == "" { + out[key] = value + } + } + return out, path, nil + } + return out, "", nil +} + +func readCloudflareEnvFile(path string) (map[string]string, bool, error) { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, err + } + if info.Mode().Perm()&0o077 != 0 { + return nil, false, fmt.Errorf("Cloudflare env file %s must be permission 0600 or stricter", path) + } + f, err := os.Open(path) + if err != nil { + return nil, false, err + } + defer f.Close() + allowed := map[string]bool{"CLOUDFLARE_API_TOKEN": true, "CLOUDFLARE_ACCOUNT_ID": true, "MNEMON_CLOUDFLARE_WORKER_NAME": true} + values := map[string]string{} + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + return nil, false, fmt.Errorf("invalid env line in %s", path) + } + key = strings.TrimSpace(key) + if !allowed[key] { + return nil, false, fmt.Errorf("unsupported Cloudflare env key %q in %s", key, path) + } + values[key] = strings.Trim(strings.TrimSpace(value), `"'`) + } + return values, true, scanner.Err() +} + +func parseScopeRefs(values []string) ([]contract.ResourceRef, error) { + if len(values) == 0 { + return nil, fmt.Errorf("at least one --scope kind/id is required") + } + seen := map[string]bool{} + var out []contract.ResourceRef + for _, value := range values { + kind, id, ok := strings.Cut(strings.TrimSpace(value), "/") + if !ok || strings.TrimSpace(kind) == "" || strings.TrimSpace(id) == "" { + return nil, fmt.Errorf("scope %q must be kind/id", value) + } + ref := contract.ResourceRef{Kind: contract.ResourceKind(strings.TrimSpace(kind)), ID: contract.ResourceID(strings.TrimSpace(id))} + key := string(ref.Kind) + "/" + string(ref.ID) + if !seen[key] { + out = append(out, ref) + seen[key] = true + } + } + sort.Slice(out, func(i, j int) bool { + return string(out[i].Kind)+"/"+string(out[i].ID) < string(out[j].Kind)+"/"+string(out[j].ID) + }) + return out, nil +} + +func validateCloudflareWorkerName(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("Cloudflare worker name is required") + } + if len(name) > 63 { + return fmt.Errorf("Cloudflare worker name must be at most 63 characters") + } + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + continue + } + return fmt.Errorf("Cloudflare worker name %q must use lowercase letters, numbers, or dash", name) + } + return nil +} + +func randomHex(bytesLen int) (string, error) { + buf := make([]byte, bytesLen) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func randomSuffix(bytesLen int) string { + value, err := randomHex(bytesLen) + if err != nil { + return "unknown" + } + return value +} + +func redact(text string, secrets []string) string { + for _, secret := range secrets { + if strings.TrimSpace(secret) != "" { + text = strings.ReplaceAll(text, secret, "") + } + } + return text +} + +func expandHome(path string) string { + if path == "~" { + if home, err := os.UserHomeDir(); err == nil { + return home + } + } + if strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, path[2:]) + } + } + return path +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func repoRootForHarness() string { + _, file, _, ok := runtime.Caller(0) + if !ok { + return "." + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..")) +} + +func okWord(ok bool) string { + if ok { + return "ok" + } + return "skipped" +} + +func validateEndpoint(endpoint string) error { + u, err := url.Parse(endpoint) + if err != nil { + return err + } + if u.Scheme != "https" && u.Scheme != "http" { + return fmt.Errorf("endpoint must be http or https") + } + if u.Host == "" { + return fmt.Errorf("endpoint host is required") + } + return nil +} diff --git a/harness/cmd/mnemon-harness/hub_test.go b/harness/cmd/mnemon-harness/hub_test.go new file mode 100644 index 00000000..818f3e47 --- /dev/null +++ b/harness/cmd/mnemon-harness/hub_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" +) + +func TestCloudflareEnvFileMergesWithoutOverridingProcessEnv(t *testing.T) { + t.Setenv("CLOUDFLARE_API_TOKEN", "process-token") + path := filepath.Join(t.TempDir(), "cloudflare.env") + if err := os.WriteFile(path, []byte("CLOUDFLARE_API_TOKEN=file-token\nCLOUDFLARE_ACCOUNT_ID=account-1\nMNEMON_CLOUDFLARE_WORKER_NAME=mnemon-r3\n"), 0o600); err != nil { + t.Fatal(err) + } + env, used, err := loadCloudflareBootstrapEnv(path) + if err != nil { + t.Fatal(err) + } + if used != path { + t.Fatalf("env file = %q, want %q", used, path) + } + if env["CLOUDFLARE_API_TOKEN"] != "process-token" { + t.Fatalf("process env must win, got %q", env["CLOUDFLARE_API_TOKEN"]) + } + if env["CLOUDFLARE_ACCOUNT_ID"] != "account-1" || env["MNEMON_CLOUDFLARE_WORKER_NAME"] != "mnemon-r3" { + t.Fatalf("file values not loaded: %+v", env) + } +} + +func TestCloudflareEnvFileRejectsLoosePermissions(t *testing.T) { + path := filepath.Join(t.TempDir(), "cloudflare.env") + if err := os.WriteFile(path, []byte("CLOUDFLARE_API_TOKEN=file-token\n"), 0o644); err != nil { + t.Fatal(err) + } + _, _, err := readCloudflareEnvFile(path) + if err == nil || !strings.Contains(err.Error(), "0600") { + t.Fatalf("expected permission error, got %v", err) + } +} + +func TestCloudflareEndpointFromWranglerOutput(t *testing.T) { + out := "Uploaded mnemon\nPublished mnemon-r3 (1.2 sec)\n https://mnemon-r3.example.workers.dev\n" + if got := cloudflareEndpointFromWranglerOutput(out); got != "https://mnemon-r3.example.workers.dev" { + t.Fatalf("endpoint = %q", got) + } +} + +func TestCloudflareGrantAndTokenJSONDoNotExposeCloudflareToken(t *testing.T) { + plan := cloudflareBootstrapPlan{ + APIToken: "cfat_secret", + ReplicaToken: "replica-secret", + Principal: "planner@team", + Scopes: mustParseScopesForTest(t, []string{"memory/project"}), + } + tokens := cloudflareTokensJSON(plan) + if strings.Contains(tokens, plan.APIToken) { + t.Fatalf("tokens JSON leaked Cloudflare token: %s", tokens) + } + grants := cloudflareGrantsJSON(plan) + if strings.Contains(grants, plan.APIToken) || strings.Contains(grants, plan.ReplicaToken) { + t.Fatalf("grants JSON leaked a secret: %s", grants) + } + var decoded map[string]map[string]any + if err := json.Unmarshal([]byte(grants), &decoded); err != nil { + t.Fatal(err) + } + if decoded["planner@team"]["principal"] != "planner@team" { + t.Fatalf("grant principal mismatch: %+v", decoded) + } +} + +func TestBootstrapCloudflareNoDeployWritesLocalConfigWithoutRunningCommands(t *testing.T) { + root := t.TempDir() + plan := cloudflareBootstrapPlan{ + Root: root, + WorkerName: "mnemon-r3", + Principal: "planner@team", + ReplicaID: "local-a", + ReplicaToken: "replica-secret", + RemoteID: "cloudflare", + Scopes: mustParseScopesForTest(t, []string{"memory/project"}), + Endpoint: "https://mnemon-r3.example.workers.dev", + NoDeploy: true, + CommandRunner: func(context.Context, commandInvocation) (commandResult, error) { + t.Fatal("no-deploy must not run external commands") + return commandResult{}, nil + }, + } + result, err := bootstrapCloudflareHub(context.Background(), plan) + if err != nil { + t.Fatal(err) + } + if result.Endpoint != plan.Endpoint || result.ConfigPath == "" || result.TokenRef == "" { + t.Fatalf("result mismatch: %+v", result) + } + tokenPath := filepath.Join(root, filepath.FromSlash(result.TokenRef)) + if raw, err := os.ReadFile(tokenPath); err != nil || strings.TrimSpace(string(raw)) != "replica-secret" { + t.Fatalf("token file mismatch raw=%q err=%v", string(raw), err) + } + cfg, err := os.ReadFile(filepath.Join(root, ".mnemon", "harness", "config.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(cfg), "replica-secret") || strings.Contains(string(cfg), "cfat_") { + t.Fatalf("product config must not contain secrets:\n%s", string(cfg)) + } +} + +func mustParseScopesForTest(t *testing.T, values []string) []contract.ResourceRef { + t.Helper() + scopes, err := parseScopeRefs(values) + if err != nil { + t.Fatal(err) + } + return scopes +} diff --git a/harness/cmd/mnemon-harness/root_test.go b/harness/cmd/mnemon-harness/root_test.go index cab7aa44..d80ad0fe 100644 --- a/harness/cmd/mnemon-harness/root_test.go +++ b/harness/cmd/mnemon-harness/root_test.go @@ -23,7 +23,7 @@ func TestRootHelpUsesLocalFirstProductSurface(t *testing.T) { t.Fatalf("root help returned error: %v", err) } got := out.String() - for _, want := range []string{"event-driven", "collaboration substrate", "Teamwork is a profile", "Agent Integration", "Local Mnemon", "setup", "config", "daemon", "doctor", "status", "session", "agent", "connect"} { + for _, want := range []string{"event-driven", "collaboration substrate", "Teamwork is a profile", "Agent Integration", "Local Mnemon", "setup", "config", "daemon", "doctor", "status", "session", "agent", "connect", "hub"} { if !strings.Contains(got, want) { t.Fatalf("expected root help to contain %q:\n%s", want, got) } @@ -42,7 +42,7 @@ func TestRootHelpUsesLocalFirstProductSurface(t *testing.T) { func TestPublicRootCommandsMatchProductSurface(t *testing.T) { got := publicRootCommandNames() - want := []string{"agent", "config", "connect", "daemon", "doctor", "session", "setup", "status"} + want := []string{"agent", "config", "connect", "daemon", "doctor", "hub", "session", "setup", "status"} sort.Strings(want) if strings.Join(got, "\x00") != strings.Join(want, "\x00") { t.Fatalf("public root commands mismatch:\ngot: %v\nwant: %v", got, want) diff --git a/harness/scripts/r3-1/cloudflare-live/run.sh b/harness/scripts/r3-1/cloudflare-live/run.sh index 6be6c1d5..dfa12063 100755 --- a/harness/scripts/r3-1/cloudflare-live/run.sh +++ b/harness/scripts/r3-1/cloudflare-live/run.sh @@ -4,4 +4,45 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/common.sh" PHASE="cloudflare-live" PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" -python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Cloudflare bootstrap CLI is not present yet" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +ENV_FILE="${MNEMON_CLOUDFLARE_ENV_FILE:-$HOME/.mnemon/cloudflare-bootstrap.env}" +LOG="$PHASE_DIR/bootstrap.log" +WORKSPACE="$PHASE_DIR/workspace" +mkdir -p "$WORKSPACE" + +if [[ ! -f "$ENV_FILE" ]]; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Cloudflare env file missing: $ENV_FILE" + exit 0 +fi + +if [[ "$(stat -f '%Lp' "$ENV_FILE" 2>/dev/null || stat -c '%a' "$ENV_FILE" 2>/dev/null)" != "600" ]]; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status failed --failure "Cloudflare env file permissions must be 0600: $ENV_FILE" + exit 1 +fi + +if ! command -v wrangler >/dev/null 2>&1 && ! command -v npx >/dev/null 2>&1; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Neither wrangler nor npx is available for Cloudflare live deploy" + exit 0 +fi + +cd "$MNEMON_R3_ROOT" +set +e +go run ./harness/cmd/mnemon-harness hub bootstrap cloudflare \ + --root "$WORKSPACE" \ + --env-file "$ENV_FILE" \ + --principal "cloudflare-live@team" \ + --replica-id "cloudflare-live" \ + --scope "memory/project" \ + --remote "cloudflare-live" \ + --timeout "${MNEMON_CLOUDFLARE_BOOTSTRAP_TIMEOUT:-5m}" \ + >"$LOG" 2>&1 +STATUS=$? +set -e + +python3 "$SCRIPT_DIR/verify.py" \ + --summary "$PHASE_DIR/summary.json" \ + --log "$LOG" \ + --exit-code "$STATUS" \ + --workspace "$WORKSPACE" +exit "$?" diff --git a/harness/scripts/r3-1/cloudflare-live/verify.py b/harness/scripts/r3-1/cloudflare-live/verify.py new file mode 100644 index 00000000..ab9e6d5f --- /dev/null +++ b/harness/scripts/r3-1/cloudflare-live/verify.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--log", required=True) + parser.add_argument("--exit-code", required=True, type=int) + parser.add_argument("--workspace", required=True) + args = parser.parse_args() + + log_text = Path(args.log).read_text(encoding="utf-8", errors="replace") + failures = [] + missing_workers_dev = "workers.dev subdomain" in log_text or "code: 10063" in log_text + if args.exit_code != 0 and not missing_workers_dev: + failures.append({ + "category": "environment_failure", + "name": "cloudflare live bootstrap", + "detail": f"bootstrap exited {args.exit_code}; see {args.log}", + }) + + config_path = Path(args.workspace) / ".mnemon" / "harness" / "config.json" + remotes_path = Path(args.workspace) / ".mnemon" / "harness" / "sync" / "remotes.json" + assertions = [ + {"name": "mnemon-harness hub bootstrap cloudflare exited successfully", "passed": args.exit_code == 0}, + {"name": "bootstrap output includes connected marker", "passed": "MnemonHub connected" in log_text}, + {"name": "bootstrap output does not contain Cloudflare token literal", "passed": "cfat_" not in log_text and "CLOUDFLARE_API_TOKEN=" not in log_text}, + {"name": "local product config was written", "passed": config_path.exists()}, + {"name": "local sync remote was written", "passed": remotes_path.exists()}, + ] + skipped = [] + if missing_workers_dev: + skipped.append({ + "category": "skipped_missing_capability", + "name": "cloudflare workers.dev subdomain", + "detail": "Cloudflare API code 10063: open the Workers dashboard once to create the account workers.dev subdomain, then rerun this phase.", + }) + status = "skipped" if skipped else ("ok" if not failures and all(item["passed"] for item in assertions) else "failed") + summary = { + "schema_version": 1, + "phase": "cloudflare-live", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": [{ + "name": "go run ./harness/cmd/mnemon-harness hub bootstrap cloudflare --root --env-file ", + "status": "ok" if args.exit_code == 0 else "failed", + "exit_code": args.exit_code, + "log": args.log, + }], + "assertions": assertions, + "skipped": skipped, + "failures": failures, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + raise SystemExit(0 if status in {"ok", "skipped"} else 1) + + +if __name__ == "__main__": + main() From ac5131c28243792753f702e3dd571efe1420b7c6 Mon Sep 17 00:00:00 2001 From: Grivn Date: Thu, 2 Jul 2026 02:53:18 +0800 Subject: [PATCH 6/9] feat: complete Cloudflare hub bootstrap Bootstrap now ensures a workers.dev subdomain through the Cloudflare API, keeps Worker grants in the lower-case sync ABI shape, and waits for the deployed endpoint before live smoke. The Worker secrets are no longer duplicated as wrangler vars. Validation: go test ./harness/cmd/mnemon-harness ./harness/internal/contract ./harness/internal/mnemonhub/...; harness/scripts/r3-1/run.sh --phase cloudflare-local; harness/scripts/r3-1/run.sh --phase cloudflare-live. --- harness/cloudflare/mnemonhub/wrangler.toml | 2 - harness/cmd/mnemon-harness/hub.go | 258 +++++++++++++++++- harness/cmd/mnemon-harness/hub_test.go | 89 +++++- harness/internal/contract/contract.go | 4 +- .../scripts/r3-1/cloudflare-live/verify.py | 2 +- 5 files changed, 341 insertions(+), 14 deletions(-) diff --git a/harness/cloudflare/mnemonhub/wrangler.toml b/harness/cloudflare/mnemonhub/wrangler.toml index 0d90d0c1..e2f726fa 100644 --- a/harness/cloudflare/mnemonhub/wrangler.toml +++ b/harness/cloudflare/mnemonhub/wrangler.toml @@ -12,5 +12,3 @@ new_sqlite_classes = ["MnemonHubDO"] [vars] MNEMON_HUB_WORKSPACE = "default" -MNEMON_HUB_TOKENS_JSON = "{}" -MNEMON_HUB_GRANTS_JSON = "{}" diff --git a/harness/cmd/mnemon-harness/hub.go b/harness/cmd/mnemon-harness/hub.go index 41135b92..95efb4d8 100644 --- a/harness/cmd/mnemon-harness/hub.go +++ b/harness/cmd/mnemon-harness/hub.go @@ -9,6 +9,8 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" + "net/http" "net/url" "os" "os/exec" @@ -34,6 +36,7 @@ var ( hubConfigPath string hubCloudflareEnvFile string hubCloudflareWorkerName string + hubCloudflareSubdomain string hubCloudflareAccountID string hubCloudflarePrincipal string hubCloudflareReplicaID string @@ -67,6 +70,10 @@ var hubDoctorCmd = &cobra.Command{ type commandRunner func(context.Context, commandInvocation) (commandResult, error) +type httpDoer interface { + Do(*http.Request) (*http.Response, error) +} + type commandInvocation struct { Dir string Env []string @@ -88,6 +95,7 @@ type cloudflareBootstrapPlan struct { APIToken string AccountID string WorkerName string + Subdomain string Principal string ReplicaID string ReplicaToken string @@ -98,6 +106,8 @@ type cloudflareBootstrapPlan struct { Timeout time.Duration NoDeploy bool CommandRunner commandRunner + HTTPClient httpDoer + APIBaseURL string } type cloudflareBootstrapResult struct { @@ -118,6 +128,7 @@ func init() { _ = hubCmd.PersistentFlags().MarkHidden("config") hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflareEnvFile, "env-file", "", "Cloudflare bootstrap env file") hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflareWorkerName, "worker-name", "", "Cloudflare Worker name") + hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflareSubdomain, "subdomain", "", "Cloudflare account workers.dev subdomain; empty reuses existing or creates a deterministic Mnemon subdomain") hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflareAccountID, "account-id", "", "Cloudflare account id") hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflarePrincipal, "principal", "mnemon-replica@team", "local replica principal for MnemonHub sync") hubBootstrapCloudflareCmd.Flags().StringVar(&hubCloudflareReplicaID, "replica-id", "", "local replica id; empty generates one") @@ -198,6 +209,12 @@ func buildCloudflareBootstrapPlan(root, configPath string) (cloudflareBootstrapP if err := validateCloudflareWorkerName(workerName); err != nil { return cloudflareBootstrapPlan{}, err } + subdomain := firstNonEmpty(hubCloudflareSubdomain, env["MNEMON_CLOUDFLARE_SUBDOMAIN"]) + if subdomain != "" { + if err := validateCloudflareWorkersSubdomain(subdomain); err != nil { + return cloudflareBootstrapPlan{}, err + } + } replicaID := strings.TrimSpace(hubCloudflareReplicaID) if replicaID == "" { replicaID = "local-" + randomSuffix(6) @@ -217,6 +234,7 @@ func buildCloudflareBootstrapPlan(root, configPath string) (cloudflareBootstrapP APIToken: apiToken, AccountID: strings.TrimSpace(accountID), WorkerName: workerName, + Subdomain: subdomain, Principal: strings.TrimSpace(hubCloudflarePrincipal), ReplicaID: replicaID, ReplicaToken: replicaToken, @@ -226,6 +244,8 @@ func buildCloudflareBootstrapPlan(root, configPath string) (cloudflareBootstrapP Timeout: hubCloudflareTimeout, NoDeploy: hubCloudflareNoDeploy, CommandRunner: defaultCommandRunner, + HTTPClient: http.DefaultClient, + APIBaseURL: "https://api.cloudflare.com/client/v4", }, nil } @@ -239,6 +259,12 @@ func bootstrapCloudflareHub(ctx context.Context, plan cloudflareBootstrapPlan) ( if plan.CommandRunner == nil { plan.CommandRunner = defaultCommandRunner } + if plan.HTTPClient == nil { + plan.HTTPClient = http.DefaultClient + } + if strings.TrimSpace(plan.APIBaseURL) == "" { + plan.APIBaseURL = "https://api.cloudflare.com/client/v4" + } endpoint := plan.Endpoint if !plan.NoDeploy { deployCtx := ctx @@ -247,6 +273,9 @@ func bootstrapCloudflareHub(ctx context.Context, plan cloudflareBootstrapPlan) ( deployCtx, cancel = context.WithTimeout(ctx, plan.Timeout) defer cancel() } + if _, err := ensureCloudflareWorkersSubdomain(deployCtx, plan); err != nil { + return cloudflareBootstrapResult{}, err + } out, err := deployCloudflareWorker(deployCtx, plan) if err != nil { return cloudflareBootstrapResult{}, err @@ -299,15 +328,143 @@ func deployCloudflareWorker(ctx context.Context, plan cloudflareBootstrapPlan) ( return plan.CommandRunner(ctx, cloudflareWranglerCommand(ctx, plan, "deploy", "--name", plan.WorkerName)) } +func ensureCloudflareWorkersSubdomain(ctx context.Context, plan cloudflareBootstrapPlan) (string, error) { + existing, err := getCloudflareWorkersSubdomain(ctx, plan) + if err != nil { + return "", err + } + if existing != "" { + return existing, nil + } + subdomain := strings.TrimSpace(plan.Subdomain) + if subdomain == "" { + subdomain = defaultCloudflareWorkersSubdomain(plan) + } + if err := validateCloudflareWorkersSubdomain(subdomain); err != nil { + return "", err + } + created, err := putCloudflareWorkersSubdomain(ctx, plan, subdomain) + if err != nil { + return "", err + } + return created, nil +} + +func getCloudflareWorkersSubdomain(ctx context.Context, plan cloudflareBootstrapPlan) (string, error) { + var resp struct { + Success bool `json:"success"` + Result struct { + Subdomain string `json:"subdomain"` + } `json:"result"` + Errors []cloudflareAPIError `json:"errors"` + } + status, err := doCloudflareAPI(ctx, plan, http.MethodGet, "/accounts/"+url.PathEscape(plan.AccountID)+"/workers/subdomain", nil, &resp) + if err != nil { + return "", err + } + if status == http.StatusNotFound { + return "", nil + } + if !resp.Success { + return "", fmt.Errorf("Cloudflare workers.dev subdomain lookup failed: %s", cloudflareErrorSummary(resp.Errors)) + } + return strings.TrimSpace(resp.Result.Subdomain), nil +} + +func putCloudflareWorkersSubdomain(ctx context.Context, plan cloudflareBootstrapPlan, subdomain string) (string, error) { + body, _ := json.Marshal(map[string]string{"subdomain": subdomain}) + var resp struct { + Success bool `json:"success"` + Result struct { + Subdomain string `json:"subdomain"` + } `json:"result"` + Errors []cloudflareAPIError `json:"errors"` + } + _, err := doCloudflareAPI(ctx, plan, http.MethodPut, "/accounts/"+url.PathEscape(plan.AccountID)+"/workers/subdomain", body, &resp) + if err != nil { + return "", err + } + if !resp.Success { + return "", fmt.Errorf("Cloudflare workers.dev subdomain create failed for %q: %s; choose another --subdomain or MNEMON_CLOUDFLARE_SUBDOMAIN", subdomain, cloudflareErrorSummary(resp.Errors)) + } + if strings.TrimSpace(resp.Result.Subdomain) == "" { + return "", fmt.Errorf("Cloudflare workers.dev subdomain create returned empty subdomain") + } + return strings.TrimSpace(resp.Result.Subdomain), nil +} + +type cloudflareAPIError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func doCloudflareAPI(ctx context.Context, plan cloudflareBootstrapPlan, method, path string, body []byte, out any) (int, error) { + base := strings.TrimRight(plan.APIBaseURL, "/") + req, err := http.NewRequestWithContext(ctx, method, base+path, bytes.NewReader(body)) + if err != nil { + return 0, err + } + req.Header.Set("Authorization", "Bearer "+plan.APIToken) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := plan.HTTPClient.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return resp.StatusCode, err + } + if len(data) > 0 && out != nil { + if err := json.Unmarshal(data, out); err != nil { + return resp.StatusCode, fmt.Errorf("decode Cloudflare API response: %w", err) + } + } + if resp.StatusCode >= 500 { + return resp.StatusCode, fmt.Errorf("Cloudflare API %s %s returned HTTP %d", method, path, resp.StatusCode) + } + return resp.StatusCode, nil +} + +func cloudflareErrorSummary(errors []cloudflareAPIError) string { + if len(errors) == 0 { + return "unknown error" + } + parts := make([]string, 0, len(errors)) + for _, item := range errors { + if item.Code != 0 { + parts = append(parts, fmt.Sprintf("%d %s", item.Code, strings.TrimSpace(item.Message))) + continue + } + parts = append(parts, strings.TrimSpace(item.Message)) + } + return strings.Join(parts, "; ") +} + +func defaultCloudflareWorkersSubdomain(plan cloudflareBootstrapPlan) string { + account := strings.ToLower(strings.TrimSpace(plan.AccountID)) + if len(account) > 8 { + account = account[:8] + } + return "mnemon-" + sanitizeCloudflareLabel(account) +} + func smokeCloudflareHub(ctx context.Context, endpoint string, plan cloudflareBootstrapPlan) error { + if err := waitCloudflareEndpointReady(ctx, endpoint); err != nil { + return err + } client := access.NewClientWithToken(endpoint, plan.ReplicaToken) + smokeID := "cloudflare-smoke-" + randomSuffix(6) ref := contract.ResourceRef{Kind: "memory", ID: "project"} - fields := map[string]any{"content": "cloudflare smoke"} + fields := map[string]any{"content": smokeID} fieldsJSON, _ := json.Marshal(fields) sum := sha256.Sum256(fieldsJSON) env, err := contract.SyncedEventEnvelopeFromMaterial(contract.SyncedEventMaterial{ OriginReplicaID: plan.ReplicaID, - LocalDecisionID: "cloudflare-smoke", + LocalDecisionID: smokeID, LocalIngestSeq: 1, Actor: contract.ActorID(plan.Principal), ResourceRef: ref, @@ -321,11 +478,11 @@ func smokeCloudflareHub(ctx context.Context, endpoint string, plan cloudflareBoo return err } var lastErr error - for i := 0; i < 6; i++ { + for i := 0; i < 10; i++ { if ctx.Err() != nil { return ctx.Err() } - push, err := client.SyncPush(contract.SyncPushRequest{ReplicaID: plan.ReplicaID, BatchID: "cloudflare-smoke", Events: []eventmodel.EventEnvelope{env}}) + push, err := client.SyncPush(contract.SyncPushRequest{ReplicaID: plan.ReplicaID, BatchID: smokeID, Events: []eventmodel.EventEnvelope{env}}) if err == nil && len(push.Accepted) == 1 { if _, err = client.SyncPull(contract.SyncPullRequest{ReplicaID: plan.ReplicaID}); err != nil { return err @@ -338,13 +495,51 @@ func smokeCloudflareHub(ctx context.Context, endpoint string, plan cloudflareBoo if err != nil { lastErr = err } else { - lastErr = fmt.Errorf("smoke push accepted %d events", len(push.Accepted)) + lastErr = fmt.Errorf("smoke push %s", syncPushDiagnostic(push)) } time.Sleep(time.Duration(i+1) * time.Second) } return fmt.Errorf("cloudflare smoke failed: %w", lastErr) } +func syncPushDiagnostic(push contract.SyncPushResponse) string { + parts := []string{fmt.Sprintf("accepted=%d rejected=%d conflicts=%d", len(push.Accepted), len(push.Rejected), len(push.Conflicts))} + for _, item := range push.Rejected { + parts = append(parts, fmt.Sprintf("rejected event=%s subject=%s diagnostic=%q", item.EventID, item.Subject, item.Diagnostic)) + } + for _, item := range push.Conflicts { + parts = append(parts, fmt.Sprintf("conflict event=%s subject=%s diagnostic=%q", item.EventID, item.Subject, item.Diagnostic)) + } + return strings.Join(parts, "; ") +} + +func waitCloudflareEndpointReady(ctx context.Context, endpoint string) error { + client := &http.Client{Timeout: 20 * time.Second} + var lastErr error + for i := 0; i < 10; i++ { + if ctx.Err() != nil { + return ctx.Err() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(endpoint, "/")+"/", nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err == nil { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + _ = resp.Body.Close() + if resp.StatusCode < 500 { + return nil + } + lastErr = fmt.Errorf("endpoint returned HTTP %d", resp.StatusCode) + } else { + lastErr = err + } + time.Sleep(time.Duration(i+1) * time.Second) + } + return fmt.Errorf("cloudflare endpoint readiness failed: %w", lastErr) +} + func writeCloudflareLocalSyncConfig(plan cloudflareBootstrapPlan, endpoint string) (string, error) { remoteID := strings.TrimSpace(plan.RemoteID) if remoteID == "" { @@ -436,7 +631,7 @@ func cloudflareEndpointFromWranglerOutput(text string) string { func loadCloudflareBootstrapEnv(explicit string) (map[string]string, string, error) { out := map[string]string{} - for _, key := range []string{"CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "MNEMON_CLOUDFLARE_WORKER_NAME"} { + for _, key := range []string{"CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "MNEMON_CLOUDFLARE_WORKER_NAME", "MNEMON_CLOUDFLARE_SUBDOMAIN"} { if value := strings.TrimSpace(os.Getenv(key)); value != "" { out[key] = value } @@ -479,7 +674,7 @@ func readCloudflareEnvFile(path string) (map[string]string, bool, error) { return nil, false, err } defer f.Close() - allowed := map[string]bool{"CLOUDFLARE_API_TOKEN": true, "CLOUDFLARE_ACCOUNT_ID": true, "MNEMON_CLOUDFLARE_WORKER_NAME": true} + allowed := map[string]bool{"CLOUDFLARE_API_TOKEN": true, "CLOUDFLARE_ACCOUNT_ID": true, "MNEMON_CLOUDFLARE_WORKER_NAME": true, "MNEMON_CLOUDFLARE_SUBDOMAIN": true} values := map[string]string{} scanner := bufio.NewScanner(f) for scanner.Scan() { @@ -541,6 +736,55 @@ func validateCloudflareWorkerName(name string) error { return nil } +func validateCloudflareWorkersSubdomain(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("Cloudflare workers.dev subdomain is required") + } + if len(name) > 63 { + return fmt.Errorf("Cloudflare workers.dev subdomain must be at most 63 characters") + } + if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") { + return fmt.Errorf("Cloudflare workers.dev subdomain must not start or end with dash") + } + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + continue + } + return fmt.Errorf("Cloudflare workers.dev subdomain %q must use lowercase letters, numbers, or dash", name) + } + return nil +} + +func sanitizeCloudflareLabel(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + var b strings.Builder + lastDash := false + for _, r := range value { + ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') + if ok { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + out = "hub" + } + if len(out) > 56 { + out = strings.Trim(out[:56], "-") + } + if out == "" { + out = "hub" + } + return out +} + func randomHex(bytesLen int) (string, error) { buf := make([]byte, bytesLen) if _, err := rand.Read(buf); err != nil { diff --git a/harness/cmd/mnemon-harness/hub_test.go b/harness/cmd/mnemon-harness/hub_test.go index 818f3e47..283b3ef7 100644 --- a/harness/cmd/mnemon-harness/hub_test.go +++ b/harness/cmd/mnemon-harness/hub_test.go @@ -3,6 +3,8 @@ package main import ( "context" "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -14,7 +16,7 @@ import ( func TestCloudflareEnvFileMergesWithoutOverridingProcessEnv(t *testing.T) { t.Setenv("CLOUDFLARE_API_TOKEN", "process-token") path := filepath.Join(t.TempDir(), "cloudflare.env") - if err := os.WriteFile(path, []byte("CLOUDFLARE_API_TOKEN=file-token\nCLOUDFLARE_ACCOUNT_ID=account-1\nMNEMON_CLOUDFLARE_WORKER_NAME=mnemon-r3\n"), 0o600); err != nil { + if err := os.WriteFile(path, []byte("CLOUDFLARE_API_TOKEN=file-token\nCLOUDFLARE_ACCOUNT_ID=account-1\nMNEMON_CLOUDFLARE_WORKER_NAME=mnemon-r3\nMNEMON_CLOUDFLARE_SUBDOMAIN=mnemon-test\n"), 0o600); err != nil { t.Fatal(err) } env, used, err := loadCloudflareBootstrapEnv(path) @@ -27,7 +29,7 @@ func TestCloudflareEnvFileMergesWithoutOverridingProcessEnv(t *testing.T) { if env["CLOUDFLARE_API_TOKEN"] != "process-token" { t.Fatalf("process env must win, got %q", env["CLOUDFLARE_API_TOKEN"]) } - if env["CLOUDFLARE_ACCOUNT_ID"] != "account-1" || env["MNEMON_CLOUDFLARE_WORKER_NAME"] != "mnemon-r3" { + if env["CLOUDFLARE_ACCOUNT_ID"] != "account-1" || env["MNEMON_CLOUDFLARE_WORKER_NAME"] != "mnemon-r3" || env["MNEMON_CLOUDFLARE_SUBDOMAIN"] != "mnemon-test" { t.Fatalf("file values not loaded: %+v", env) } } @@ -50,6 +52,78 @@ func TestCloudflareEndpointFromWranglerOutput(t *testing.T) { } } +func TestEnsureCloudflareWorkersSubdomainCreatesWhenMissing(t *testing.T) { + var requests []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer cf-token" { + t.Fatalf("authorization header = %q", got) + } + requests = append(requests, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + _, _ = w.Write([]byte(`{"success":true,"result":{"subdomain":""},"errors":[]}`)) + case http.MethodPut: + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["subdomain"] != "mnemon-0ff22127" { + t.Fatalf("subdomain body = %+v", body) + } + _, _ = w.Write([]byte(`{"success":true,"result":{"subdomain":"mnemon-0ff22127"},"errors":[]}`)) + default: + http.Error(w, "unexpected method", http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + got, err := ensureCloudflareWorkersSubdomain(context.Background(), cloudflareBootstrapPlan{ + APIToken: "cf-token", + AccountID: "0ff22127f3f11976dfea078f13f4c056", + HTTPClient: server.Client(), + APIBaseURL: server.URL, + }) + if err != nil { + t.Fatal(err) + } + if got != "mnemon-0ff22127" { + t.Fatalf("subdomain = %q", got) + } + want := []string{ + "GET /accounts/0ff22127f3f11976dfea078f13f4c056/workers/subdomain", + "PUT /accounts/0ff22127f3f11976dfea078f13f4c056/workers/subdomain", + } + if strings.Join(requests, "\n") != strings.Join(want, "\n") { + t.Fatalf("requests = %#v, want %#v", requests, want) + } +} + +func TestEnsureCloudflareWorkersSubdomainReusesExisting(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("existing subdomain should not be overwritten, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"result":{"subdomain":"existing-subdomain"},"errors":[]}`)) + })) + defer server.Close() + + got, err := ensureCloudflareWorkersSubdomain(context.Background(), cloudflareBootstrapPlan{ + APIToken: "cf-token", + AccountID: "acct-1", + Subdomain: "ignored-when-existing", + HTTPClient: server.Client(), + APIBaseURL: server.URL, + }) + if err != nil { + t.Fatal(err) + } + if got != "existing-subdomain" { + t.Fatalf("subdomain = %q", got) + } +} + func TestCloudflareGrantAndTokenJSONDoNotExposeCloudflareToken(t *testing.T) { plan := cloudflareBootstrapPlan{ APIToken: "cfat_secret", @@ -72,6 +146,17 @@ func TestCloudflareGrantAndTokenJSONDoNotExposeCloudflareToken(t *testing.T) { if decoded["planner@team"]["principal"] != "planner@team" { t.Fatalf("grant principal mismatch: %+v", decoded) } + scopes, ok := decoded["planner@team"]["scopes"].([]any) + if !ok || len(scopes) != 1 { + t.Fatalf("grant scopes missing: %+v", decoded["planner@team"]["scopes"]) + } + scope, ok := scopes[0].(map[string]any) + if !ok || scope["kind"] != "memory" || scope["id"] != "project" { + t.Fatalf("grant scopes must use lower-case ABI keys, got %+v", scopes[0]) + } + if _, hasUpperKind := scope["Kind"]; hasUpperKind { + t.Fatalf("grant scopes leaked Go field name Kind: %+v", scope) + } } func TestBootstrapCloudflareNoDeployWritesLocalConfigWithoutRunningCommands(t *testing.T) { diff --git a/harness/internal/contract/contract.go b/harness/internal/contract/contract.go index b2dce551..96ff00a9 100644 --- a/harness/internal/contract/contract.go +++ b/harness/internal/contract/contract.go @@ -7,8 +7,8 @@ type ResourceKind string // "memory", "goal", "skill" type ResourceID string type Version int64 // per-resource; 1 on create; +1 each accepted write. NEVER global. type ResourceRef struct { - Kind ResourceKind - ID ResourceID + Kind ResourceKind `json:"kind"` + ID ResourceID `json:"id"` } type ResourceVersion struct { Ref ResourceRef diff --git a/harness/scripts/r3-1/cloudflare-live/verify.py b/harness/scripts/r3-1/cloudflare-live/verify.py index ab9e6d5f..0e29ad8e 100644 --- a/harness/scripts/r3-1/cloudflare-live/verify.py +++ b/harness/scripts/r3-1/cloudflare-live/verify.py @@ -20,7 +20,7 @@ def main(): log_text = Path(args.log).read_text(encoding="utf-8", errors="replace") failures = [] - missing_workers_dev = "workers.dev subdomain" in log_text or "code: 10063" in log_text + missing_workers_dev = "code: 10063" in log_text or "You need a workers.dev subdomain" in log_text if args.exit_code != 0 and not missing_workers_dev: failures.append({ "category": "environment_failure", From 447168e3e676a5184782ab0fbdc923eb4c6062ad Mon Sep 17 00:00:00 2001 From: Grivn Date: Thu, 2 Jul 2026 02:57:47 +0800 Subject: [PATCH 7/9] feat: gate Multica runtime before provider proxy mnemon-multica-runtime now buffers early JSON-RPC until a turn can be classified, proxies untouched provider traffic only when no Multica issue activation is present, and keeps issue-triggered imports under mnemond control. The R3-1 gate and writeback phases now execute focused tests instead of reporting pending skips. Validation: go test ./harness/cmd/mnemon-multica-runtime ./harness/cmd/mnemon-harness ./harness/internal/coreguard; harness/scripts/r3-1/run.sh --phase multica-runtime-gate; harness/scripts/r3-1/run.sh --phase multica-writeback. --- harness/cmd/mnemon-multica-runtime/main.go | 287 +++++++++--------- .../cmd/mnemon-multica-runtime/main_test.go | 208 ++++++++----- .../scripts/r3-1/multica-runtime-gate/run.sh | 17 +- .../r3-1/multica-runtime-gate/verify.py | 65 ++++ harness/scripts/r3-1/multica-writeback/run.sh | 17 +- .../scripts/r3-1/multica-writeback/verify.py | 66 ++++ 6 files changed, 429 insertions(+), 231 deletions(-) create mode 100644 harness/scripts/r3-1/multica-runtime-gate/verify.py create mode 100644 harness/scripts/r3-1/multica-writeback/verify.py diff --git a/harness/cmd/mnemon-multica-runtime/main.go b/harness/cmd/mnemon-multica-runtime/main.go index e47de007..f03752ef 100644 --- a/harness/cmd/mnemon-multica-runtime/main.go +++ b/harness/cmd/mnemon-multica-runtime/main.go @@ -73,17 +73,6 @@ type runtimeImportResult struct { Err error } -type providerTurnResult struct { - Configured bool - Runtime string - Command string - CWD string - Output string - ExitCode int - DurationMs int64 - Err error -} - func main() { if err := runRuntime(runtimeConfig{ Args: os.Args[1:], @@ -138,17 +127,47 @@ func runRuntime(cfg runtimeConfig) error { } func runRuntimeRPC(cfg runtimeConfig, cwd string) error { - scanner := bufio.NewScanner(cfg.Stdin) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - state := runtimeRPCState{CWD: cwd, Env: cfg.Env, Now: cfg.Now} - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { + reader := bufio.NewReaderSize(cfg.Stdin, 1024*1024) + if runtimeHasIssueActivation(cfg.Env, cwd, multicasurface.RuntimeInput{}) { + return runManagedRuntimeRPC(cfg, cwd, nil, reader) + } + + var buffered []string + for { + raw, err := reader.ReadString('\n') + if raw != "" { + buffered = append(buffered, raw) + msg, ok := decodeRuntimeRPCLine(raw, cfg.Stderr) + if ok && msg.Method == "turn/start" { + input := multicasurface.RuntimeInputMaterial(msg.Params) + if runtimeHasIssueActivation(cfg.Env, cwd, input) { + return runManagedRuntimeRPC(cfg, cwd, buffered, reader) + } + proxyCfg := cfg + proxyCfg.Stdin = io.MultiReader(strings.NewReader(strings.Join(buffered, "")), reader) + return runProviderStdioProxy(proxyCfg, cwd) + } + } + if err == nil { continue } - var msg rpcMessage - if err := json.Unmarshal([]byte(line), &msg); err != nil { - fmt.Fprintf(cfg.Stderr, "%s: ignoring invalid rpc line: %v\n", multicasurface.MulticaRuntimeCommandName, err) + if err == io.EOF { + if len(buffered) == 0 { + return nil + } + proxyCfg := cfg + proxyCfg.Stdin = strings.NewReader(strings.Join(buffered, "")) + return runProviderStdioProxy(proxyCfg, cwd) + } + return err + } +} + +func runManagedRuntimeRPC(cfg runtimeConfig, cwd string, prelude []string, reader *bufio.Reader) error { + state := runtimeRPCState{CWD: cwd, Env: cfg.Env, Now: cfg.Now} + for _, raw := range prelude { + msg, ok := decodeRuntimeRPCLine(raw, cfg.Stderr) + if !ok { continue } if err := state.handle(msg, func(response rpcMessage) error { @@ -157,7 +176,39 @@ func runRuntimeRPC(cfg runtimeConfig, cwd string) error { return err } } - return scanner.Err() + for { + raw, err := reader.ReadString('\n') + if raw != "" { + msg, ok := decodeRuntimeRPCLine(raw, cfg.Stderr) + if ok { + if handleErr := state.handle(msg, func(response rpcMessage) error { + return writeRuntimeRPC(cfg.Stdout, response) + }); handleErr != nil { + return handleErr + } + } + } + if err == nil { + continue + } + if err == io.EOF { + return nil + } + return err + } +} + +func decodeRuntimeRPCLine(raw string, stderr io.Writer) (rpcMessage, bool) { + line := strings.TrimSpace(raw) + if line == "" { + return rpcMessage{}, false + } + var msg rpcMessage + if err := json.Unmarshal([]byte(line), &msg); err != nil { + fmt.Fprintf(stderr, "%s: ignoring invalid rpc line: %v\n", multicasurface.MulticaRuntimeCommandName, err) + return rpcMessage{}, false + } + return msg, true } func (s *runtimeRPCState) handle(msg rpcMessage, emit func(rpcMessage) error) error { @@ -292,16 +343,76 @@ func (s *runtimeRPCState) nextItemID(prefix string) string { func (s *runtimeRPCState) runTurn(input multicasurface.RuntimeInput, progress runtimeProgressSink) string { result := s.importIssue(input, progress) - provider := s.runProviderTurn(input, result, progress) - if provider.Configured { - if strings.TrimSpace(provider.Output) != "" && provider.Err == nil { - return strings.TrimSpace(provider.Output) - } - return formatProviderFailureAnswer(provider, result) - } return multicasurface.FormatRuntimeFinalAnswer(runtimeResultSummary(result)) } +func runtimeHasIssueActivation(env []string, cwd string, input multicasurface.RuntimeInput) bool { + activation := multicasurface.RuntimeContextFromActivation(env, cwd, multicasurface.RuntimeInput{}) + if strings.TrimSpace(activation.IssueIdentity) != "" { + return true + } + activation = multicasurface.RuntimeContextFromActivation(env, cwd, input) + return strings.TrimSpace(activation.IssueIdentity) != "" +} + +func runProviderStdioProxy(cfg runtimeConfig, cwd string) error { + command := runtimeProviderCommand(cfg.Env) + if command == "" { + return fmt.Errorf("no provider command configured") + } + ctx := context.Background() + cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command) + cmd.Dir = cwd + cmd.Env = append([]string(nil), cfg.Env...) + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("provider stdin pipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("provider stdout pipe: %w", err) + } + cmd.Stderr = cfg.Stderr + if err := cmd.Start(); err != nil { + return fmt.Errorf("start provider app-server: %w", err) + } + copyErr := make(chan error, 2) + go func() { + _, err := io.Copy(stdin, cfg.Stdin) + _ = stdin.Close() + copyErr <- err + }() + go func() { + _, err := io.Copy(cfg.Stdout, stdout) + copyErr <- err + }() + stdinErr := <-copyErr + stdoutErr := <-copyErr + waitErr := cmd.Wait() + if stdinErr != nil { + return fmt.Errorf("copy runtime input to provider: %w", stdinErr) + } + if stdoutErr != nil { + return fmt.Errorf("copy provider output to runtime: %w", stdoutErr) + } + if waitErr != nil { + return fmt.Errorf("provider app-server exited: %w", waitErr) + } + return nil +} + +func runtimeProviderCommand(env []string) string { + if command := multicasurface.RuntimeEnvValue(env, "MNEMON_MULTICA_PROVIDER_COMMAND"); command != "" { + return command + } + switch strings.ToLower(multicasurface.RuntimeEnvDefault(env, "MNEMON_MULTICA_PROVIDER_RUNTIME", "codex")) { + case "codex", "codex-jsonrpc": + return "codex app-server --listen stdio://" + default: + return "" + } +} + func (s *runtimeRPCState) importIssue(input multicasurface.RuntimeInput, progress runtimeProgressSink) runtimeImportResult { activation := multicasurface.RuntimeContextFromActivation(s.Env, s.CWD, input) taskID := activation.TaskID @@ -403,126 +514,6 @@ func (s *runtimeRPCState) importIssue(input multicasurface.RuntimeInput, progres return result } -func (s *runtimeRPCState) runProviderTurn(input multicasurface.RuntimeInput, imported runtimeImportResult, progress runtimeProgressSink) providerTurnResult { - command := multicasurface.RuntimeEnvValue(s.Env, "MNEMON_MULTICA_PROVIDER_COMMAND") - if command == "" { - return providerTurnResult{} - } - provider := multicasurface.RuntimeEnvDefault(s.Env, "MNEMON_MULTICA_PROVIDER_RUNTIME", "provider") - cwd := multicasurface.RuntimeEnvDefault(s.Env, "MNEMON_MULTICA_PROVIDER_WORKSPACE", s.CWD) - prompt := providerPrompt(input, imported) - timeout := multicasurface.RuntimeProviderTurnTimeout(s.Env) - result := providerTurnResult{ - Configured: true, - Runtime: provider, - Command: command, - CWD: cwd, - } - emitRuntimeProgress(progress, "Starting Multica-hosted provider turn through "+provider+".") - started := s.now() - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command) - cmd.Dir = cwd - cmd.Env = runtimeProviderEnv(s.Env, imported) - cmd.Stdin = strings.NewReader(prompt) - out, err := cmd.CombinedOutput() - result.DurationMs = s.now().Sub(started).Milliseconds() - result.Output = strings.TrimSpace(string(out)) - if err != nil { - result.Err = err - result.ExitCode = runtimeExitCode(err) - if exitErr, ok := err.(*exec.ExitError); ok { - result.ExitCode = exitErr.ExitCode() - } - if ctx.Err() == context.DeadlineExceeded { - result.Err = fmt.Errorf("provider turn timed out after %s", timeout) - } - emitRuntimeCommandCWD(progress, command, cwd, result.Output, result.ExitCode, result.DurationMs) - emitRuntimeProgress(progress, "Provider turn failed; Mnemon surface import remains separate from provider execution.") - return result - } - result.ExitCode = 0 - emitRuntimeCommandCWD(progress, command, cwd, result.Output, result.ExitCode, result.DurationMs) - emitRuntimeProgress(progress, "Provider turn completed; Mnemon did not rewrite provider output.") - return result -} - -func runtimeProviderEnv(env []string, imported runtimeImportResult) []string { - out := append([]string(nil), env...) - out = setRuntimeProviderEnv(out, "MULTICA_ISSUE_ID", imported.IssueID) - out = setRuntimeProviderEnv(out, "MULTICA_TASK_ID", imported.TaskID) - out = setRuntimeProviderEnv(out, "MNEMON_MULTICA_ISSUE_ID", imported.IssueID) - out = setRuntimeProviderEnv(out, "MNEMON_MULTICA_ISSUE_IDENTIFIER", imported.Identifier) - out = setRuntimeProviderEnv(out, "MNEMON_MULTICA_ISSUE_TITLE", imported.Title) - out = setRuntimeProviderEnv(out, "MNEMON_MULTICA_ISSUE_STATUS", imported.Status) - out = setRuntimeProviderEnv(out, "MNEMON_MULTICA_PRINCIPAL", imported.Principal) - return out -} - -func setRuntimeProviderEnv(env []string, key, value string) []string { - key = strings.TrimSpace(key) - value = strings.TrimSpace(value) - if key == "" || value == "" { - return env - } - next := make([]string, 0, len(env)+1) - prefix := key + "=" - for _, entry := range env { - if strings.HasPrefix(entry, prefix) { - continue - } - next = append(next, entry) - } - return append(next, key+"="+value) -} - -func providerPrompt(input multicasurface.RuntimeInput, imported runtimeImportResult) string { - if text := strings.TrimSpace(input.Text); text != "" { - return text + "\n" - } - var b strings.Builder - label := firstNonEmpty(imported.Identifier, imported.IssueID) - if label != "" || strings.TrimSpace(imported.Title) != "" { - b.WriteString("Multica issue") - if label != "" { - b.WriteByte(' ') - b.WriteString(label) - } - if strings.TrimSpace(imported.Title) != "" { - b.WriteString(": ") - b.WriteString(strings.TrimSpace(imported.Title)) - } - b.WriteString("\n\n") - } - if strings.TrimSpace(imported.Statement) != "" { - b.WriteString(strings.TrimSpace(imported.Statement)) - b.WriteByte('\n') - } - return b.String() -} - -func formatProviderFailureAnswer(provider providerTurnResult, imported runtimeImportResult) string { - var b strings.Builder - b.WriteString("Provider turn failed") - if provider.Runtime != "" { - b.WriteString(" (") - b.WriteString(provider.Runtime) - b.WriteString(")") - } - if provider.Err != nil { - b.WriteString(": ") - b.WriteString(provider.Err.Error()) - } - if strings.TrimSpace(provider.Output) != "" { - b.WriteString("\n\n") - b.WriteString(strings.TrimSpace(provider.Output)) - } - b.WriteString("\n\n") - b.WriteString(multicasurface.FormatRuntimeFinalAnswer(runtimeResultSummary(imported))) - return strings.TrimSpace(b.String()) -} - func loadRuntimeIssueMetadata(ctx context.Context, cli driver.MulticaCLI, issue driver.MulticaIssue, progress runtimeProgressSink) driver.MulticaIssue { if strings.TrimSpace(issue.ID) == "" { return issue diff --git a/harness/cmd/mnemon-multica-runtime/main_test.go b/harness/cmd/mnemon-multica-runtime/main_test.go index a3e7650f..4ab0e231 100644 --- a/harness/cmd/mnemon-multica-runtime/main_test.go +++ b/harness/cmd/mnemon-multica-runtime/main_test.go @@ -115,19 +115,55 @@ func TestRuntimeTreatsLegacyMailboxMetadataAsSurfaceInputOnly(t *testing.T) { } } -func TestRuntimeRunsProviderCommandWithOriginalTurnInput(t *testing.T) { +func TestRuntimeProxiesProviderAppServerWhenNoIssueGateIsPresent(t *testing.T) { + tmp := t.TempDir() + providerLogPath := filepath.Join(tmp, "provider-rpc.jsonl") + providerBin := writeFakeCodexAppServer(t, providerLogPath) + input := strings.Join([]string{ + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"multica","version":"test"}}}`, + `{"jsonrpc":"2.0","method":"initialized"}`, + `{"jsonrpc":"2.0","id":2,"method":"thread/start","params":{"cwd":"` + tmp + `","ephemeral":true}}`, + `{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"threadId":"thread-fake","input":[{"type":"text","text":"你好,请保持原生 Codex 流。"}]}}`, + }, "\n") + "\n" + var out strings.Builder + err := runRuntime(runtimeConfig{ + Env: []string{ + "MNEMON_MULTICA_PROVIDER_RUNTIME=codex", + "MNEMON_MULTICA_PROVIDER_COMMAND=" + providerBin, + "FAKE_CODEX_APP_SERVER_LOG=" + providerLogPath, + }, + CWD: tmp, + Stdin: strings.NewReader(input), + Stdout: &out, + Now: fixedRuntimeTime, + }) + if err != nil { + t.Fatal(err) + } + providerLog := readFile(t, providerLogPath) + for _, want := range []string{`"method":"initialize"`, `"method":"thread/start"`, `"method":"turn/start"`, "你好,请保持原生 Codex 流。"} { + if !strings.Contains(providerLog, want) { + t.Fatalf("provider did not receive %q:\n%s", want, providerLog) + } + } + for _, want := range []string{`"provider":"fake-codex"`, `"method":"item/agentMessage/delta"`, "原生 provider 输出:已读取中文 turn"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("runtime did not proxy provider output %q:\n%s", want, out.String()) + } + } +} + +func TestRuntimeGateDoesNotStartProviderForIssueActivation(t *testing.T) { tmp := t.TempDir() logPath := filepath.Join(tmp, "multica-args.log") + providerLogPath := filepath.Join(tmp, "provider-rpc.jsonl") multicaBin := writeFakeMulticaCLI(t, logPath, fakeMulticaScript{ IssueJSON: `{"id":"issue-provider","identifier":"TEA-9","title":"中文验收-多角色决策","description":"请协调多角色完成退款策略复盘。","status":"todo","priority":"high"}`, MetadataJSON: `[]`, }) server, received := fakeIngestServer(t) defer server.Close() - providerInputPath := filepath.Join(tmp, "provider.stdin") - providerOutput := "原生 provider 输出:已读取中文 turn" - providerBin := writeFakeProvider(t) - progress := []runtimeProgressEvent{} + providerBin := writeFakeCodexAppServer(t, providerLogPath) final := (&runtimeRPCState{ Env: []string{ @@ -140,97 +176,85 @@ func TestRuntimeRunsProviderCommandWithOriginalTurnInput(t *testing.T) { "MNEMON_CONTROL_PRINCIPAL=reviewer@team", "MNEMON_MULTICA_PROVIDER_RUNTIME=codex", "MNEMON_MULTICA_PROVIDER_COMMAND=" + providerBin, - "MNEMON_MULTICA_PROVIDER_WORKSPACE=" + tmp, - "MNEMON_MULTICA_PROVIDER_TURN_TIMEOUT=5s", - "FAKE_PROVIDER_INPUT=" + providerInputPath, - "FAKE_PROVIDER_OUTPUT=" + providerOutput, + "FAKE_CODEX_APP_SERVER_LOG=" + providerLogPath, "FAKE_MULTICA_LOG=" + logPath, }, CWD: tmp, Now: fixedRuntimeTime, - }).runTurn(multicasurface.RuntimeInput{Text: "请基于当前 issue 继续推进中文 ReAct 协作。"}, func(event runtimeProgressEvent) { - progress = append(progress, event) - }) + }).runTurn(multicasurface.RuntimeInput{Text: "请基于当前 issue 继续推进中文 ReAct 协作。"}, nil) - if final != providerOutput { + if !strings.Contains(final, "Mnemon Multica runtime handled issue") { t.Fatalf("final answer = %q", final) } - if got := readFile(t, providerInputPath); got != "请基于当前 issue 继续推进中文 ReAct 协作。\n" { - t.Fatalf("provider input = %q", got) - } env := <-received if env.ExternalID != "multica-task-task-provider" { t.Fatalf("unexpected ingest external id: %+v", env) } - foundProviderCommand := false - for _, event := range progress { - if event.Command == providerBin { - foundProviderCommand = true - if event.CWD != tmp { - t.Fatalf("provider cwd = %q, want %q", event.CWD, tmp) - } - } - } - if !foundProviderCommand { - t.Fatalf("provider command was not emitted in progress: %+v", progress) - } - argsLog := readFile(t, logPath) - if strings.Contains(argsLog, "issue comment add") || - strings.Contains(argsLog, "issue status set") || - strings.Contains(argsLog, "[mnemon:wake]") { - t.Fatalf("provider wrapper performed forbidden R2 side effect:\n%s", argsLog) + if got := readFile(t, providerLogPath); strings.TrimSpace(got) != "" { + t.Fatalf("issue activation bypassed gate and started provider:\n%s", got) } } -func TestRuntimeProviderEnvAddsImportedIssueContext(t *testing.T) { - env := runtimeProviderEnv([]string{ - "MULTICA_TASK_ID=daemon-task", - "MULTICA_AGENT_ID=agent-1", - }, runtimeImportResult{ - IssueID: "issue-imported", - Identifier: "TEA-42", - Title: "中文协作验收", - Principal: "planner@team", - TaskID: "task-imported", - Status: "recorded", +func TestRuntimeGateUsesTurnIssueTagBeforeStartingProvider(t *testing.T) { + tmp := t.TempDir() + logPath := filepath.Join(tmp, "multica-args.log") + providerLogPath := filepath.Join(tmp, "provider-rpc.jsonl") + multicaBin := writeFakeMulticaCLI(t, logPath, fakeMulticaScript{ + IssueJSON: `{"id":"issue-tagged","identifier":"TEA-9","title":"中文验收-由 @tag 触发","description":"请根据 @TEA-9 进入 Mnemon gate。","status":"todo","priority":"high"}`, + MetadataJSON: `[]`, }) - got := map[string]string{} - counts := map[string]int{} - for _, entry := range env { - key, value, ok := strings.Cut(entry, "=") - if ok { - got[key] = value - counts[key]++ - } + server, received := fakeIngestServer(t) + defer server.Close() + providerBin := writeFakeCodexAppServer(t, providerLogPath) + input := strings.Join([]string{ + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"multica","version":"test"}}}`, + `{"jsonrpc":"2.0","id":2,"method":"thread/start","params":{"cwd":"` + tmp + `","ephemeral":true}}`, + `{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"threadId":"thread-fake","input":[{"type":"text","text":"请处理 @TEA-9 并进入 Mnemon 协作流程。"}]}}`, + }, "\n") + "\n" + var out strings.Builder + + err := runRuntime(runtimeConfig{ + Env: []string{ + "MULTICA_TASK_ID=task-tagged", + "MULTICA_AGENT_ID=agent-1", + "MULTICA_AGENT_NAME=Reviewer", + "MNEMON_MULTICA_BIN=" + multicaBin, + "MNEMON_CONTROL_ADDR=" + server.URL, + "MNEMON_CONTROL_PRINCIPAL=reviewer@team", + "MNEMON_MULTICA_PROVIDER_RUNTIME=codex", + "MNEMON_MULTICA_PROVIDER_COMMAND=" + providerBin, + "FAKE_CODEX_APP_SERVER_LOG=" + providerLogPath, + "FAKE_MULTICA_LOG=" + logPath, + }, + CWD: tmp, + Stdin: strings.NewReader(input), + Stdout: &out, + Now: fixedRuntimeTime, + }) + if err != nil { + t.Fatal(err) } - for key, want := range map[string]string{ - "MULTICA_ISSUE_ID": "issue-imported", - "MULTICA_TASK_ID": "task-imported", - "MNEMON_MULTICA_ISSUE_IDENTIFIER": "TEA-42", - "MNEMON_MULTICA_ISSUE_TITLE": "中文协作验收", - "MNEMON_MULTICA_ISSUE_STATUS": "recorded", - "MNEMON_MULTICA_PRINCIPAL": "planner@team", - } { - if got[key] != want { - t.Fatalf("%s = %q, want %q (env=%v)", key, got[key], want, env) - } - if counts[key] != 1 { - t.Fatalf("%s appears %d times in env=%v", key, counts[key], env) - } + env := <-received + if env.ExternalID != "multica-task-task-tagged" { + t.Fatalf("unexpected ingest external id: %+v", env) + } + if got := readFile(t, providerLogPath); strings.TrimSpace(got) != "" { + t.Fatalf("turn issue gate started provider before mnemond import:\n%s", got) + } + if !strings.Contains(out.String(), "Mnemon Multica runtime handled issue") { + t.Fatalf("runtime output did not include Mnemon result:\n%s", out.String()) } } -func TestProviderPromptFallsBackToIssueWhenTurnInputIsEmpty(t *testing.T) { - got := providerPrompt(multicasurface.RuntimeInput{}, runtimeImportResult{ - IssueID: "issue-1", - Identifier: "TEA-1", - Title: "中文复用上下文评审", - Statement: "请复用上一轮风险结论,并给出下一步。", - }) - for _, want := range []string{"TEA-1", "中文复用上下文评审", "请复用上一轮风险结论"} { - if !strings.Contains(got, want) { - t.Fatalf("provider prompt missing %q:\n%s", want, got) - } +func TestRuntimeProviderCommandDefaultsToCodexJSONRPC(t *testing.T) { + if got := runtimeProviderCommand(nil); got != "codex app-server --listen stdio://" { + t.Fatalf("default provider command = %q", got) + } + if got := runtimeProviderCommand([]string{"MNEMON_MULTICA_PROVIDER_RUNTIME=codex-jsonrpc"}); got != "codex app-server --listen stdio://" { + t.Fatalf("codex-jsonrpc provider command = %q", got) + } + if got := runtimeProviderCommand([]string{"MNEMON_MULTICA_PROVIDER_RUNTIME=unknown"}); got != "" { + t.Fatalf("unknown provider command = %q", got) } } @@ -267,17 +291,39 @@ esac return path } -func writeFakeProvider(t *testing.T) string { +func writeFakeCodexAppServer(t *testing.T, logPath string) string { t.Helper() - path := filepath.Join(t.TempDir(), "provider") + path := filepath.Join(t.TempDir(), "fake-codex-app-server") body := `#!/bin/sh set -eu -cat > "$FAKE_PROVIDER_INPUT" -printf '%s\n' "$FAKE_PROVIDER_OUTPUT" +: > "$FAKE_CODEX_APP_SERVER_LOG" +while IFS= read -r line; do + printf '%s\n' "$line" >> "$FAKE_CODEX_APP_SERVER_LOG" + case "$line" in + *'"method":"initialize"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"provider":"fake-codex","userAgent":"fake-codex/0.1"}}' + ;; + *'"method":"thread/start"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"thread-fake","sessionId":"thread-fake","status":{"type":"idle"}}}}' + printf '%s\n' '{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"thread-fake","sessionId":"thread-fake","status":{"type":"idle"}}}}' + ;; + *'"method":"turn/start"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"turn":{"id":"turn-fake","status":"inProgress","items":[]}}}' + printf '%s\n' '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thread-fake","turn":{"id":"turn-fake","status":"inProgress","items":[]}}}' + printf '%s\n' '{"jsonrpc":"2.0","method":"item/agentMessage/delta","params":{"threadId":"thread-fake","turnId":"turn-fake","itemId":"msg-fake","delta":"原生 provider 输出:已读取中文 turn"}}' + printf '%s\n' '{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thread-fake","turn":{"id":"turn-fake","status":"completed","items":[]}}}' + exit 0 + ;; + esac +done ` if err := os.WriteFile(path, []byte(body), 0o755); err != nil { t.Fatal(err) } + if err := os.WriteFile(logPath, nil, 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("FAKE_CODEX_APP_SERVER_LOG", logPath) return path } diff --git a/harness/scripts/r3-1/multica-runtime-gate/run.sh b/harness/scripts/r3-1/multica-runtime-gate/run.sh index 36fe13d9..e99ae669 100755 --- a/harness/scripts/r3-1/multica-runtime-gate/run.sh +++ b/harness/scripts/r3-1/multica-runtime-gate/run.sh @@ -1,7 +1,22 @@ #!/usr/bin/env bash set -uo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/common.sh" + PHASE="multica-runtime-gate" PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" -python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "R3-1 gate-first runtime implementation is pending" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +LOG="$PHASE_DIR/go-test.log" +cd "$MNEMON_R3_ROOT" +set +e +go test -v ./harness/cmd/mnemon-multica-runtime -run 'TestRuntime(ProxiesProviderAppServerWhenNoIssueGateIsPresent|GateDoesNotStartProviderForIssueActivation|GateUsesTurnIssueTagBeforeStartingProvider|ProviderCommandDefaultsToCodexJSONRPC)$' >"$LOG" 2>&1 +STATUS=$? +set -e + +python3 "$SCRIPT_DIR/verify.py" \ + --summary "$PHASE_DIR/summary.json" \ + --log "$LOG" \ + --exit-code "$STATUS" +exit "$STATUS" diff --git a/harness/scripts/r3-1/multica-runtime-gate/verify.py b/harness/scripts/r3-1/multica-runtime-gate/verify.py new file mode 100644 index 00000000..ff92518b --- /dev/null +++ b/harness/scripts/r3-1/multica-runtime-gate/verify.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--log", required=True) + parser.add_argument("--exit-code", required=True, type=int) + args = parser.parse_args() + + log_text = Path(args.log).read_text(encoding="utf-8", errors="replace") + wanted = [ + "TestRuntimeProxiesProviderAppServerWhenNoIssueGateIsPresent", + "TestRuntimeGateDoesNotStartProviderForIssueActivation", + "TestRuntimeGateUsesTurnIssueTagBeforeStartingProvider", + "TestRuntimeProviderCommandDefaultsToCodexJSONRPC", + ] + assertions = [ + {"name": "gate-focused runtime tests exited successfully", "passed": args.exit_code == 0}, + {"name": "go test selected mnemon-multica-runtime package", "passed": "github.com/mnemon-dev/mnemon/harness/cmd/mnemon-multica-runtime" in log_text}, + ] + for name in wanted: + assertions.append({"name": f"{name} executed", "passed": name in log_text}) + + failures = [] + if args.exit_code != 0: + failures.append({ + "category": "test_failure", + "name": "multica runtime gate tests", + "detail": f"go test exited {args.exit_code}; see {args.log}", + }) + status = "ok" if not failures and all(item["passed"] for item in assertions) else "failed" + summary = { + "schema_version": 1, + "phase": "multica-runtime-gate", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": [{ + "name": "go test ./harness/cmd/mnemon-multica-runtime -run ", + "status": "ok" if args.exit_code == 0 else "failed", + "exit_code": args.exit_code, + "log": args.log, + }], + "assertions": assertions, + "skipped": [], + "failures": failures, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + raise SystemExit(0 if status == "ok" else 1) + + +if __name__ == "__main__": + main() diff --git a/harness/scripts/r3-1/multica-writeback/run.sh b/harness/scripts/r3-1/multica-writeback/run.sh index 18e886b2..18762291 100755 --- a/harness/scripts/r3-1/multica-writeback/run.sh +++ b/harness/scripts/r3-1/multica-writeback/run.sh @@ -1,7 +1,22 @@ #!/usr/bin/env bash set -uo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/common.sh" + PHASE="multica-writeback" PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" -python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Command-driven Multica writeback implementation is pending" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +LOG="$PHASE_DIR/go-test.log" +cd "$MNEMON_R3_ROOT" +set +e +go test -v ./harness/cmd/mnemon-harness ./harness/internal/coreguard -run 'Test(MulticaSurfaceReportWritesDisplayOnlyState|MulticaActivationCarrierCreatesTriggerIssue|MulticaRuntimeDoesNotOwnManagedWakeOrDisplayWriteback|MnemondAndMnemonHubDoNotDependOnMulticaSurface)$' >"$LOG" 2>&1 +STATUS=$? +set -e + +python3 "$SCRIPT_DIR/verify.py" \ + --summary "$PHASE_DIR/summary.json" \ + --log "$LOG" \ + --exit-code "$STATUS" +exit "$STATUS" diff --git a/harness/scripts/r3-1/multica-writeback/verify.py b/harness/scripts/r3-1/multica-writeback/verify.py new file mode 100644 index 00000000..414a5732 --- /dev/null +++ b/harness/scripts/r3-1/multica-writeback/verify.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--log", required=True) + parser.add_argument("--exit-code", required=True, type=int) + args = parser.parse_args() + + log_text = Path(args.log).read_text(encoding="utf-8", errors="replace") + wanted = [ + "TestMulticaSurfaceReportWritesDisplayOnlyState", + "TestMulticaActivationCarrierCreatesTriggerIssue", + "TestMulticaRuntimeDoesNotOwnManagedWakeOrDisplayWriteback", + "TestMnemondAndMnemonHubDoNotDependOnMulticaSurface", + ] + assertions = [ + {"name": "writeback boundary tests exited successfully", "passed": args.exit_code == 0}, + {"name": "display writeback does not use wake tag", "passed": "[mnemon:wake]" not in log_text}, + {"name": "test log does not contain Cloudflare token literal", "passed": "cfat_" not in log_text}, + ] + for name in wanted: + assertions.append({"name": f"{name} executed", "passed": name in log_text}) + + failures = [] + if args.exit_code != 0: + failures.append({ + "category": "test_failure", + "name": "multica writeback boundary tests", + "detail": f"go test exited {args.exit_code}; see {args.log}", + }) + status = "ok" if not failures and all(item["passed"] for item in assertions) else "failed" + summary = { + "schema_version": 1, + "phase": "multica-writeback", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": [{ + "name": "go test ./harness/cmd/mnemon-harness ./harness/internal/coreguard -run ", + "status": "ok" if args.exit_code == 0 else "failed", + "exit_code": args.exit_code, + "log": args.log, + }], + "assertions": assertions, + "skipped": [], + "failures": failures, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + raise SystemExit(0 if status == "ok" else 1) + + +if __name__ == "__main__": + main() From 3d915d80b026c2af1e89eb0f5d340a678dcbfd38 Mon Sep 17 00:00:00 2001 From: Grivn Date: Thu, 2 Jul 2026 03:14:38 +0800 Subject: [PATCH 8/9] test: add Docker and live Multica R3-1 phases R3-1 now has real Docker local and Docker-to-Cloudflare sync probes, a live Multica smoke that creates a visible issue/run, and a Chinese complex-case runner that creates overlapping PoC roots, role assignments, shared-context comments, and a follow-up issue. Validation: harness/scripts/r3-1/run.sh --phase docker-local; harness/scripts/r3-1/run.sh --phase docker-cloudflare; harness/scripts/r3-1/run.sh --phase live-multica; harness/scripts/r3-1/run.sh --phase zh-complex-cases; go test ./harness/scripts/r3-1/docker; python3 -m py_compile harness/scripts/r3-1/docker-local/verify.py harness/scripts/r3-1/docker-cloudflare/verify.py harness/scripts/r3-1/live-multica/verify.py harness/scripts/r3-1/zh-complex-cases/run.py. --- harness/scripts/r3-1/docker-cloudflare/run.sh | 75 ++++- .../scripts/r3-1/docker-cloudflare/verify.py | 77 +++++ harness/scripts/r3-1/docker-local/run.sh | 70 +++- harness/scripts/r3-1/docker-local/verify.py | 77 +++++ harness/scripts/r3-1/docker/compose.yml | 58 ++++ harness/scripts/r3-1/docker/probe.go | 128 ++++++++ harness/scripts/r3-1/live-multica/run.sh | 154 ++++++++- harness/scripts/r3-1/live-multica/verify.py | 96 ++++++ harness/scripts/r3-1/zh-complex-cases/run.py | 303 ++++++++++++++++++ harness/scripts/r3-1/zh-complex-cases/run.sh | 11 +- 10 files changed, 1045 insertions(+), 4 deletions(-) create mode 100644 harness/scripts/r3-1/docker-cloudflare/verify.py create mode 100644 harness/scripts/r3-1/docker-local/verify.py create mode 100644 harness/scripts/r3-1/docker/compose.yml create mode 100644 harness/scripts/r3-1/docker/probe.go create mode 100644 harness/scripts/r3-1/live-multica/verify.py create mode 100644 harness/scripts/r3-1/zh-complex-cases/run.py diff --git a/harness/scripts/r3-1/docker-cloudflare/run.sh b/harness/scripts/r3-1/docker-cloudflare/run.sh index 71dd67b4..df136013 100755 --- a/harness/scripts/r3-1/docker-cloudflare/run.sh +++ b/harness/scripts/r3-1/docker-cloudflare/run.sh @@ -1,7 +1,80 @@ #!/usr/bin/env bash set -uo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/common.sh" + PHASE="docker-cloudflare" PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" -python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Docker Cloudflare endpoint validation is pending" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +ENV_FILE="${MNEMON_CLOUDFLARE_ENV_FILE:-$HOME/.mnemon/cloudflare-bootstrap.env}" +BOOTSTRAP_LOG="$PHASE_DIR/bootstrap.log" +PROBE_LOG="$PHASE_DIR/docker-probe.log" +WORKSPACE="$PHASE_DIR/workspace" +mkdir -p "$WORKSPACE" + +if [[ ! -f "$ENV_FILE" ]]; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Cloudflare env file missing: $ENV_FILE" + exit 0 +fi + +if [[ "$(stat -f '%Lp' "$ENV_FILE" 2>/dev/null || stat -c '%a' "$ENV_FILE" 2>/dev/null)" != "600" ]]; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status failed --failure "Cloudflare env file permissions must be 0600: $ENV_FILE" + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "docker command is not available" + exit 0 +fi + +if ! command -v wrangler >/dev/null 2>&1 && ! command -v npx >/dev/null 2>&1; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Neither wrangler nor npx is available for Cloudflare live deploy" + exit 0 +fi + +cd "$MNEMON_R3_ROOT" +set +e +go run ./harness/cmd/mnemon-harness hub bootstrap cloudflare \ + --root "$WORKSPACE" \ + --env-file "$ENV_FILE" \ + --principal "cloudflare-docker@team" \ + --replica-id "cloudflare-docker" \ + --scope "memory/project" \ + --remote "cloudflare-docker" \ + --timeout "${MNEMON_CLOUDFLARE_BOOTSTRAP_TIMEOUT:-5m}" \ + >"$BOOTSTRAP_LOG" 2>&1 +BOOTSTRAP_STATUS=$? +set -e + +ENDPOINT="$(awk '/^Endpoint: / {print $2}' "$BOOTSTRAP_LOG" | tail -1)" +TOKEN_FILE="$WORKSPACE/.mnemon/harness/sync/credentials/cloudflare-docker.token" +TOKEN="" +if [[ -f "$TOKEN_FILE" ]]; then + TOKEN="$(tr -d '\r\n' < "$TOKEN_FILE")" +fi + +PROBE_STATUS=1 +if [[ "$BOOTSTRAP_STATUS" -eq 0 && -n "$ENDPOINT" && -n "$TOKEN" ]]; then + DECISION_ID="docker-cloudflare-$(date -u +"%Y%m%d%H%M%S")" + set +e + docker run --rm \ + -v "$MNEMON_R3_ROOT:/workspace:ro" \ + -v mnemon-r3-docker-gomodcache:/go/pkg/mod \ + -v mnemon-r3-docker-gocache:/root/.cache/go-build \ + -w /workspace \ + golang:1.24 \ + bash -c "go run ./harness/scripts/r3-1/docker/probe.go --mode push --endpoint '$ENDPOINT' --token '$TOKEN' --replica-id cloudflare-docker --decision-id '$DECISION_ID' --content 'Docker 到 Cloudflare Durable Object 的中文同步事件' && go run ./harness/scripts/r3-1/docker/probe.go --mode status --endpoint '$ENDPOINT' --token '$TOKEN' --replica-id cloudflare-docker" \ + >"$PROBE_LOG" 2>&1 + PROBE_STATUS=$? + set -e +fi + +python3 "$SCRIPT_DIR/verify.py" \ + --summary "$PHASE_DIR/summary.json" \ + --bootstrap-log "$BOOTSTRAP_LOG" \ + --bootstrap-exit "$BOOTSTRAP_STATUS" \ + --probe-log "$PROBE_LOG" \ + --probe-exit "$PROBE_STATUS" \ + --endpoint "$ENDPOINT" diff --git a/harness/scripts/r3-1/docker-cloudflare/verify.py b/harness/scripts/r3-1/docker-cloudflare/verify.py new file mode 100644 index 00000000..48629f89 --- /dev/null +++ b/harness/scripts/r3-1/docker-cloudflare/verify.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def read(path): + p = Path(path) + if not p.exists(): + return "" + return p.read_text(encoding="utf-8", errors="replace") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--bootstrap-log", required=True) + parser.add_argument("--bootstrap-exit", required=True, type=int) + parser.add_argument("--probe-log", required=True) + parser.add_argument("--probe-exit", required=True, type=int) + parser.add_argument("--endpoint", required=True) + args = parser.parse_args() + + bootstrap = read(args.bootstrap_log) + probe = read(args.probe_log) + logs = bootstrap + "\n" + probe + missing_workers_dev = "code: 10063" in bootstrap or "You need a workers.dev subdomain" in bootstrap + assertions = [ + {"name": "cloudflare bootstrap exited successfully", "passed": args.bootstrap_exit == 0}, + {"name": "bootstrap reported workers.dev endpoint", "passed": args.endpoint.startswith("https://") and args.endpoint.endswith(".workers.dev")}, + {"name": "docker probe pushed one event", "passed": args.probe_exit == 0 and '"accepted": 1' in probe}, + {"name": "docker probe status saw received events", "passed": args.probe_exit == 0 and '"received":' in probe}, + {"name": "logs do not contain Cloudflare API token literal", "passed": "cfat_" not in logs and "CLOUDFLARE_API_TOKEN=" not in logs}, + ] + skipped = [] + if missing_workers_dev: + skipped.append({ + "category": "skipped_missing_capability", + "name": "cloudflare workers.dev subdomain", + "detail": "Cloudflare API code 10063: account workers.dev subdomain is not enabled.", + }) + failures = [] + if not skipped and not all(item["passed"] for item in assertions): + failures.append({ + "category": "test_failure", + "name": "docker Cloudflare MnemonHub sync", + "detail": f"see {args.bootstrap_log} and {args.probe_log}", + }) + status = "skipped" if skipped else ("ok" if not failures else "failed") + summary = { + "schema_version": 1, + "phase": "docker-cloudflare", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": [ + {"name": "mnemon-harness hub bootstrap cloudflare", "status": "ok" if args.bootstrap_exit == 0 else "failed", "exit_code": args.bootstrap_exit, "log": args.bootstrap_log}, + {"name": "docker run golang:1.24 go run docker/probe.go", "status": "ok" if args.probe_exit == 0 else "failed", "exit_code": args.probe_exit, "log": args.probe_log}, + ], + "assertions": assertions, + "skipped": skipped, + "failures": failures, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + raise SystemExit(0 if status in {"ok", "skipped"} else 1) + + +if __name__ == "__main__": + main() diff --git a/harness/scripts/r3-1/docker-local/run.sh b/harness/scripts/r3-1/docker-local/run.sh index dc2959d3..80143365 100755 --- a/harness/scripts/r3-1/docker-local/run.sh +++ b/harness/scripts/r3-1/docker-local/run.sh @@ -1,7 +1,75 @@ #!/usr/bin/env bash set -uo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/common.sh" + PHASE="docker-local" PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" -python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Docker R3-1 compose fixture is pending" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +STATE_DIR="$PHASE_DIR/state" +REPLICA_DIR="$STATE_DIR/replicas" +mkdir -p "$REPLICA_DIR" +chmod 700 "$STATE_DIR" "$REPLICA_DIR" +printf 'token-a\n' > "$REPLICA_DIR/a.token" +printf 'token-b\n' > "$REPLICA_DIR/b.token" +chmod 600 "$REPLICA_DIR/a.token" "$REPLICA_DIR/b.token" +cat > "$REPLICA_DIR/replicas.json" <<'JSON' +{ + "schema_version": 1, + "replicas": [ + { + "principal": "replica-a@docker", + "credential_ref": "a.token", + "scopes": [{"kind": "memory", "id": "project"}] + }, + { + "principal": "replica-b@docker", + "credential_ref": "b.token", + "scopes": [{"kind": "memory", "id": "project"}] + } + ] +} +JSON +chmod 600 "$REPLICA_DIR/replicas.json" + +COMPOSE="$SCRIPT_DIR/../docker/compose.yml" +PROJECT="mnemon-r3-local-$(printf '%s' "${MNEMON_R3_RUN_ID:-manual}" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_-' '-')" +HUB_LOG="$PHASE_DIR/hub.log" +NODE_A_LOG="$PHASE_DIR/node-a.log" +NODE_B_LOG="$PHASE_DIR/node-b.log" +DOWN_LOG="$PHASE_DIR/compose-down.log" +export MNEMON_R3_DOCKER_WORK="$STATE_DIR" + +cd "$MNEMON_R3_ROOT" +set +e +docker compose -p "$PROJECT" -f "$COMPOSE" up -d hub >"$HUB_LOG" 2>&1 +UP_STATUS=$? +if [[ "$UP_STATUS" -eq 0 ]]; then + docker compose -p "$PROJECT" -f "$COMPOSE" run --rm node-a >"$NODE_A_LOG" 2>&1 + NODE_A_STATUS=$? +else + NODE_A_STATUS=1 +fi +if [[ "$UP_STATUS" -eq 0 && "$NODE_A_STATUS" -eq 0 ]]; then + docker compose -p "$PROJECT" -f "$COMPOSE" run --rm node-b >"$NODE_B_LOG" 2>&1 + NODE_B_STATUS=$? +else + NODE_B_STATUS=1 +fi +docker compose -p "$PROJECT" -f "$COMPOSE" logs hub >>"$HUB_LOG" 2>&1 +docker compose -p "$PROJECT" -f "$COMPOSE" down -v >"$DOWN_LOG" 2>&1 +DOWN_STATUS=$? +set -e + +python3 "$SCRIPT_DIR/verify.py" \ + --summary "$PHASE_DIR/summary.json" \ + --up-exit "$UP_STATUS" \ + --node-a-exit "$NODE_A_STATUS" \ + --node-b-exit "$NODE_B_STATUS" \ + --down-exit "$DOWN_STATUS" \ + --hub-log "$HUB_LOG" \ + --node-a-log "$NODE_A_LOG" \ + --node-b-log "$NODE_B_LOG" \ + --down-log "$DOWN_LOG" diff --git a/harness/scripts/r3-1/docker-local/verify.py b/harness/scripts/r3-1/docker-local/verify.py new file mode 100644 index 00000000..39256eb9 --- /dev/null +++ b/harness/scripts/r3-1/docker-local/verify.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from datetime import datetime, timezone +from pathlib import Path + + +def now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def read(path): + p = Path(path) + if not p.exists(): + return "" + return p.read_text(encoding="utf-8", errors="replace") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--up-exit", required=True, type=int) + parser.add_argument("--node-a-exit", required=True, type=int) + parser.add_argument("--node-b-exit", required=True, type=int) + parser.add_argument("--down-exit", required=True, type=int) + parser.add_argument("--hub-log", required=True) + parser.add_argument("--node-a-log", required=True) + parser.add_argument("--node-b-log", required=True) + parser.add_argument("--down-log", required=True) + args = parser.parse_args() + + hub = read(args.hub_log) + node_a = read(args.node_a_log) + node_b = read(args.node_b_log) + all_logs = "\n".join([hub, node_a, node_b, read(args.down_log)]) + assertions = [ + {"name": "docker compose started hub", "passed": args.up_exit == 0}, + {"name": "node-a pushed one synced event", "passed": args.node_a_exit == 0 and '"accepted": 1' in node_a}, + {"name": "node-b pulled at least one synced event", "passed": args.node_b_exit == 0 and '"events": 1' in node_b}, + {"name": "node-b status saw received event", "passed": args.node_b_exit == 0 and '"received": 1' in node_b}, + {"name": "hub audit saw push and pull", "passed": "verb=sync.push result=ok" in hub and "verb=sync.pull result=ok" in hub}, + {"name": "compose cleanup completed", "passed": args.down_exit == 0}, + {"name": "logs do not contain Cloudflare token literal", "passed": "cfat_" not in all_logs}, + ] + failures = [] + if not all(item["passed"] for item in assertions): + failures.append({ + "category": "test_failure", + "name": "docker local MnemonHub sync", + "detail": f"see {args.hub_log}, {args.node_a_log}, and {args.node_b_log}", + }) + status = "ok" if not failures else "failed" + summary = { + "schema_version": 1, + "phase": "docker-local", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": [ + {"name": "docker compose up -d hub", "status": "ok" if args.up_exit == 0 else "failed", "exit_code": args.up_exit, "log": args.hub_log}, + {"name": "docker compose run --rm node-a", "status": "ok" if args.node_a_exit == 0 else "failed", "exit_code": args.node_a_exit, "log": args.node_a_log}, + {"name": "docker compose run --rm node-b", "status": "ok" if args.node_b_exit == 0 else "failed", "exit_code": args.node_b_exit, "log": args.node_b_log}, + {"name": "docker compose down -v", "status": "ok" if args.down_exit == 0 else "failed", "exit_code": args.down_exit, "log": args.down_log}, + ], + "assertions": assertions, + "skipped": [], + "failures": failures, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + raise SystemExit(0 if status == "ok" else 1) + + +if __name__ == "__main__": + main() diff --git a/harness/scripts/r3-1/docker/compose.yml b/harness/scripts/r3-1/docker/compose.yml new file mode 100644 index 00000000..0a99b231 --- /dev/null +++ b/harness/scripts/r3-1/docker/compose.yml @@ -0,0 +1,58 @@ +services: + hub: + image: golang:1.24 + working_dir: /workspace + command: + - bash + - -c + - go run ./harness/cmd/mnemon-hub serve --addr 0.0.0.0:9787 --store /state/hub.db --replicas /state/replicas/replicas.json + volumes: + - ${MNEMON_R3_ROOT:-../../..}:/workspace:ro + - ${MNEMON_R3_DOCKER_WORK:?set MNEMON_R3_DOCKER_WORK}:/state + - gomodcache:/go/pkg/mod + - gocache:/root/.cache/go-build + ports: + - "9787" + + node-a: + image: golang:1.24 + working_dir: /workspace + profiles: ["run"] + command: + - go + - run + - ./harness/scripts/r3-1/docker/probe.go + - --mode + - push + - --endpoint + - http://hub:9787 + - --token + - token-a + - --replica-id + - local-a + - --decision-id + - docker-local-a + - --content + - "Docker 本地多节点同步: A 推送中文事件" + - --allow-insecure + volumes: + - ${MNEMON_R3_ROOT:-../../..}:/workspace:ro + - gomodcache:/go/pkg/mod + - gocache:/root/.cache/go-build + + node-b: + image: golang:1.24 + working_dir: /workspace + profiles: ["run"] + command: + - bash + - -c + - go run ./harness/scripts/r3-1/docker/probe.go --mode pull --endpoint http://hub:9787 --token token-b --replica-id local-b --allow-insecure && go run ./harness/scripts/r3-1/docker/probe.go --mode status --endpoint http://hub:9787 --token token-b --replica-id local-b --allow-insecure + volumes: + - ${MNEMON_R3_ROOT:-../../..}:/workspace:ro + - gomodcache:/go/pkg/mod + - gocache:/root/.cache/go-build + +volumes: + gomodcache: + gocache: diff --git a/harness/scripts/r3-1/docker/probe.go b/harness/scripts/r3-1/docker/probe.go new file mode 100644 index 00000000..3e87203e --- /dev/null +++ b/harness/scripts/r3-1/docker/probe.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + mode := flag.String("mode", "", "push, pull, or status") + endpoint := flag.String("endpoint", "", "MnemonHub endpoint") + token := flag.String("token", "", "replica bearer token") + replicaID := flag.String("replica-id", "", "local replica id") + decisionID := flag.String("decision-id", "docker-probe", "local decision id") + content := flag.String("content", "docker probe", "event content") + allowInsecure := flag.Bool("allow-insecure", false, "allow plaintext non-loopback endpoint for Docker local tests") + flag.Parse() + + if strings.TrimSpace(*mode) == "" || strings.TrimSpace(*endpoint) == "" || strings.TrimSpace(*token) == "" || strings.TrimSpace(*replicaID) == "" { + return fmt.Errorf("--mode, --endpoint, --token, and --replica-id are required") + } + client, err := access.NewSyncClient(*endpoint, access.SyncClientConfig{Token: *token, AllowInsecure: *allowInsecure}) + if err != nil { + return err + } + if err := waitStatus(client, 30*time.Second); err != nil { + return err + } + switch *mode { + case "push": + return push(client, *replicaID, *decisionID, *content) + case "pull": + return pull(client, *replicaID) + case "status": + return status(client) + default: + return fmt.Errorf("unknown --mode %q", *mode) + } +} + +func waitStatus(client *access.Client, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + var lastErr error + for ctx.Err() == nil { + if _, err := client.SyncStatus(); err == nil { + return nil + } else { + lastErr = err + } + time.Sleep(500 * time.Millisecond) + } + return fmt.Errorf("hub did not become ready: %w", lastErr) +} + +func push(client *access.Client, replicaID, decisionID, content string) error { + fields := map[string]any{"content": content} + fieldsJSON, _ := json.Marshal(fields) + sum := sha256.Sum256(fieldsJSON) + env, err := contract.SyncedEventEnvelopeFromMaterial(contract.SyncedEventMaterial{ + OriginReplicaID: replicaID, + LocalDecisionID: decisionID, + LocalIngestSeq: 1, + Actor: contract.ActorID(replicaID + "@docker"), + ResourceRef: contract.ResourceRef{Kind: "memory", ID: "project"}, + ResourceVersion: 1, + FieldsDigest: hex.EncodeToString(sum[:]), + Fields: fields, + DecidedAt: time.Now().UTC().Format(time.RFC3339), + Status: "pending", + }) + if err != nil { + return err + } + resp, err := client.SyncPush(contract.SyncPushRequest{ReplicaID: replicaID, BatchID: decisionID, Events: []eventmodel.EventEnvelope{env}}) + if err != nil { + return err + } + if len(resp.Accepted) != 1 || len(resp.Rejected) != 0 || len(resp.Conflicts) != 0 { + return fmt.Errorf("push mismatch: accepted=%d rejected=%d conflicts=%d %+v", len(resp.Accepted), len(resp.Rejected), len(resp.Conflicts), resp) + } + return printJSON(map[string]any{"mode": "push", "accepted": len(resp.Accepted), "next_cursor": resp.NextCursor}) +} + +func pull(client *access.Client, replicaID string) error { + resp, err := client.SyncPull(contract.SyncPullRequest{ReplicaID: replicaID}) + if err != nil { + return err + } + if len(resp.Events) < 1 { + return fmt.Errorf("pull returned no events") + } + return printJSON(map[string]any{"mode": "pull", "events": len(resp.Events), "next_cursor": resp.NextCursor}) +} + +func status(client *access.Client) error { + resp, err := client.SyncStatus() + if err != nil { + return err + } + if resp.HubEventsReceived < 1 { + return fmt.Errorf("status received count = %d, want >= 1", resp.HubEventsReceived) + } + return printJSON(map[string]any{"mode": "status", "received": resp.HubEventsReceived, "served": resp.HubEventsServed, "principal": resp.Principal}) +} + +func printJSON(value any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(value) +} diff --git a/harness/scripts/r3-1/live-multica/run.sh b/harness/scripts/r3-1/live-multica/run.sh index d8b40849..c7957183 100755 --- a/harness/scripts/r3-1/live-multica/run.sh +++ b/harness/scripts/r3-1/live-multica/run.sh @@ -1,7 +1,159 @@ #!/usr/bin/env bash set -uo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/common.sh" + PHASE="live-multica" PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" -python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Live Multica validation is pending R3-1 runtime setup" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +PROFILE="${MNEMON_MULTICA_PROFILE:-desktop-api.multica.ai}" +WORKSPACE_ID="${MNEMON_MULTICA_WORKSPACE_ID:-}" +DAEMON_LOG="$PHASE_DIR/daemon-status.json" +RUNTIMES_LOG="$PHASE_DIR/runtimes.json" +AGENTS_LOG="$PHASE_DIR/agents.json" +CREATE_LOG="$PHASE_DIR/issue-create.json" +RUNS_LOG="$PHASE_DIR/issue-runs.json" +MESSAGES_LOG="$PHASE_DIR/run-messages.json" +META_LOG="$PHASE_DIR/live-meta.json" + +if ! command -v multica >/dev/null 2>&1; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "multica CLI is not available" + exit 0 +fi + +set +e +multica --profile "$PROFILE" daemon status --output json >"$DAEMON_LOG" 2>&1 +DAEMON_STATUS=$? +set -e +if [[ "$DAEMON_STATUS" -ne 0 ]]; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Multica daemon/profile is not ready; see $DAEMON_LOG" + exit 0 +fi + +if [[ -z "$WORKSPACE_ID" ]]; then + WORKSPACE_ID="$(python3 - "$DAEMON_LOG" <<'PY' +import json, sys +data = json.load(open(sys.argv[1], encoding="utf-8")) +workspaces = data.get("workspaces") or [] +print((workspaces[0] or {}).get("id", "") if workspaces else "") +PY +)" +fi +if [[ -z "$WORKSPACE_ID" ]]; then + python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Multica daemon did not report a workspace id" + exit 0 +fi + +set +e +multica --profile "$PROFILE" --workspace-id "$WORKSPACE_ID" runtime list --output json >"$RUNTIMES_LOG" 2>&1 +RUNTIMES_STATUS=$? +multica --profile "$PROFILE" --workspace-id "$WORKSPACE_ID" agent list --output json >"$AGENTS_LOG" 2>&1 +AGENTS_STATUS=$? +set -e + +RUNTIME_ID="" +AGENT_NAME="" +if [[ "$RUNTIMES_STATUS" -eq 0 && "$AGENTS_STATUS" -eq 0 ]]; then + RUNTIME_ID="$(python3 - "$RUNTIMES_LOG" <<'PY' +import json, sys +items = json.load(open(sys.argv[1], encoding="utf-8")) +for item in items: + if item.get("status") == "online" and "mnemon-runtime" in str(item.get("name", "")): + print(item.get("id", "")) + break +PY +)" + AGENT_NAME="$(python3 - "$AGENTS_LOG" "$RUNTIME_ID" <<'PY' +import json, sys +items = json.load(open(sys.argv[1], encoding="utf-8")) +runtime_id = sys.argv[2] +for item in items: + if item.get("name") == "mnemon-planner" and (not runtime_id or item.get("runtime_id") == runtime_id): + print(item.get("name", "")) + break +PY +)" +fi + +CREATE_STATUS=1 +RUNS_STATUS=1 +MESSAGES_STATUS=1 +ISSUE_ID="" +TASK_ID="" +if [[ -n "$RUNTIME_ID" && -n "$AGENT_NAME" ]]; then + TITLE="R3-1 live Multica runtime smoke ${MNEMON_R3_RUN_ID:-manual}" + DESCRIPTION="中文 live smoke:验证 mnemon-multica-runtime 在 Multica 中以 codex app-server 形态在线,并能通过 issue 分配触发。" + set +e + multica --profile "$PROFILE" --workspace-id "$WORKSPACE_ID" issue create \ + --title "$TITLE" \ + --description "$DESCRIPTION" \ + --assignee "$AGENT_NAME" \ + --status todo \ + --priority medium \ + --output json >"$CREATE_LOG" 2>&1 + CREATE_STATUS=$? + set -e + if [[ "$CREATE_STATUS" -eq 0 ]]; then + ISSUE_ID="$(python3 - "$CREATE_LOG" <<'PY' +import json, sys +data = json.load(open(sys.argv[1], encoding="utf-8")) +print(data.get("id", "")) +PY +)" + fi +fi + +if [[ -n "$ISSUE_ID" ]]; then + for _ in $(seq 1 18); do + set +e + multica --profile "$PROFILE" --workspace-id "$WORKSPACE_ID" issue runs "$ISSUE_ID" --output json >"$RUNS_LOG" 2>&1 + RUNS_STATUS=$? + set -e + if [[ "$RUNS_STATUS" -eq 0 ]]; then + TASK_ID="$(python3 - "$RUNS_LOG" <<'PY' +import json, sys +data = json.load(open(sys.argv[1], encoding="utf-8")) +if isinstance(data, dict): + data = data.get("runs") or data.get("items") or [] +if data: + first = data[0] + print(first.get("id") or first.get("task_id") or "") +PY +)" + [[ -n "$TASK_ID" ]] && break + fi + sleep 5 + done +fi + +if [[ -n "$TASK_ID" ]]; then + set +e + multica --profile "$PROFILE" --workspace-id "$WORKSPACE_ID" issue run-messages "$TASK_ID" --output json >"$MESSAGES_LOG" 2>&1 + MESSAGES_STATUS=$? + set -e +fi + +python3 - "$META_LOG" <") + skip_next = False + continue + out.append(item) + if item in {"--token", "--mnemon-control-token"}: + skip_next = True + return out + + +def load_json_text(text, fallback): + try: + return json.loads(text) + except Exception: + return fallback + + +def workspace_from_daemon(profile, out_dir, commands): + proc = subprocess.run(["multica", "--profile", profile, "daemon", "status", "--output", "json"], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + log = Path(out_dir) / "00-daemon-status.log" + log.write_text(proc.stdout + proc.stderr, encoding="utf-8") + commands.append({"name": "multica --profile daemon status --output json", "status": "ok" if proc.returncode == 0 else "failed", "exit_code": proc.returncode, "log": str(log)}) + if proc.returncode != 0: + return "", proc + data = load_json_text(proc.stdout, {}) + workspaces = data.get("workspaces") or [] + if not workspaces: + return "", proc + return (workspaces[0] or {}).get("id", ""), proc + + +def issue_id(proc): + data = load_json_text(proc.stdout, {}) + return data.get("id", "") + + +def first_run_id(text): + data = load_json_text(text, []) + if isinstance(data, dict): + data = data.get("runs") or data.get("items") or [] + if not data: + return "" + return data[0].get("id") or data[0].get("task_id") or "" + + +def create_issue(runner, title, description, assignee="", parent=""): + args = ["issue", "create", "--title", title, "--description", description, "--status", "todo", "--priority", "medium", "--output", "json"] + if assignee: + args += ["--assignee", assignee] + if parent: + args += ["--parent", parent] + proc, _ = runner.multica("issue-create", *args) + if proc.returncode != 0: + return "" + return issue_id(proc) + + +def add_comment(runner, issue, body): + proc, _ = runner.multica("issue-comment", "issue", "comment", "add", issue, "--content-stdin", "--output", "json", stdin=body) + return proc.returncode == 0 + + +def poll_run(runner, issue, attempts=18, delay=5): + last = "" + for _ in range(attempts): + proc, _ = runner.multica("issue-runs", "issue", "runs", issue, "--output", "json") + last = proc.stdout + if proc.returncode == 0: + rid = first_run_id(proc.stdout) + if rid: + return rid + time.sleep(delay) + return first_run_id(last) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--summary", required=True) + parser.add_argument("--out-dir", required=True) + parser.add_argument("--profile", required=True) + parser.add_argument("--workspace-id", default="") + parser.add_argument("--run-id", required=True) + args = parser.parse_args() + + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + commands = [] + workspace_id = args.workspace_id + if not workspace_id: + workspace_id, daemon_proc = workspace_from_daemon(args.profile, out_dir, commands) + if daemon_proc.returncode != 0: + return write_summary(args, commands, [], [], [], skipped="Multica daemon/profile is not ready") + if not workspace_id: + return write_summary(args, commands, [], [], [], skipped="Multica workspace id is unavailable") + + runner = Runner(out_dir, args.profile, workspace_id) + runner.commands.extend(commands) + agents_proc, _ = runner.multica("agent-list", "agent", "list", "--output", "json") + if agents_proc.returncode != 0: + return write_summary(args, runner.commands, [], [], [], failed="agent list failed") + agents = {item.get("name"): item for item in load_json_text(agents_proc.stdout, [])} + missing_agents = [name for name in REQUIRED_AGENTS if name not in agents] + if missing_agents: + return write_summary(args, runner.commands, [], [], [], failed="missing agents: " + ", ".join(missing_agents)) + + suffix = args.run_id.replace("T", "").replace("Z", "") + session_title = f"中文验收-R3-1并发PoC上下文复用 {suffix}" + session_desc = """背景: +本次验收模拟真实 OA 协作:同一发布窗口同时影响库存、退款风控和会员补偿。 + +共享上下文: +- ctx:发布窗口=最近一次发布同时影响库存同步、退款规则、会员补偿。 +- ctx:风险登记=重复补偿、库存超卖、误拒退款、客服承诺不一致。 +- ctx:证据索引=日志片段、指标快照、手工核对表、回滚预案。 + +验收意图: +触发多个 PoC、共享上下文复用、二轮 follow-up 和 integrator 汇总。""" + session = create_issue(runner, session_title, session_desc) + if not session: + return write_summary(args, runner.commands, [], [], [], failed="session root create failed") + + shared_context_comment = """Mnemon 更新: 共享上下文 + +## 状态 + +display-only + +## 摘要 + +ctx:发布窗口、ctx:风险登记、ctx:证据索引、ctx:运行手册在三个 PoC 中复用。该评论不触发 provider 执行,只作为 OA 可见上下文。 + +## 事件引用 + +event:zh-parallel-poc-overlap/shared-context""" + add_comment(runner, session, shared_context_comment) + + poc_specs = [ + ("PoC-A", "华东仓库存偏差排查", [("mnemon-researcher", "库存同步链路与差异样本核验"), ("mnemon-reviewer", "库存超卖风险与回滚条件复核")]), + ("PoC-B", "退款风控误杀排查", [("mnemon-researcher", "风控规则命中样本与误杀比例核验"), ("mnemon-implementer", "退款白名单临时运行手册")]), + ("PoC-C", "会员权益补偿与客服口径", [("mnemon-implementer", "补偿脚本与重复补偿保护"), ("mnemon-reviewer", "客服承诺边界与发布风险复核")]), + ] + created = [{"kind": "session", "id": session, "title": session_title}] + run_targets = [] + for poc_id, title, children in poc_specs: + root_title = f"中文验收-{poc_id}/{title} {suffix}" + root_desc = f"""任务目标: +围绕 {title} 完成第一轮 PoC 判断。 + +共享上下文: +引用 ctx:发布窗口、ctx:风险登记、ctx:证据索引。 + +要求: +planner 需要拆解证据、识别与其他 PoC 的重叠依赖,并避免重复创建 shared context。""" + root_id = create_issue(runner, root_title, root_desc, "mnemon-planner", session) + if not root_id: + return write_summary(args, runner.commands, created, run_targets, [], failed=f"{poc_id} root create failed") + created.append({"kind": "poc-root", "poc_id": poc_id, "id": root_id, "title": root_title}) + run_targets.append(root_id) + add_comment(runner, root_id, f"Mnemon 更新: {poc_id} 共享上下文引用\n\n## 摘要\n\n复用 ctx:发布窗口 与 ctx:风险登记,禁止复制成新的 canonical context。\n\n## 事件引用\n\nevent:{poc_id}/shared-context-ref") + for agent, child_title in children: + full_title = f"中文验收-{poc_id}/{child_title} {suffix}" + desc = f"""任务目标: +{child_title} + +共享上下文: +- ctx:发布窗口 +- ctx:风险登记 +- ctx:证据索引 + +本角色产出: +请给出中文结构化进展、证据、风险、下一步。不要直接判定最终结论。 + +回写提示: +accepted 后通过 Mnemon display writeback 写 Multica comment/metadata。""" + child_id = create_issue(runner, full_title, desc, agent, root_id) + if not child_id: + return write_summary(args, runner.commands, created, run_targets, [], failed=f"{poc_id} child create failed") + created.append({"kind": "assignment", "poc_id": poc_id, "id": child_id, "title": full_title, "agent": agent}) + run_targets.append(child_id) + + follow_title = f"中文验收-follow-up/跨PoC证据冲突对齐 {suffix}" + follow_desc = """Round 2 follow-up: +请对齐 PoC-A 库存差异、PoC-B 退款误杀、PoC-C 会员补偿之间的共享发布窗口。 + +必须引用: +- ctx:发布窗口 +- ctx:风险登记 +- 三个 PoC 第一轮 issue + +目标: +给出继续观察、局部补偿、灰度回滚和客服统一口径的条件。""" + follow_id = create_issue(runner, follow_title, follow_desc, "mnemon-integrator", session) + if not follow_id: + return write_summary(args, runner.commands, created, run_targets, [], failed="follow-up create failed") + created.append({"kind": "follow-up", "id": follow_id, "title": follow_title, "agent": "mnemon-integrator"}) + run_targets.append(follow_id) + + run_ids = [] + for issue in run_targets: + rid = poll_run(runner, issue, attempts=6, delay=5) + if rid: + run_ids.append({"issue_id": issue, "run_id": rid}) + + return write_summary(args, runner.commands, created, run_targets, run_ids) + + +def write_summary(args, commands, created, run_targets, run_ids, skipped="", failed=""): + logs = "\n".join(Path(cmd["log"]).read_text(encoding="utf-8", errors="replace") for cmd in commands if Path(cmd["log"]).exists()) + assertions = [ + {"name": "created session root plus three PoC roots", "passed": len([x for x in created if x.get("kind") == "session"]) == 1 and len([x for x in created if x.get("kind") == "poc-root"]) == 3}, + {"name": "created at least six role assignments", "passed": len([x for x in created if x.get("kind") == "assignment"]) >= 6}, + {"name": "created follow-up issue for second round", "passed": any(x.get("kind") == "follow-up" for x in created)}, + {"name": "at least three assigned issues produced runs", "passed": len(run_ids) >= 3}, + {"name": "multiple PoCs share context comments", "passed": "ctx:发布窗口" in logs and "ctx:风险登记" in logs}, + {"name": "logs do not expose Multica token literal", "passed": "mul_" not in logs}, + ] + failures = [] + skipped_items = [] + if skipped: + skipped_items.append({"category": "skipped_missing_capability", "detail": skipped}) + if failed: + failures.append({"category": "live_failure", "name": "zh complex Multica case", "detail": failed}) + if not skipped and not failed and not all(item["passed"] for item in assertions): + failures.append({"category": "acceptance_failure", "name": "zh complex Multica case assertions", "detail": "one or more Chinese complex case assertions failed"}) + status = "skipped" if skipped_items else ("ok" if not failures else "failed") + summary = { + "schema_version": 1, + "phase": "zh-complex-cases", + "status": status, + "started_at": os.environ.get("MNEMON_R3_PHASE_STARTED_AT") or now(), + "finished_at": now(), + "commands": commands, + "assertions": assertions, + "skipped": skipped_items, + "failures": failures, + "created": created, + "run_targets": run_targets, + "runs": run_ids, + } + Path(args.summary).parent.mkdir(parents=True, exist_ok=True) + Path(args.summary).write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + return 0 if status in {"ok", "skipped"} else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/scripts/r3-1/zh-complex-cases/run.sh b/harness/scripts/r3-1/zh-complex-cases/run.sh index a91d3390..9b570c6c 100755 --- a/harness/scripts/r3-1/zh-complex-cases/run.sh +++ b/harness/scripts/r3-1/zh-complex-cases/run.sh @@ -1,7 +1,16 @@ #!/usr/bin/env bash set -uo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/common.sh" + PHASE="zh-complex-cases" PHASE_DIR="$(mnemon_r3_prepare_phase "$PHASE")" -python3 "$SCRIPT_DIR/../lib/report.py" --summary "$PHASE_DIR/summary.json" --phase "$PHASE" --status skipped --skip "Chinese complex case runner is pending Docker/Multica implementation" +export MNEMON_R3_PHASE_STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +python3 "$SCRIPT_DIR/run.py" \ + --summary "$PHASE_DIR/summary.json" \ + --out-dir "$PHASE_DIR" \ + --profile "${MNEMON_MULTICA_PROFILE:-desktop-api.multica.ai}" \ + --workspace-id "${MNEMON_MULTICA_WORKSPACE_ID:-}" \ + --run-id "${MNEMON_R3_RUN_ID:-manual}" From d5505ad8829714314ace1a8cda21acbab35f6c09 Mon Sep 17 00:00:00 2001 From: Grivn Date: Thu, 2 Jul 2026 03:30:50 +0800 Subject: [PATCH 9/9] test: run Chinese Multica case through Docker Route the Chinese complex R3-1 live case through a containerized Multica CLI by default, while keeping host mode available for explicit local debugging. The phase summary now records the execution mode and asserts that Docker created the Multica-visible case path. Validation: python3 -m py_compile harness/scripts/r3-1/zh-complex-cases/run.py harness/scripts/r3-1/*/verify.py harness/scripts/r3-1/lib/report.py; harness/scripts/r3-1/run.sh --phase zh-complex-cases; harness/scripts/r3-1/run.sh --full. --- harness/scripts/r3-1/zh-complex-cases/run.py | 58 +++++++++++++++++++- harness/scripts/r3-1/zh-complex-cases/run.sh | 2 + 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/harness/scripts/r3-1/zh-complex-cases/run.py b/harness/scripts/r3-1/zh-complex-cases/run.py index 26a0cde0..61f2efd2 100644 --- a/harness/scripts/r3-1/zh-complex-cases/run.py +++ b/harness/scripts/r3-1/zh-complex-cases/run.py @@ -2,6 +2,7 @@ import argparse import json import os +import shutil import subprocess import sys import time @@ -23,10 +24,15 @@ def now(): class Runner: - def __init__(self, out_dir, profile, workspace_id): + def __init__(self, out_dir, profile, workspace_id, multica_mode, multica_source): self.out_dir = Path(out_dir) self.profile = profile self.workspace_id = workspace_id + self.multica_mode = multica_mode + self.multica_source = Path(multica_source) if multica_source else Path("") + self.docker_image = os.environ.get("MNEMON_R3_ZH_MULTICA_DOCKER_IMAGE", "golang:1.24") + self.go_mod_cache = self.out_dir / "go-mod-cache" + self.go_build_cache = self.out_dir / "go-build-cache" self.commands = [] self.seq = 0 @@ -44,11 +50,46 @@ def run(self, label, args, stdin=""): return proc, log def multica(self, label, *args, stdin=""): - base = ["multica", "--profile", self.profile] + base = self.multica_base() + base += ["--profile", self.profile] if self.workspace_id: base += ["--workspace-id", self.workspace_id] return self.run(label, base + list(args), stdin=stdin) + def multica_base(self): + if self.multica_mode == "host": + return ["multica"] + self.go_mod_cache.mkdir(parents=True, exist_ok=True) + self.go_build_cache.mkdir(parents=True, exist_ok=True) + return [ + "docker", "run", "--rm", "-i", + "-e", "GOTOOLCHAIN=auto", + "-v", f"{self.multica_source}:/multica-server:ro", + "-v", f"{Path.home() / '.multica'}:/root/.multica:ro", + "-v", f"{self.go_mod_cache}:/go/pkg/mod", + "-v", f"{self.go_build_cache}:/root/.cache/go-build", + "-w", "/multica-server", + self.docker_image, + "go", "run", "./cmd/multica", + ] + + def readiness_error(self): + if self.multica_mode == "host": + if not shutil.which("multica"): + return "multica CLI is unavailable" + return "" + if self.multica_mode != "docker": + return f"unsupported multica mode: {self.multica_mode}" + if not shutil.which("docker"): + return "docker CLI is unavailable" + if not self.multica_source.exists(): + return f"Multica source directory is unavailable: {self.multica_source}" + if not (self.multica_source / "go.mod").exists(): + return f"Multica source directory does not contain go.mod: {self.multica_source}" + if not (Path.home() / ".multica").exists(): + return "~/.multica profile directory is unavailable" + return "" + def redact_args(args): out = [] @@ -135,6 +176,8 @@ def main(): parser.add_argument("--out-dir", required=True) parser.add_argument("--profile", required=True) parser.add_argument("--workspace-id", default="") + parser.add_argument("--multica-mode", choices=["docker", "host"], default="docker") + parser.add_argument("--multica-source", default="") parser.add_argument("--run-id", required=True) args = parser.parse_args() @@ -149,8 +192,11 @@ def main(): if not workspace_id: return write_summary(args, commands, [], [], [], skipped="Multica workspace id is unavailable") - runner = Runner(out_dir, args.profile, workspace_id) + runner = Runner(out_dir, args.profile, workspace_id, args.multica_mode, args.multica_source) runner.commands.extend(commands) + readiness_error = runner.readiness_error() + if readiness_error: + return write_summary(args, runner.commands, [], [], [], skipped=readiness_error) agents_proc, _ = runner.multica("agent-list", "agent", "list", "--output", "json") if agents_proc.returncode != 0: return write_summary(args, runner.commands, [], [], [], failed="agent list failed") @@ -262,12 +308,14 @@ def main(): def write_summary(args, commands, created, run_targets, run_ids, skipped="", failed=""): logs = "\n".join(Path(cmd["log"]).read_text(encoding="utf-8", errors="replace") for cmd in commands if Path(cmd["log"]).exists()) + docker_mode = args.multica_mode == "docker" assertions = [ {"name": "created session root plus three PoC roots", "passed": len([x for x in created if x.get("kind") == "session"]) == 1 and len([x for x in created if x.get("kind") == "poc-root"]) == 3}, {"name": "created at least six role assignments", "passed": len([x for x in created if x.get("kind") == "assignment"]) >= 6}, {"name": "created follow-up issue for second round", "passed": any(x.get("kind") == "follow-up" for x in created)}, {"name": "at least three assigned issues produced runs", "passed": len(run_ids) >= 3}, {"name": "multiple PoCs share context comments", "passed": "ctx:发布窗口" in logs and "ctx:风险登记" in logs}, + {"name": "Dockerized Multica CLI executed visible case", "passed": (not docker_mode) or any((cmd.get("name") or "").startswith("docker run") for cmd in commands)}, {"name": "logs do not expose Multica token literal", "passed": "mul_" not in logs}, ] failures = [] @@ -289,6 +337,10 @@ def write_summary(args, commands, created, run_targets, run_ids, skipped="", fai "assertions": assertions, "skipped": skipped_items, "failures": failures, + "metadata": { + "multica_mode": args.multica_mode, + "multica_source": args.multica_source, + }, "created": created, "run_targets": run_targets, "runs": run_ids, diff --git a/harness/scripts/r3-1/zh-complex-cases/run.sh b/harness/scripts/r3-1/zh-complex-cases/run.sh index 9b570c6c..9a3a6ea2 100755 --- a/harness/scripts/r3-1/zh-complex-cases/run.sh +++ b/harness/scripts/r3-1/zh-complex-cases/run.sh @@ -13,4 +13,6 @@ python3 "$SCRIPT_DIR/run.py" \ --out-dir "$PHASE_DIR" \ --profile "${MNEMON_MULTICA_PROFILE:-desktop-api.multica.ai}" \ --workspace-id "${MNEMON_MULTICA_WORKSPACE_ID:-}" \ + --multica-mode "${MNEMON_R3_ZH_MULTICA_MODE:-docker}" \ + --multica-source "${MNEMON_MULTICA_SOURCE_DIR:-/Users/grivn/go/src/github.com/multica-ai/multica/server}" \ --run-id "${MNEMON_R3_RUN_ID:-manual}"