forked from jason/echo
b3bc5272d5
Config schema {owner,endpoint,key} -> {group, member, endpoint, key}:
- member (kebab-case slug, validated) is REQUIRED — who this machine
writes as; group is the descriptive team name. CHORUS_GROUP/
CHORUS_MEMBER env, config set --group/--member, doctor + config show
both with sources, NOT-CONFIGURED (78) without a valid member.
- capture stamps author: <member> on every note (_build_note); bootstrap
gains {{MEMBER}} substitution and stamps the seeded anchors; all 8
scaffold templates + canonical frontmatter docs gain author:.
- New missing-author lint check: agent_written notes must carry author:
(bootstrap marker exempt — plugin-owned).
- Lock owner is now <member>-<client>-<pid> (auto_owner); offline-queue
records carry a member field for audit.
- build.py --bake-key bakes group/member/endpoint/key (--group/--member;
member required + slug-validated) for per-member artifacts.
- Manifest -> 2.0.0-alpha.2; rebuilt chorus-memory.plugin.
Verified: 25/25 unit (+config/member checks), scaffold suite (+6 new
member/config assertions), routing-sync, 4 mock e2e (+capture-stamps-
author), run_eval metrics unchanged, +8 phase-2 smoke checks green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
127 lines
5.5 KiB
Python
127 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""test_offline_queue.py — H2: writes survive an outage; cold start degrades to cache.
|
|
|
|
Phase 1 — vault DOWN: `put` / `append` are queued (exit 0, "queued"), not lost.
|
|
Phase 2 — vault UP: `flush` replays them idempotently; the writes land.
|
|
Phase 3 — vault UP: `load` caches the orientation reads.
|
|
Phase 4 — vault DOWN: `load` serves the last-known-good cache and says OFFLINE.
|
|
|
|
Drives the real chorus.py against eval/mock_olrapi.py. No creds, no live vault.
|
|
Run: python test_offline_queue.py [--port 8840]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
CHORUS = HERE.parent / "chorus-memory.plugin.src" / "skills" / "chorus-memory" / "scripts" / "chorus.py"
|
|
KEY = "test-key-not-a-real-secret"
|
|
DEAD = "http://127.0.0.1:59998" # nothing listens here -> connection refused
|
|
|
|
failures = []
|
|
|
|
|
|
def check(name, cond, detail=""):
|
|
print(f"{'ok ' if cond else 'FAIL'} {name}" + (f" -- {detail}" if not cond else ""))
|
|
if not cond:
|
|
failures.append(name)
|
|
|
|
|
|
def http(method, url, body=None):
|
|
data = body.encode() if isinstance(body, str) else body
|
|
req = urllib.request.Request(url, data=data, method=method,
|
|
headers={"Authorization": f"Bearer {KEY}"})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as r:
|
|
return r.status, r.read().decode("utf-8", "replace")
|
|
except Exception as e: # noqa: BLE001
|
|
return getattr(e, "code", 0), ""
|
|
|
|
|
|
def tmp(content):
|
|
f = tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8")
|
|
f.write(content); f.close()
|
|
return f.name
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--port", type=int, default=8840)
|
|
a = ap.parse_args()
|
|
mock_base = f"http://127.0.0.1:{a.port}"
|
|
state = tempfile.mkdtemp()
|
|
|
|
def chorus(base, *args):
|
|
env = dict(os.environ, CHORUS_BASE=base, CHORUS_KEY=KEY, CHORUS_MEMBER="eval-member", CHORUS_STATE_DIR=state,
|
|
CHORUS_VERIFY="0", CHORUS_TODAY="2026-06-22")
|
|
return subprocess.run([sys.executable, str(CHORUS), *args], capture_output=True, text=True, env=env)
|
|
|
|
def start_mock():
|
|
p = subprocess.Popen([sys.executable, str(HERE / "mock_olrapi.py"), "--port", str(a.port)],
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
for _ in range(50):
|
|
try:
|
|
urllib.request.urlopen(f"{mock_base}/__debug__reset", data=b"", timeout=1); break
|
|
except Exception:
|
|
time.sleep(0.1)
|
|
return p
|
|
|
|
def ground(path):
|
|
_, body = http("GET", f"{mock_base}/__debug__?path={path}")
|
|
return None if body == "<<MISSING>>" else body
|
|
|
|
srv = None
|
|
try:
|
|
# --- Phase 1: vault DOWN -> writes are queued, not lost ------------------
|
|
r = chorus(DEAD, "put", "_agent/sessions/2026-06-22-1200-x.md", tmp("EVALMARK-session\n"))
|
|
check("offline put exits 0 (queued)", r.returncode == 0, r.stderr)
|
|
check("offline put reports queued", "queued (offline)" in r.stdout, r.stdout)
|
|
r = chorus(DEAD, "append", "inbox/captures/inbox.md", "- 2026-06-22: EVALMARK-inbox")
|
|
check("offline append reports queued", "queued (offline)" in r.stdout, r.stdout)
|
|
outbox = Path(state) / "outbox.ndjson"
|
|
check("two writes are persisted to the outbox",
|
|
outbox.exists() and len(outbox.read_text(encoding="utf-8").splitlines()) == 2)
|
|
|
|
# --- Phase 2: vault UP -> flush replays them -----------------------------
|
|
srv = start_mock()
|
|
r = chorus(mock_base, "flush")
|
|
check("flush replays the queue", "flushed 2" in r.stdout, r.stdout + r.stderr)
|
|
check("queued PUT landed in the vault", (ground("_agent/sessions/2026-06-22-1200-x.md") or "").find("EVALMARK-session") >= 0)
|
|
check("queued APPEND landed in the vault", "EVALMARK-inbox" in (ground("inbox/captures/inbox.md") or ""))
|
|
check("outbox is empty after a clean flush", not outbox.exists() or not outbox.read_text(encoding="utf-8").strip())
|
|
|
|
# idempotent replay: a second flush of the same content must not duplicate.
|
|
chorus(DEAD, "append", "inbox/captures/inbox.md", "- 2026-06-22: EVALMARK-inbox") # re-queue same line
|
|
chorus(mock_base, "flush")
|
|
inbox = ground("inbox/captures/inbox.md") or ""
|
|
check("replay is idempotent (no duplicate line)", inbox.count("EVALMARK-inbox") == 1, inbox)
|
|
|
|
# --- Phase 3: vault UP -> load caches the orientation reads ---------------
|
|
http("PUT", f"{mock_base}/vault/_agent/chorus-vault.md", "schema_version: 4\nEVALMARK-marker\n")
|
|
r = chorus(mock_base, "load")
|
|
check("online load shows marker", "EVALMARK-marker" in r.stdout, r.stdout)
|
|
check("load wrote a cache dir", (Path(state) / "cache").exists())
|
|
|
|
# --- Phase 4: vault DOWN -> load serves the cache ------------------------
|
|
srv.terminate(); srv.wait(timeout=5); srv = None
|
|
r = chorus(DEAD, "load")
|
|
check("offline load flags OFFLINE", "OFFLINE" in r.stdout, r.stdout)
|
|
check("offline load serves cached marker", "EVALMARK-marker" in r.stdout, r.stdout)
|
|
|
|
print(f"\n{len(failures)} failure(s)" if failures else "\nall offline-queue tests passed")
|
|
return 1 if failures else 0
|
|
finally:
|
|
if srv:
|
|
srv.terminate()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|