From 13a16decb077c525a60d1442ea228c0d48de7c2d Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Mon, 12 Oct 2026 15:38:13 +0100 Subject: [PATCH] feat: track optional-organ setup and paused observation --- STARTER.md | 67 +++++++++++++++++- examples/lifecycle/instance.json | 14 ++++ examples/lifecycle/projects.json | 5 ++ examples/lifecycle/repos.yaml | 9 +++ scripts/connect_organ.py | 14 ++-- scripts/instance_profile.py | 26 +++++-- scripts/organ_lifecycle.py | 87 +++++++++++++++++++++++ scripts/organism_board.py | 51 ++++++++++++-- scripts/overview.js | 7 +- tests/lifecycle_browser.cjs | 48 +++++++++++++ tests/test_organ_lifecycle.py | 117 +++++++++++++++++++++++++++++++ 11 files changed, 425 insertions(+), 20 deletions(-) create mode 100644 examples/lifecycle/instance.json create mode 100644 examples/lifecycle/projects.json create mode 100644 examples/lifecycle/repos.yaml create mode 100644 scripts/organ_lifecycle.py create mode 100644 tests/lifecycle_browser.cjs create mode 100644 tests/test_organ_lifecycle.py diff --git a/STARTER.md b/STARTER.md index 2de4eb2..75c5c09 100644 --- a/STARTER.md +++ b/STARTER.md @@ -167,8 +167,9 @@ To resume with an AI assistant: > report connection validation separately from readiness. Do not create or publish > anything without its own approved setup task. -Organ provisioning, resumable owner-specific setup, pause controls and deployment -are later activation phases. This helper covers the validated connection boundary. +This helper covers the validated connection boundary. The lifecycle controls below +track incomplete setup and pause Scientist observation. Provisioning and deployment +remain owner-specific steps. ## Discover what a project can add @@ -186,3 +187,65 @@ Names are suggestions in instance configuration, not detected repositories. Choose opportunities explicitly when connecting your project; see [PROJECTS.md](PROJECTS.md#available-to-add). The empty starter stays empty. These prompts begin a setup conversation; they do not run setup or activate organs. + +## Track setup and pause observation + +Independent profiles now distinguish configuration from evidence. The **Available +organs** catalogue offers setup prompts; selecting text there does not change files. +An optional `organ_setup` map records a chosen organ's canonical `repo` and +`pages_url` while its setup is incomplete. Its card makes no feed request and is +not a connected board link. Mind retains the actual setup task and history. + +Preview beginning Broca setup (repeat with `--apply` after reviewing): + +```sh +python scripts/organ_lifecycle.py setup --instance /path/to/instance.json \ + --repos /path/to/Mind/repos.yaml --organ broca --repo my-guides \ + --pages-url https://research.example/guides/ +``` + +When the owner setup and publication are ready, use `connect_organ.py` as above. +A matching setup entry moves to `organs` only after its real feed validates. +Failures preserve the profile; mismatched identities require manual review. +Neither grey evidence nor a recorded failure prevents a valid connection: owner +readiness is separate from connection state. + +To pause Scientist's observation of a connected organ: + +```sh +python scripts/organ_lifecycle.py pause --instance /path/to/instance.json \ + --repos /path/to/Mind/repos.yaml --organ broca +``` + +Repeat with `--apply` to save optional `paused: true` on the connection. Render and +publish your updated Scientist dashboard for viewers to see the change; an already +open or previously published page keeps its old configuration until reloaded from +the updated publication. New renders and refreshed pages skip server/browser feed +requests for paused organs, and cached evidence cannot revive them as current. +The repository identity and board link remain available. + +**Pause stops only Scientist's observation.** It does not stop services, scheduled +Actions, jobs or compute. Those remain controlled by the organ's own procedures. +Use the same command with `resume` to preview resumption. Resume validates the saved +feed identity before removing the pause flag; an unavailable or invalid feed leaves +it paused. Apply and republish the Scientist page to resume its observation. + +Connected cards show **Connected** separately from current evidence such as +**Needs attention** or **Unavailable**. Missing, old or grey evidence never turns a +connection into an available-to-add capability. Setup entries and paused connections +do not claim healthy evidence. Existing profiles without these optional fields +retain their connections and continue fetching normally. + +For a fresh conversation: + +> Read my instance profile, canonical map and existing Mind task. Identify organs +> still being set up, connected organs and paused observations. Preview the relevant +> lifecycle operation before applying it. Preserve records and identities; validate +> the published feed before connecting or resuming. Pausing observation must not +> stop owner jobs. Explain whether the dashboard needs republishing. + +The fictitious `examples/lifecycle/` profile contains a pending Broca, paused Mind +and connected Heart with explicit independent names. Its `example.invalid` URLs +are placeholders; it makes no claim of live deployment. Use the normal renderer +with `--instance` and `--repos` for a local preview. Optional-organ provisioning, +real independent deployment and framework upgrades remain separate tasks. diff --git a/examples/lifecycle/instance.json b/examples/lifecycle/instance.json new file mode 100644 index 0000000..b4ccd9b --- /dev/null +++ b/examples/lifecycle/instance.json @@ -0,0 +1,14 @@ +{ + "schema_version": 1, + "name": "Mira Scientist", + "repo": "scientist", + "pages_url": "https://example.invalid/mira/", + "projects": "projects.json", + "organs": { + "mind": {"repo": "plans", "pages_url": "https://example.invalid/plans/", "paused": true}, + "heart": {"repo": "checks", "pages_url": "https://example.invalid/checks/"} + }, + "organ_setup": { + "broca": {"repo": "guides", "pages_url": "https://example.invalid/guides/"} + } +} diff --git a/examples/lifecycle/projects.json b/examples/lifecycle/projects.json new file mode 100644 index 0000000..ea3849d --- /dev/null +++ b/examples/lifecycle/projects.json @@ -0,0 +1,5 @@ +{ + "schema_version": 1, + "resources": {}, + "projects": {} +} diff --git a/examples/lifecycle/repos.yaml b/examples/lifecycle/repos.yaml new file mode 100644 index 0000000..43d8e7f --- /dev/null +++ b/examples/lifecycle/repos.yaml @@ -0,0 +1,9 @@ +repos: + scientist: + github: Research/Mira + plans: + github: Research/ResearchPlans + checks: + github: Research/Checks + guides: + github: Research/Communication diff --git a/scripts/connect_organ.py b/scripts/connect_organ.py index 9afae45..a4e1eb8 100644 --- a/scripts/connect_organ.py +++ b/scripts/connect_organ.py @@ -15,7 +15,7 @@ import project_resources -def plan(instance, repos, organ, repo, pages_url): +def plan(instance, repos, organ, repo, pages_url, *, resume=False): """Return validated replacement bytes and input snapshots, without writing. A connection is a location/contract check, never installation or a health @@ -37,8 +37,12 @@ def plan(instance, repos, organ, repo, pages_url): github = instance_profile._identity(repo, yaml.safe_load(body_bytes)["repos"]) connection = {"repo": repo, "pages_url": instance_profile._pages(pages_url)} existing = config["organs"].get(organ) - if existing is not None: - normalized = dict(existing, pages_url=instance_profile._pages(existing["pages_url"])) + pending = config.get("organ_setup", {}).get(organ) + if existing is not None and existing.get("paused", False) and not resume: + raise ValueError("organ observation is paused; use explicit lifecycle resume") + if existing is not None or pending is not None: + prior = existing if existing is not None else pending + normalized = {"repo": prior["repo"], "pages_url": instance_profile._pages(prior["pages_url"])} if normalized != connection: raise ValueError("organ already has a different connection; review it manually") @@ -54,12 +58,14 @@ def plan(instance, repos, organ, repo, pages_url): raise ValueError("organ feed pages_url does not match the selected location") config["organs"][organ] = connection + if pending is not None: + del config["organ_setup"][organ] return { "instance": instance, "inputs": {instance: original, repos: body_bytes, projects: project_bytes}, "mode": stat.S_IMODE(instance.stat().st_mode), "replacement": (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode(), - "changed": existing is None, + "changed": existing is None or bool(existing.get("paused", False)), "organ": organ, "connection": connection, "status": feed["status"], diff --git a/scripts/instance_profile.py b/scripts/instance_profile.py index a0a291d..7d26779 100644 --- a/scripts/instance_profile.py +++ b/scripts/instance_profile.py @@ -41,7 +41,7 @@ def load(path, repos_path, theme): path = Path(path) config = json.loads(path.read_text(encoding="utf-8")) required = {"schema_version", "name", "repo", "pages_url", "projects", "organs"} - if not isinstance(config, dict) or set(config) != required: + if not isinstance(config, dict) or not required <= set(config) or set(config) - required - {"organ_setup"}: raise ValueError("instance profile must declare schema_version, name, repo, pages_url, projects and organs") if type(config["schema_version"]) is not int or config["schema_version"] != 1: raise ValueError("unsupported instance schema_version") @@ -64,18 +64,34 @@ def load(path, repos_path, theme): raise ValueError("Brain board registry unavailable") if not isinstance(selected, dict) or set(selected) - set(family): raise ValueError("organs must map canonical Brain organ keys to explicit connections") + pending = config.get("organ_setup", {}) + if not isinstance(pending, dict) or set(pending) - set(family) or set(pending) & set(selected): + raise ValueError("organ_setup must contain known, unconnected organ keys") + setup = [] + for key, connection in pending.items(): + if not isinstance(connection, dict) or set(connection) != {"repo", "pages_url"}: + raise ValueError(f"organ setup {key}: declare only repo and pages_url") + github_setup = _identity(connection["repo"], repos) + setup.append({"key": key, "name": theme.organ(key)["organ"], + "role": theme.organ(key)["function"], "repo": github_setup.split("/")[-1], + "url": _pages(connection["pages_url"])}) + available = [{"key": key, "name": theme.organ(key)["organ"], + "role": theme.organ(key)["function"]} for key in family + if key not in selected and key not in pending] boards = [] for key in family: if key not in selected: continue connection = selected[key] - if not isinstance(connection, dict) or set(connection) != {"repo", "pages_url"}: - raise ValueError(f"organ {key}: declare only repo and pages_url") + if (not isinstance(connection, dict) or not {"repo", "pages_url"} <= set(connection) + or set(connection) - {"repo", "pages_url", "paused"} + or type(connection.get("paused", False)) is not bool): + raise ValueError(f"organ {key}: declare repo, pages_url and optional boolean paused") organ_github = _identity(connection["repo"], repos) boards.append({"key": key, "name": theme.organ(key)["organ"], "role": theme.organ(key)["function"], "repo": organ_github.split("/")[1], - "url": _pages(connection["pages_url"])}) + "url": _pages(connection["pages_url"]), "paused": connection.get("paused", False)}) return {"schema_version": 1, "name": name, "repo": github.split("/")[1], "source_url": "https://github.com/" + github, "pages_url": pages_url, - "boards": boards}, projects + "boards": boards, "setup": setup, "available": available}, projects diff --git a/scripts/organ_lifecycle.py b/scripts/organ_lifecycle.py new file mode 100644 index 0000000..17e14d1 --- /dev/null +++ b/scripts/organ_lifecycle.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Preview/apply setup or pause Scientist observation; never stop owner jobs.""" +import argparse +import json +from pathlib import Path +import stat + +import yaml + +import connect_organ +import instance_profile +import organism_board +import project_resources + + +def plan(instance, repos, organ, action, repo=None, pages_url=None): + instance, repos = Path(instance), Path(repos) + if instance.is_symlink(): + raise ValueError("instance profile must not be a symlink") + original, body = instance.read_bytes(), repos.read_bytes() + config = json.loads(original) + theme = organism_board.theme() + _, projects = instance_profile.load(instance, repos, theme) + project_bytes = projects.read_bytes() + project_resources.load(projects, repos) + if organ not in theme.board_links(config["pages_url"], current="organism"): + raise ValueError("unknown organ key") + existing = config["organs"].get(organ) + if action == "resume": + if existing is None: + raise ValueError("resume requires a connected organ") + prepared = connect_organ.plan(instance, repos, organ, existing["repo"], existing["pages_url"], resume=True) + # Preserve the initial snapshot too; a concurrent identity edit must not + # silently change which connection this resume was meant to validate. + if prepared["inputs"] != {instance: original, repos: body, projects: project_bytes}: + raise ValueError("input changed during resume; rerun preview") + return prepared + if action == "setup": + if existing is not None: + raise ValueError("organ is already connected") + instance_profile._identity(repo, yaml.safe_load(body)["repos"]) + connection = {"repo": repo, "pages_url": instance_profile._pages(pages_url)} + pending = config.setdefault("organ_setup", {}) + if organ in pending and pending[organ] != connection: + raise ValueError("conflicting setup; review existing identity manually") + changed = organ not in pending + pending[organ] = connection + elif action == "pause": + if existing is None: + raise ValueError("pause requires a connected organ") + changed = not existing.get("paused", False) + existing["paused"] = True + else: + raise ValueError("action must be setup, pause or resume") + return {"instance": instance, "inputs": {instance: original, repos: body, projects: project_bytes}, + "mode": stat.S_IMODE(instance.stat().st_mode), "changed": changed, + "replacement": (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode()} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=["setup", "pause", "resume"]) + parser.add_argument("--instance", type=Path, required=True) + parser.add_argument("--repos", type=Path, required=True) + parser.add_argument("--organ", required=True) + parser.add_argument("--repo") + parser.add_argument("--pages-url") + parser.add_argument("--apply", action="store_true") + args = parser.parse_args(argv) + if args.action != "setup" and (args.repo is not None or args.pages_url is not None): + parser.error("pause/resume use the existing connection identity") + try: + prepared = plan(args.instance, args.repos, args.organ, args.action, args.repo, args.pages_url) + connect_organ._unchanged(prepared) + if args.apply: + print("Updated" if connect_organ.apply(prepared) else "Unchanged") + else: + print("Preview only; pass --apply to save" if prepared["changed"] else "Unchanged") + print(prepared["replacement"].decode()) + print("Pause controls Scientist observation only; owner jobs and services are unaffected.") + except (OSError, ValueError, RuntimeError, yaml.YAMLError) as exc: + parser.exit(2, f"organ-lifecycle: {exc}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/organism_board.py b/scripts/organism_board.py index 0679690..0b009d7 100644 --- a/scripts/organism_board.py +++ b/scripts/organism_board.py @@ -87,7 +87,7 @@ def instance_prompt(instance): return (f"Use this chat as an ongoing entry point to {instance['name']}. " "Read this Scientist checkout’s AGENTS.md, STARTER.md and REPORTING.md, " "then its explicit instance profile and project relationships. " - "Consult only connected organs and relevant project-owned sources. " + "Consult only connected, unpaused organs and relevant project-owned sources. " "Do not assume optional organs, an agent harness or remote access are installed. " "Help connect existing projects using their canonical repository identities; " "ask about purpose, visibility and needed capabilities before changing setup. " @@ -171,10 +171,14 @@ def collect(owner: str | None = None, instance: dict | None = None) -> dict: def read(row): row = dict(row, headline=None, color=None, feed=None, error=None) + if row.get("paused"): + row.update(headline="Observation paused", color="grey") + return row try: feed = json.loads(_get(row["url"] + "state.json")) errors = validate_state(feed) - if errors or feed["organ"] != row["key"] or feed["repo"] != row["repo"]: + if (errors or feed["organ"] != row["key"] or feed["repo"] != row["repo"] + or (instance is not None and feed["pages_url"].rstrip("/") != row["url"].rstrip("/"))): raise ValueError("Invalid organ feed") row.update(feed=feed, headline=feed["headline"], color=feed["status"]) except (urllib.error.URLError, TimeoutError, ValueError, OSError): @@ -200,7 +204,7 @@ def _heart(snapshot: dict) -> dict | None: def heart_word(snapshot: dict) -> str: h = _heart(snapshot) - if not h or not h.get("headline"): + if not h or h.get("paused") or not h.get("headline"): return "UNKNOWN" if h.get("feed"): return h["feed"]["status"].upper() @@ -235,6 +239,8 @@ def _render_md(snapshot: dict) -> str: head = b.get("headline") or "unavailable" link = f"[{b['name']}]({b['url']})" if b.get("url") else b["name"] lines.append(f"| {link} | {head} | {b['role']} |") + for pending in snapshot.get("instance", {}).get("setup", []): + lines.append(f"| {pending['name']} | Setup incomplete; not connected | {pending['role']} |") if "project_resources" in snapshot: from project_resources import render_markdown lines.extend(["", render_markdown(snapshot["project_resources"])]) @@ -283,6 +289,9 @@ def _row_id(name: str) -> str: .available-resource-body{padding:0 .75rem .75rem 1.25rem} .available-resource-body label{display:block;margin-bottom:.35rem} .available-resource-body textarea{font:inherit;width:100%;box-sizing:border-box;resize:vertical;background:var(--bg);color:var(--fg);border:1px solid var(--line);padding:.65rem} +.organ-catalogue textarea,.organ-content textarea{width:100%;box-sizing:border-box;min-height:7rem;font:inherit} +.organ-catalogue{margin-block:1rem} +.organ-catalogue details>summary{color:var(--muted,#666)} .organ-grid :focus-visible{outline:3px solid var(--accent);outline-offset:3px} @media(max-width:64rem){.organ-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:40rem){.organ-grid{grid-template-columns:1fr}.organ-content .summary-row{grid-template-columns:1fr;gap:.15rem}} @@ -300,7 +309,7 @@ def _render_html(snapshot: dict) -> str: "scientist", "", "", prompt, organ="scientist", work_links=([{"label": (name if instance is not None else "Scientist") + " repository", "href": source}] if source else []), refreshed_at=(snapshot.get("generated") if snapshot.get("boards") and - all(row.get("headline") is not None for row in snapshot["boards"]) else None), + all(row.get("headline") is not None and not row.get("paused") for row in snapshot["boards"]) else None), refresh_url=(source + "/actions/workflows/organism_board.yml" if source and instance is None else None)) from project_resources import render_html as render_projects projects_html = render_projects(snapshot["project_resources"]) if "project_resources" in snapshot else "" @@ -315,10 +324,37 @@ def _render_html(snapshot: dict) -> str: # Server fallback is explicitly a publication snapshot, not a live check. head = _html.escape(row.get("headline") or "Evidence unavailable") url = _html.escape(row["url"], quote=True) + lifecycle = "Paused" if row.get("paused") else "Connected" if instance is not None else "Snapshot" + note = ("Scientist observation paused; owner jobs are unaffected." if row.get("paused") + else "Published " + str(snapshot.get("generated") or "unknown")) rows.append(f"""
-{organ_name}Snapshot +{organ_name}{lifecycle}
Published status
{head}
-
Published {_html.escape(str(snapshot.get('generated') or 'unknown'))}Open {organ_name} →
""") +
{_html.escape(note)}Open {organ_name} →
""") + catalogue = "" + if instance is not None: + for pending in instance.get("setup", []): + key = pending["key"] + prompt_text = (f"Resume setup for {pending['name']} in my existing Mind task. " + "Read my profile and canonical map first. Validate the owner setup and published feed, " + "then preview connect_organ.py before applying. Preserve existing records.") + rows.append(f'
' + f'{_html.escape(pending["name"])} · Setup incomplete' + f'

Not connected. No feed requested.

' + f'

{_html.escape(pending["role"])}

') + entries = [] + for available in instance.get("available", []): + key = available["key"] + prompt_text = (f"Help me add {available['name']} ({available['role']}) to this Scientist. " + "Read my existing profile, canonical map and Mind task. Confirm the purpose, repository " + "identity and public board location; preview organ_lifecycle.py setup before applying. " + "Keep it setup incomplete until connect_organ.py validates its published feed.") + entries.append(f'
{_html.escape(available["name"])} · Available to add' + f'

{_html.escape(available["role"])}

') + if entries: + catalogue = '
Available organs' + ''.join(entries) + '
' data = json.dumps(snapshot, ensure_ascii=False).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026") script = (HOME / "scripts" / "overview.js").read_text(encoding="utf-8") banner = (t.hero(BOARD_KEY, "Dashboard", display_name=name) if instance is not None @@ -331,8 +367,9 @@ def _render_html(snapshot: dict) -> str: {banner}{panel} {projects_html}

Dashboards

-{'

No organs connected. Add a connection when your project needs it.

' if instance is not None and not rows else ''} +{'

No organs connected. Add a connection when your project needs it.

' if instance is not None and not snapshot.get("boards") else ''}
{''.join(rows)}
+{catalogue} {footer} diff --git a/scripts/overview.js b/scripts/overview.js index bc84acb..0e3944d 100644 --- a/scripts/overview.js +++ b/scripts/overview.js @@ -149,6 +149,7 @@ function cacheKey(organ, instance) { const snapshot = JSON.parse(document.getElementById('scientist-snapshot').textContent); const organStates = new Map(); for (const organ of snapshot.boards) { + if (organ.paused) { organStates.set(organ.key, {}); continue; } const identity = { organ: organ.name, repo: organ.repo }; let saved = null; try { saved = JSON.parse(localStorage.getItem(cacheKey(organ, snapshot.instance))); } catch (_) {} @@ -157,10 +158,11 @@ for (const organ of snapshot.boards) { organStates.set(organ.key, { lastGood }); } function paint(organ) { + if (organ.paused) return; // retain server-rendered pause state; never use cached evidence const view = observation(organStates.get(organ.key)); const card = document.getElementById('board-' + organ.key); const badge = card.querySelector('.organ-status'); - badge.textContent = statusLabel(view); + badge.textContent = (snapshot.instance ? 'Connected · ' : '') + statusLabel(view); badge.dataset.status = view.status; const list = card.querySelector('dl'); const markup = summaryHtml(summaries(organ, view)); @@ -181,6 +183,7 @@ async function refresh() { refreshing = true; try { await Promise.all(snapshot.boards.map(async organ => { + if (organ.paused) return; const st = organStates.get(organ.key); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000); @@ -188,7 +191,7 @@ async function refresh() { const response = await fetch(organ.url + 'state.json?t=' + Date.now(), { cache: 'no-store', signal: controller.signal }); if (!response.ok) throw new Error('Evidence unavailable'); const feed = await response.json(); - if (validate(feed, { organ: organ.name, repo: organ.repo })) throw new Error('Invalid evidence'); + if (validate(feed, { organ: organ.name, repo: organ.repo }) || (snapshot.instance && feed.pages_url.replace(/\/$/, '') !== organ.url.replace(/\/$/, ''))) throw new Error('Invalid evidence'); st.feed = feed; st.lastGood = feed; st.error = null; try { localStorage.setItem(cacheKey(organ, snapshot.instance), JSON.stringify({ feed, at: new Date().toISOString() })); } catch (_) {} } catch (_) { st.feed = null; st.error = 'Evidence unavailable'; } diff --git a/tests/lifecycle_browser.cjs b/tests/lifecycle_browser.cjs new file mode 100644 index 0000000..2397377 --- /dev/null +++ b/tests/lifecycle_browser.cjs @@ -0,0 +1,48 @@ +// NODE_PATH= node tests/lifecycle_browser.cjs +const {chromium} = require('playwright'); +const {pathToFileURL} = require('node:url'); +const path = require('node:path'); +const fs = require('node:fs'); +const assert = require('node:assert/strict'); +(async () => { + const browser = await chromium.launch({headless:true, channel:'chromium'}); + const evidence = path.resolve(process.argv[3]); fs.mkdirSync(evidence,{recursive:true}); + try { + for (const width of [390,1440]) for(const colorScheme of ['light','dark']) { + const ctx=await browser.newContext({viewport:{width,height:1100},colorScheme}); + const requests=[]; + await ctx.route('https://**/*', route=>{requests.push(route.request().url());return route.fulfill({status:503,body:'{}'});}); + await ctx.addInitScript(()=>{ + const feed={schema_version:1,organ:'mind',repo:'ResearchPlans',status:'green',headline:'CACHED MUST NOT APPEAR',items:[],updated:new Date().toISOString(),pages_url:'https://example.invalid/plans/'}; + const key='scientist:last-good:'+JSON.stringify(['https://github.com/Research/Mira','mind','ResearchPlans','https://example.invalid/plans/']); + localStorage.setItem(key,JSON.stringify({feed})); + }); + const page=await ctx.newPage(); const errors=[]; page.on('pageerror',e=>errors.push(e.message)); + await page.goto(pathToFileURL(path.resolve(process.argv[2])).href); + await page.waitForFunction(()=>!refreshing); + assert.equal(await page.locator('#board-mind .organ-status').textContent(),'Paused'); + assert((await page.locator('#setup-broca').textContent()).includes('Setup incomplete')); + assert((await page.locator('#board-heart .organ-status').textContent()).startsWith('Connected')); + await page.locator('#board-mind summary').click(); + await page.locator('#board-mind a').focus(); + await page.evaluate(async()=>{await refresh();document.dispatchEvent(new Event('visibilitychange'));}); + await page.waitForFunction(()=>!refreshing); + assert.equal(await page.locator('#board-mind').getAttribute('open'),''); + assert(await page.locator('#board-mind a').evaluate(el=>el===document.activeElement)); + assert(!await page.locator('body').innerText().then(t=>t.includes('CACHED MUST NOT APPEAR'))); + assert(requests.length>0 && requests.every(url=>url.startsWith('https://example.invalid/checks/'))); + await page.locator('#setup-broca summary').click(); + await page.locator('.organ-catalogue > summary').click(); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true); + assert.deepEqual(errors,[]); + await page.screenshot({path:path.join(evidence,`${width}-${colorScheme}.png`),fullPage:true}); + await ctx.close(); + } + const ctx=await browser.newContext({javaScriptEnabled:false}); + const page=await ctx.newPage(); await page.goto(pathToFileURL(path.resolve(process.argv[2])).href); + assert((await page.locator('#board-mind').textContent()).includes('Paused')); + assert((await page.locator('#setup-broca').textContent()).includes('Setup incomplete')); + await ctx.close(); + console.log('PASS: four browser layouts plus no-JS; pause/cache/refresh/focus/network checks'); + } finally {await browser.close();} +})().catch(e=>{console.error(e);process.exit(1);}); diff --git a/tests/test_organ_lifecycle.py b/tests/test_organ_lifecycle.py new file mode 100644 index 0000000..68f4909 --- /dev/null +++ b/tests/test_organ_lifecycle.py @@ -0,0 +1,117 @@ +"""Configuration transitions are independent of owner evidence and execution.""" +import json +import pytest + +from test_connect_organ import pilot, snapshot +import connect_organ as connector +import instance_profile +import organism_board as board +import organ_lifecycle as lifecycle + + +def prepare(pilot, action): + instance, repos, _, _, _ = pilot + return lifecycle.plan(instance, repos, 'broca', action, 'guides', 'https://example.invalid/guides/') + + +def test_setup_connection_pause_resume_preserves_records(pilot): + instance, repos, feed, requests, args = pilot + before = snapshot(instance.parent) + p = prepare(pilot, 'setup') + assert snapshot(instance.parent) == before and requests == [] + assert connector.apply(p) + pending = snapshot(instance.parent) + assert not connector.apply(prepare(pilot, 'setup')) + assert snapshot(instance.parent) == pending + profile, _ = instance_profile.load(instance, repos, board.theme()) + data = board.collect(instance=profile) + assert data['boards'] == [] and requests == [] + page = board.render(data, 'html') + assert 'Setup incomplete' in page and 'Available organs' in page + assert 'id="board-broca"' not in page and 'id="setup-broca"' in page + connector.main(args + ['--apply']) + config = json.loads(instance.read_text()) + assert 'broca' not in config['organ_setup'] + assert config['organs']['broca']['repo'] == 'guides' + assert connector.apply(prepare(pilot, 'pause')) + paused = snapshot(instance.parent) + requests.clear() + profile, _ = instance_profile.load(instance, repos, board.theme()) + data = board.collect(instance=profile) + assert requests == [] and data['boards'][0]['feed'] is None + assert 'Paused' in board.render(data, 'html') + assert not connector.apply(prepare(pilot, 'pause')) + with pytest.raises(SystemExit): + connector.main(args + ['--apply']) + assert snapshot(instance.parent) == paused and requests == [] + feed['repo'] = 'Wrong' + with pytest.raises(ValueError): + prepare(pilot, 'resume') + assert snapshot(instance.parent) == paused + feed['repo'] = 'Communication'; feed['status'] = 'grey' + assert connector.apply(prepare(pilot, 'resume')) + config = json.loads(instance.read_text()) + assert not config['organs']['broca'].get('paused', False) + assert snapshot(instance.parent)['research-note.md'] == before['research-note.md'] + assert snapshot(instance.parent)['projects.json'] == before['projects.json'] + + +@pytest.mark.parametrize('action', ['pause', 'resume']) +def test_disconnected_cannot_pause_or_resume(pilot, action): + before = snapshot(pilot[0].parent) + with pytest.raises(ValueError, match='connected'): + prepare(pilot, action) + assert snapshot(pilot[0].parent) == before and pilot[3] == [] + + +@pytest.mark.parametrize('filename', ['instance.json', 'repos.yaml', 'projects.json']) +def test_offline_transition_rejects_changed_inputs(pilot, filename): + prepared = prepare(pilot, 'setup') + path = pilot[0].parent / filename + path.write_bytes(path.read_bytes()+b'\n') + before = snapshot(pilot[0].parent) + with pytest.raises(ValueError, match='input changed'): + connector.apply(prepared) + assert snapshot(pilot[0].parent) == before + + +def test_failed_promotion_keeps_pending_and_conflict_never_fetches(pilot): + instance, _, feed, requests, args = pilot + connector.apply(prepare(pilot, 'setup')) + before = snapshot(instance.parent) + feed['pages_url'] = 'https://example.invalid/wrong/' + with pytest.raises(SystemExit): + connector.main(args + ['--apply']) + assert snapshot(instance.parent) == before + requests.clear() + with pytest.raises(ValueError, match='conflict'): + lifecycle.plan(instance, pilot[1], 'broca', 'setup', 'guides', 'https://example.invalid/other/') + assert requests == [] + + +@pytest.mark.parametrize('bad', ['overlap', 'paused-string', 'pending-key', 'pending-fields']) +def test_bad_schema_is_rejected(pilot, bad): + instance, repos, _, _, args = pilot + connector.main(args + ['--apply']) + cfg = json.loads(instance.read_text()) + if bad == 'overlap': cfg['organ_setup'] = {'broca': cfg['organs']['broca']} + elif bad == 'paused-string': cfg['organs']['broca']['paused'] = 'true' + elif bad == 'pending-key': cfg['organ_setup'] = {'fake': {}} + else: cfg['organ_setup'] = {'mind': {'repo': 'guides', 'pages_url': 'https://example.invalid/', 'local_path': '/private'}} + instance.write_text(json.dumps(cfg)) + with pytest.raises(ValueError): instance_profile.load(instance, repos, board.theme()) + + +def test_setup_cli_preview_and_missing_arguments(pilot): + instance, repos, _, requests, args = pilot + before = snapshot(instance.parent) + assert lifecycle.main(['setup'] + args) == 0 + assert snapshot(instance.parent) == before and not requests + with pytest.raises(SystemExit): + lifecycle.main(['setup', '--instance', str(instance), '--repos', str(repos), '--organ', 'broca']) + assert snapshot(instance.parent) == before + + +def test_paused_heart_is_not_a_readiness_verdict(): + snap = {"boards": [{"name": "Heart", "paused": True, "headline": "Observation paused", "color": "grey"}]} + assert board.heart_word(snap) == "UNKNOWN"