From 46a13cae70f15f9444588250d4d8f47c49d68b1d Mon Sep 17 00:00:00 2001 From: forthfate Date: Sat, 12 Sep 2026 18:12:32 +0900 Subject: [PATCH] feat: add autonomous persona journey template --- .../autonomous-persona-journey.py | 284 ++++++++++++++++++ .../playwright-continuous-journey.json | 5 + .../playwright-continuous-journey.py | 135 +++++++++ .../selenium-external-journey.json | 5 + runner-templates/selenium-external-journey.py | 104 +++++++ runner-templates/tailwind-source-aware.json | 5 + runner-templates/tailwind-source-aware.py | 86 ++++++ 7 files changed, 624 insertions(+) create mode 100644 runner-templates/autonomous-persona-journey.py create mode 100644 runner-templates/playwright-continuous-journey.json create mode 100644 runner-templates/playwright-continuous-journey.py create mode 100644 runner-templates/selenium-external-journey.json create mode 100644 runner-templates/selenium-external-journey.py create mode 100644 runner-templates/tailwind-source-aware.json create mode 100644 runner-templates/tailwind-source-aware.py diff --git a/runner-templates/autonomous-persona-journey.py b/runner-templates/autonomous-persona-journey.py new file mode 100644 index 0000000..af0e577 --- /dev/null +++ b/runner-templates/autonomous-persona-journey.py @@ -0,0 +1,284 @@ +"""A safe, stateful persona that chooses one evidence-backed browser action at a time.""" + +import json +import re +import subprocess +from pathlib import Path + +import orbit_sdk +from orbit_sdk import graph, runner + +BLOCKED = re.compile( + r"logout|signout|delete|remove|destroy|payment|checkout|purchase|upgrade|unsubscribe", re.I +) +MAX_HISTORY, MAX_LEARNINGS, MAX_ISSUES = 24, 12, 8 + +graph.connect("validate", "observe") +graph.connect("observe", "decide", kind="data", label="rendered choices") +graph.connect("decide", "act", kind="data", label="one safe action") +graph.connect("act", "reflect", kind="data", label="action evidence") +graph.connect("reflect", "observe", kind="loop", label="next visit") + + +def state_path(ctx): + slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", str(ctx.build.get("id") or "persona")) + directory = ctx.app_data / "autonomous-personas" + directory.mkdir(parents=True, exist_ok=True) + return directory / f"{slug}.json" + + +def load_state(ctx): + if state_path(ctx).exists(): + return json.loads(state_path(ctx).read_text(encoding="utf-8")) + case = ctx.test_cases[0] + return { + "persona": str(case.get("name") or "A careful product user"), + "current_goal": str(case.get("prompt") or "Understand the product through safe visits."), + "feeling": "curious", + "next_intent": "Orient myself on the first visible page.", + "learnings": [], + "reported_issues": [], + "history": [], + } + + +def save_state(ctx, state): + state["learnings"] = list(state.get("learnings", []))[-MAX_LEARNINGS:] + state["reported_issues"] = list(state.get("reported_issues", []))[-MAX_ISSUES:] + state["history"] = list(state.get("history", []))[-MAX_HISTORY:] + state_path(ctx).write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + + +def model_json(ctx, prompt): + """Use the configured model, but retain only a strict JSON decision record.""" + response = ctx.complete_model(prompt)["response"] + try: + value = json.loads(response) + except json.JSONDecodeError as error: + raise ValueError("persona model must return one JSON object") from error + if not isinstance(value, dict): + raise ValueError("persona model response must be a JSON object") + return value + + +def browser(ctx, *, url, screenshot, allowed_href=None): + """Observe or follow exactly one pre-approved same-origin, non-destructive link.""" + module = str(Path(orbit_sdk.__file__).resolve().parents[1] / "frontend" / "node_modules" / "playwright") + artifacts = ( + ctx.app_data / "artifacts" / ctx.environment.get("ORBIT_RUN_ID", "manual") / f"loop-{ctx.loop_index}" + ) + artifacts.mkdir(parents=True, exist_ok=True) + executable = str(ctx.build.get("browser_executable_path") or "").strip() + payload = { + "url": url, + "screenshot": str(artifacts / screenshot), + "allowedHref": allowed_href, + "executablePath": executable, + } + script = r"""const { chromium } = require(process.argv[1]); const input = JSON.parse(process.argv[2]); +const blocked = /(logout|signout|delete|remove|destroy|payment|checkout|purchase|upgrade|unsubscribe)/i; +(async()=>{const options={headless:true};if(input.executablePath)options.executablePath=input.executablePath;const browser=await chromium.launch(options);const page=await browser.newPage();try{ + await page.goto(input.url,{waitUntil:'domcontentloaded',timeout:30000}); const before=page.url(); + const actions=await page.locator('a[href]').evaluateAll(items=>items.map((item,index)=>({id:`link-${index}`,href:item.href,label:(item.textContent||'').trim().replace(/\s+/g,' ').slice(0,140)})).filter(item=>item.href)); + const safe=actions.filter(item=>{try{const u=new URL(item.href), origin=new URL(before).origin;return u.origin===origin&&!blocked.test(u.pathname+' '+item.label)}catch{return false}}).slice(0,30); + let acted=false;if(input.allowedHref){const candidate=safe.find(item=>item.href===input.allowedHref);if(!candidate)throw new Error('planned action is no longer an allowed visible link');await page.goto(candidate.href,{waitUntil:'domcontentloaded',timeout:30000});acted=true;} + const text=(await page.locator('body').innerText().catch(()=>'' )).replace(/\s+/g,' ').slice(0,1800);await page.screenshot({path:input.screenshot,fullPage:true}); + console.log(JSON.stringify({before_url:before,url:page.url(),title:await page.title(),visible_text:text,available_actions:safe,acted,screenshot:input.screenshot})); + }finally{await browser.close()}})().catch(error=>{console.error(error);process.exit(1)});""" + result = subprocess.run( + ["node", "-e", script, module, json.dumps(payload)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=120, + ) + if result.returncode: + raise RuntimeError(result.stdout[-4000:] or "browser persona action failed") + return json.loads(result.stdout.strip().splitlines()[-1]) + + +@graph.step("validate", title="Validate autonomous persona", phase="before_all", outputs=["persona_contract"]) +@runner.phase("before_all") +def before_all(ctx): + if not ctx.build.get("browser_base_url") or not ctx.test_cases: + raise ValueError("An autonomous persona needs a browser base URL and one persona contract") + ctx.log("Validated the autonomous persona contract and safe browser boundary") + + +@graph.step( + "observe", + title="Observe current page", + phase="before_each", + inputs=["persona_contract"], + outputs=["page_choices"], +) +@runner.phase("before_each") +def before_each(ctx): + state = load_state(ctx) + url = str(state.get("last_url") or ctx.build["browser_base_url"]) + observation = browser(ctx, url=url, screenshot=f"persona-observation-{ctx.loop_index}.png") + state["observation"] = observation + save_state(ctx, state) + ctx.emit_result( + { + "persona": { + "iteration": ctx.loop_index, + "state": { + key: state[key] + for key in ( + "persona", + "current_goal", + "feeling", + "next_intent", + "learnings", + "reported_issues", + ) + }, + "observation": observation, + } + } + ) + ctx.log(f"Observed {len(observation['available_actions'])} safe visible action(s)") + + +@graph.step( + "decide", + title="Plan next persona action", + phase="execute", + inputs=["page_choices"], + outputs=["persona_plan"], +) +@runner.phase("execute") +def execute(ctx): + state = load_state(ctx) + actions = state["observation"]["available_actions"] + prompt = ( + """You are a persistent product user. Decide one next action, not a test verdict. Return JSON only: +{"intent":"short first-person purpose","action_id":"one listed id or empty","rationale":"observable reason","expected_signal":"what would change my understanding"}. +You may choose an empty action_id to observe again. Never choose actions outside listed IDs. Avoid repeating a reported issue unless new evidence exists. + +Persona state:\n""" + + json.dumps( + { + key: state[key] + for key in ( + "persona", + "current_goal", + "feeling", + "next_intent", + "learnings", + "reported_issues", + "history", + ) + }, + ensure_ascii=False, + ) + + "\nVisible safe actions:\n" + + json.dumps(actions, ensure_ascii=False) + ) + plan = model_json(ctx, prompt) + action_id = str(plan.get("action_id") or "") + selected = next((item for item in actions if item["id"] == action_id), None) + if action_id and selected is None: + raise ValueError("persona selected an action outside the visible safe action list") + state["plan"] = { + "intent": str(plan.get("intent") or state["next_intent"]), + "rationale": str(plan.get("rationale") or ""), + "expected_signal": str(plan.get("expected_signal") or ""), + "action": selected, + } + save_state(ctx, state) + ctx.emit_result({"persona": {"iteration": ctx.loop_index, "plan": state["plan"]}}) + ctx.log("Planned one persona action from rendered, safe choices") + + +@graph.step( + "act", + title="Take one safe persona action", + phase="verify", + inputs=["persona_plan"], + outputs=["action_evidence"], +) +@runner.phase("verify") +def verify(ctx): + state = load_state(ctx) + action = state["plan"].get("action") + evidence = browser( + ctx, + url=state["observation"]["before_url"], + allowed_href=action["href"] if action else None, + screenshot=f"persona-action-{ctx.loop_index}.png", + ) + state["evidence"] = evidence + save_state(ctx, state) + ctx.emit_result({"persona": {"iteration": ctx.loop_index, "action_evidence": evidence}}) + ctx.log("Completed one bounded persona action with rendered evidence") + + +@runner.phase("after_each") +def after_each(ctx): + state = load_state(ctx) + prompt = """You are reflecting as a persistent product user after one safe browser action. Return JSON only: +{"feeling":"brief feeling","learning":"specific observation","issue":{"title":"short or empty","evidence":"observable evidence","severity":"low|medium|high"},"next_intent":"one concrete next action to investigate"}. +Do not repeat a reported issue title unless the new evidence materially differs. + +Current state:\n""" + json.dumps(state, ensure_ascii=False) + reflection = model_json(ctx, prompt) + issue = reflection.get("issue") if isinstance(reflection.get("issue"), dict) else {} + title = str(issue.get("title") or "").strip() + known = {str(item.get("title", "")).casefold() for item in state["reported_issues"]} + if title and title.casefold() not in known: + state["reported_issues"].append( + { + "title": title, + "evidence": str(issue.get("evidence") or ""), + "severity": str(issue.get("severity") or "low"), + "iteration": ctx.loop_index, + } + ) + learning = str(reflection.get("learning") or "").strip() + if learning and learning not in state["learnings"]: + state["learnings"].append(learning) + state["feeling"] = str(reflection.get("feeling") or state["feeling"]) + state["next_intent"] = str(reflection.get("next_intent") or state["plan"]["intent"]) + state["last_url"] = state["evidence"]["url"] + state["history"].append( + { + "iteration": ctx.loop_index, + "intent": state["plan"]["intent"], + "action": state["plan"].get("action"), + "feeling": state["feeling"], + "learning": learning, + "next_intent": state["next_intent"], + } + ) + save_state(ctx, state) + summary = {key: state[key] for key in ("feeling", "next_intent", "learnings", "reported_issues")} + ctx.emit_result( + {"persona": {"iteration": ctx.loop_index, "reflection": reflection, "next_state": summary}} + ) + ctx.save_data_file( + f"autonomous-persona/iteration-{ctx.loop_index}.json", + json.dumps( + { + "plan": state["plan"], + "evidence": state["evidence"], + "reflection": reflection, + "next_state": summary, + }, + ensure_ascii=False, + indent=2, + ), + label="Autonomous persona handoff", + content_type="application/json", + ) + ctx.log("Retained persona feeling, learning, deduplicated issues, and next intent") + + +@runner.phase("after_all") +def after_all(ctx): + ctx.log("Finalized the autonomous persona journey") + + +if __name__ == "__main__": + runner.main() diff --git a/runner-templates/playwright-continuous-journey.json b/runner-templates/playwright-continuous-journey.json new file mode 100644 index 0000000..c94d3ce --- /dev/null +++ b/runner-templates/playwright-continuous-journey.json @@ -0,0 +1,5 @@ +{ + "id": "playwright-continuous-journey", + "name": "Playwright continuous journey", + "description": "Runs one focused browser journey per iteration, preserves evidence-backed handoffs, and retries failed cases before advancing." +} diff --git a/runner-templates/playwright-continuous-journey.py b/runner-templates/playwright-continuous-journey.py new file mode 100644 index 0000000..e385207 --- /dev/null +++ b/runner-templates/playwright-continuous-journey.py @@ -0,0 +1,135 @@ +"""A reference runner for recurring, evidence-led browser journeys.""" + +import json +import re + +from orbit_sdk import graph, runner + +graph.connect("validate", "plan") +graph.connect("plan", "exercise", kind="data", label="focused journey") +graph.connect("exercise", "retain") +graph.connect("retain", "plan", kind="loop", label="next iteration") + + +def state_path(ctx): + """Keep durable journey state in OpenOrbit AppData, never in the target repo.""" + build_id = re.sub(r"[^a-zA-Z0-9_-]+", "-", str(ctx.build.get("id") or "journey")) + directory = ctx.app_data / "continuous-journeys" + directory.mkdir(parents=True, exist_ok=True) + return directory / f"{build_id}.json" + + +def load_state(ctx): + path = state_path(ctx) + return ( + json.loads(path.read_text(encoding="utf-8")) + if path.exists() + else {"next_case": 0, "failed": [], "history": []} + ) + + +def save_state(ctx, state): + state["history"] = state.get("history", [])[-24:] + state_path(ctx).write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + + +@graph.step( + "validate", title="Validate recurring browser journey", phase="before_all", outputs=["journey_contract"] +) +@runner.phase("before_all") +def before_all(ctx): + # The SDK owns browser execution; callers supply only a URL and fixed cases. + if not ctx.build.get("browser_base_url") or not ctx.test_cases: + raise ValueError("A browser base URL and at least one journey case are required") + ctx.log("Validated the continuous Playwright journey contract") + + +@graph.step( + "plan", + title="Choose next user journey", + phase="before_each", + inputs=["journey_contract"], + outputs=["journey_plan"], +) +@runner.phase("before_each") +def before_each(ctx): + state = load_state(ctx) + # Failed paths are retried first; otherwise rotate to retain broad coverage. + focused = [case for case in ctx.test_cases if str(case.get("id")) in set(state["failed"])] + if not focused: + focused = [ctx.test_cases[int(state["next_case"]) % len(ctx.test_cases)]] + state["plan"] = { + "case_ids": [str(case.get("id")) for case in focused], + "prior_feedback": ctx.previous_supervisor_feedback, + } + save_state(ctx, state) + ctx.emit_result({"continuous_journey": {"iteration": ctx.loop_index, "plan": state["plan"]}}) + ctx.log(f"Planned journey case(s): {', '.join(state['plan']['case_ids'])}") + + +@graph.step( + "exercise", + title="Exercise rendered journey", + phase="execute", + inputs=["journey_plan"], + outputs=["browser_evidence"], +) +@runner.phase("execute") +def execute(ctx): + state = load_state(ctx) + chosen = set(state["plan"]["case_ids"]) + evidence = ctx.playwright_journey([case for case in ctx.test_cases if str(case.get("id")) in chosen]) + state["failed"] = [str(item.get("id")) for item in evidence["results"] if not item["passed"]] + state["next_case"] = int(state["next_case"]) + 1 + state["history"].append( + {"iteration": ctx.loop_index, "case_ids": state["plan"]["case_ids"], "failed": state["failed"]} + ) + save_state(ctx, state) + ctx.emit_result( + { + "continuous_journey": { + "iteration": ctx.loop_index, + "evidence": evidence, + "handoff": state["history"][-1], + } + } + ) + # Preserve a small, downloadable iteration summary alongside the richer + # Playwright evidence retained by the SDK. + ctx.save_data_file( + f"continuous-journey/iteration-{ctx.loop_index}.json", + json.dumps(state["history"][-1], ensure_ascii=False, indent=2), + label="Continuous journey handoff", + content_type="application/json", + ) + ctx.log(f"Executed {len(evidence['results'])} browser case(s); failed: {len(state['failed'])}") + + +@graph.step( + "retain", + title="Retain journey handoff", + phase="verify", + inputs=["browser_evidence"], + outputs=["next_iteration"], +) +@runner.phase("verify") +def verify(ctx): + state = load_state(ctx) + ctx.emit_result( + {"continuous_journey": {"next_iteration": state["history"][-1], "state_path": str(state_path(ctx))}} + ) + ctx.log("Retained browser evidence and the next-iteration handoff") + + +@runner.phase("after_each") +def after_each(ctx): + ctx.log("Completed one bounded continuous browser journey") + + +@runner.phase("after_all") +def after_all(ctx): + ctx.log("Finalized the continuous browser journey") + + +if __name__ == "__main__": + runner.main() diff --git a/runner-templates/selenium-external-journey.json b/runner-templates/selenium-external-journey.json new file mode 100644 index 0000000..7f7c7fe --- /dev/null +++ b/runner-templates/selenium-external-journey.json @@ -0,0 +1,5 @@ +{ + "id": "selenium-external-journey", + "name": "Selenium external journey", + "description": "Connects an existing Selenium automation adapter while OpenOrbit owns scheduling, structured evidence, and supervision." +} diff --git a/runner-templates/selenium-external-journey.py b/runner-templates/selenium-external-journey.py new file mode 100644 index 0000000..87e78d7 --- /dev/null +++ b/runner-templates/selenium-external-journey.py @@ -0,0 +1,104 @@ +"""Reference adapter runner for teams that already operate Selenium journeys.""" + +import json +import os +import shlex + +from orbit_sdk import graph, runner + +graph.connect("ready", "prepare") +graph.connect("prepare", "run", kind="data", label="one Selenium cycle") +graph.connect("run", "collect") + + +def command(): + """Read an explicit adapter command without embedding target credentials.""" + configured = os.environ.get("ORBIT_SELENIUM_COMMAND", "").strip() + if not configured: + raise ValueError( + "Set ORBIT_SELENIUM_COMMAND to an adapter supporting status, prepare, run-once, and collect-evidence" + ) + value = json.loads(configured) if configured.startswith("[") else shlex.split(configured) + if not isinstance(value, list) or not value or not all(isinstance(item, str) for item in value): + raise ValueError("ORBIT_SELENIUM_COMMAND must resolve to a non-empty string command list") + return value + + +def invoke(ctx, action): + # One bounded call per phase: the adapter must never start its own daemon. + return ctx.exec([*command(), action], cwd=ctx.project_root, timeout=3600) + + +def evidence(value): + """Bound adapter output before it becomes retained OpenOrbit evidence.""" + text = str(value).strip() + return text[:4000] if text else "(adapter returned no output)" + + +@graph.step("ready", title="Check Selenium adapter", phase="before_all", outputs=["adapter_status"]) +@runner.phase("before_all") +def before_all(ctx): + status = evidence(invoke(ctx, "status")) + ctx.emit_result({"selenium_journey": {"status": status}}) + ctx.log("Validated the Selenium adapter contract") + + +@graph.step( + "prepare", + title="Prepare Selenium cycle", + phase="before_each", + inputs=["adapter_status"], + outputs=["prepared_cycle"], +) +@runner.phase("before_each") +def before_each(ctx): + prepared = evidence(invoke(ctx, "prepare")) + ctx.emit_result({"selenium_journey": {"iteration": ctx.loop_index, "prepared": prepared}}) + ctx.log("Prepared one Selenium adapter cycle") + + +@graph.step( + "run", + title="Run Selenium journey", + phase="execute", + inputs=["prepared_cycle"], + outputs=["journey_result"], +) +@runner.phase("execute") +def execute(ctx): + result = evidence(invoke(ctx, "run-once")) + ctx.emit_result({"selenium_journey": {"iteration": ctx.loop_index, "result": result}}) + ctx.target_log("Selenium adapter completed one bounded journey", level="info", source="selenium-adapter") + + +@graph.step( + "collect", + title="Collect Selenium evidence", + phase="verify", + inputs=["journey_result"], + outputs=["journey_evidence"], +) +@runner.phase("verify") +def verify(ctx): + collected = evidence(invoke(ctx, "collect-evidence")) + ctx.emit_result({"selenium_journey": {"iteration": ctx.loop_index, "evidence": collected}}) + ctx.save_data_file( + f"selenium-journey/iteration-{ctx.loop_index}.txt", + collected, + label="Selenium adapter evidence", + content_type="text/plain", + ) + + +@runner.phase("after_each") +def after_each(ctx): + ctx.log("Completed one bounded Selenium adapter cycle") + + +@runner.phase("after_all") +def after_all(ctx): + ctx.log("Finalized the Selenium adapter evaluation") + + +if __name__ == "__main__": + runner.main() diff --git a/runner-templates/tailwind-source-aware.json b/runner-templates/tailwind-source-aware.json new file mode 100644 index 0000000..25326e3 --- /dev/null +++ b/runner-templates/tailwind-source-aware.json @@ -0,0 +1,5 @@ +{ + "id": "tailwind-source-aware", + "name": "Tailwind source-aware journey", + "description": "Checks a Tailwind project’s source contract before retaining Playwright evidence for its rendered user journeys." +} diff --git a/runner-templates/tailwind-source-aware.py b/runner-templates/tailwind-source-aware.py new file mode 100644 index 0000000..5ba337f --- /dev/null +++ b/runner-templates/tailwind-source-aware.py @@ -0,0 +1,86 @@ +"""Reference runner for Tailwind applications with rendered browser evidence.""" + +from orbit_sdk import graph, runner + +graph.connect("inspect-source", "run-browser") +graph.connect("run-browser", "publish-evidence") + + +@graph.step( + "inspect-source", + title="Inspect Tailwind source contract", + phase="before_all", + outputs=["source_contract"], +) +@runner.phase("before_all") +def before_all(ctx): + # Inspect stylesheet content as well as config paths: Tailwind v4 commonly + # uses `@import "tailwindcss"` in an otherwise generic `index.css` name. + files = ctx.exec( + ["sh", "-lc", "rg -l 'tailwindcss|@tailwind' --glob '*.css' . || true"], + cwd=ctx.project_root, + timeout=30, + ) + config = ctx.exec( + ["sh", "-lc", "rg --files -g 'tailwind.config.*' . || true"], cwd=ctx.project_root, timeout=30 + ) + if not files.strip() and not config.strip(): + raise ValueError("No Tailwind configuration or stylesheet was found in the target project") + if not ctx.build.get("browser_base_url") or not ctx.test_cases: + raise ValueError("A browser base URL and at least one journey case are required") + ctx.emit_result( + { + "tailwind_journey": { + "stylesheet_files": files.splitlines()[:40], + "config_files": config.splitlines()[:20], + } + } + ) + ctx.log("Validated the Tailwind source contract") + + +@graph.step( + "run-browser", + title="Run rendered Tailwind journey", + phase="execute", + inputs=["source_contract"], + outputs=["browser_evidence"], +) +@runner.phase("execute") +def execute(ctx): + # The SDK captures screenshots and visible-page evidence; Tailwind class names + # are never treated as proof that a user-visible interaction succeeded. + evidence = ctx.playwright_journey() + ctx.emit_result({"tailwind_journey": {"iteration": ctx.loop_index, "evidence": evidence}}) + ctx.save_data_file( + f"tailwind-journey/iteration-{ctx.loop_index}.json", + __import__("json").dumps(evidence, ensure_ascii=False, indent=2), + label="Tailwind rendered journey evidence", + content_type="application/json", + ) + + +@graph.step( + "publish-evidence", + title="Publish rendered evidence", + phase="verify", + inputs=["browser_evidence"], + outputs=["review_ready"], +) +@runner.phase("verify") +def verify(ctx): + ctx.log("Published Tailwind source context with rendered browser evidence") + + +@runner.phase("after_each") +def after_each(ctx): + ctx.log("Completed one bounded Tailwind browser journey") + + +@runner.phase("after_all") +def after_all(ctx): + ctx.log("Finalized the Tailwind browser evaluation") + + +if __name__ == "__main__": + runner.main()