qaas-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. qaas/adapters/__init__.py +19 -0
  2. qaas/adapters/tracker.py +1350 -0
  3. qaas/adapters/vcs.py +494 -0
  4. qaas/cli.py +1564 -0
  5. qaas/conductor.py +527 -0
  6. qaas/config.py +407 -0
  7. qaas/defaults/config/agents/arbiter.yaml +19 -0
  8. qaas/defaults/config/agents/cartographer.yaml +20 -0
  9. qaas/defaults/config/agents/clerk.yaml +21 -0
  10. qaas/defaults/config/agents/conduit.yaml +19 -0
  11. qaas/defaults/config/agents/forge.yaml +22 -0
  12. qaas/defaults/config/agents/mender.yaml +56 -0
  13. qaas/defaults/config/agents/proof.yaml +21 -0
  14. qaas/defaults/config/agents/surface.yaml +16 -0
  15. qaas/defaults/config/system.yaml +69 -0
  16. qaas/discover.py +227 -0
  17. qaas/envelope.py +290 -0
  18. qaas/guardrails.py +431 -0
  19. qaas/mcp/__init__.py +0 -0
  20. qaas/mcp/context.py +70 -0
  21. qaas/mcp/contract_diff.py +937 -0
  22. qaas/mcp/defect_memory.py +495 -0
  23. qaas/mcp/env_control.py +905 -0
  24. qaas/mcp/envelope_server.py +463 -0
  25. qaas/mcp/test_runner.py +773 -0
  26. qaas/mcp/tracker.py +412 -0
  27. qaas/mcp/vcs.py +506 -0
  28. qaas/paths.py +317 -0
  29. qaas/plugin/.claude-plugin/plugin.json +9 -0
  30. qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
  31. qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
  32. qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
  33. qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
  34. qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
  35. qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
  36. qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
  37. qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
  38. qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
  39. qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
  40. qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
  41. qaas/plugin/skills/flake-detection/SKILL.md +39 -0
  42. qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
  43. qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
  44. qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
  45. qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
  46. qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
  47. qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
  48. qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
  49. qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
  50. qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
  51. qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
  52. qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
  53. qaas/plugin/skills/routing-rules/SKILL.md +34 -0
  54. qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
  55. qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
  56. qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
  57. qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
  58. qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
  59. qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
  60. qaas/prompts/ARBITER.md +53 -0
  61. qaas/prompts/CARTOGRAPHER.md +46 -0
  62. qaas/prompts/CLERK.md +45 -0
  63. qaas/prompts/CONDUIT.md +44 -0
  64. qaas/prompts/FORGE.md +43 -0
  65. qaas/prompts/MENDER.md +55 -0
  66. qaas/prompts/PROOF.md +41 -0
  67. qaas/prompts/SURFACE.md +46 -0
  68. qaas/prompts/_shared.md +45 -0
  69. qaas/registry.py +465 -0
  70. qaas/runner.py +192 -0
  71. qaas/scorecard.py +425 -0
  72. qaas/sdk_compat.py +52 -0
  73. qaas/store.py +290 -0
  74. qaas/target.py +261 -0
  75. qaas/tasks.py +361 -0
  76. qaas/trace.py +270 -0
  77. qaas_python-0.1.0.dist-info/METADATA +388 -0
  78. qaas_python-0.1.0.dist-info/RECORD +81 -0
  79. qaas_python-0.1.0.dist-info/WHEEL +4 -0
  80. qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
  81. qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,905 @@
1
+ """The `env_control` MCP server — the environment an agent can actually name.
2
+
3
+ A defect envelope claims `reproduction.environment = {branch, fixture, flags}`.
4
+ That claim is only worth something if some component owns those three things and
5
+ can put them back. This server is that component: every tool here exists so a
6
+ later run (FORGE re-running a repro, PROOF verifying a fix) can stand the world
7
+ up in the same shape and get the same answer.
8
+
9
+ Two deliberate properties:
10
+
11
+ * **Nothing shells out through a shell.** Every subprocess is an argv list built
12
+ here from validated pieces, so a fixture name or a flag key coming out of a
13
+ model can never become a command.
14
+ * **Nothing hangs and nothing raises.** Docker can be absent, the daemon can be
15
+ down, the target app can be half-built. Each of those is a normal Tuesday, and
16
+ each returns an `err` naming the cause rather than a traceback or a stall.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import json
23
+ import os
24
+ import re
25
+ import shutil
26
+ import subprocess
27
+ import urllib.error
28
+ import urllib.request
29
+ from dataclasses import dataclass
30
+ from datetime import datetime
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ import yaml
35
+ from claude_agent_sdk import create_sdk_mcp_server, tool
36
+
37
+ from qaas.mcp.context import ToolContext, err, ok
38
+
39
+ #: Fallbacks only. Every one of these has a field on the target profile
40
+ #: (`environment.compose_file`, `environment.seed_sql`) that had existed since
41
+ #: the schema was written and that nothing read -- so the "portable" seam was
42
+ #: fiction, and anyone pointing this at their own repository hit a hardcoded
43
+ #: `target-app/api/seed/fixtures.sql` on their first run. These now apply only
44
+ #: when the profile is silent.
45
+ DEFAULT_COMPOSE_FILENAME = "docker-compose.yml"
46
+ DEFAULT_SEED_DIR = "api/seed"
47
+ DEFAULT_FIXTURE_FILE = "fixtures.sql"
48
+
49
+ # Kept as the old names so nothing importing them breaks.
50
+ COMPOSE_FILENAME = DEFAULT_COMPOSE_FILENAME
51
+ SEED_DIR = DEFAULT_SEED_DIR
52
+
53
+ #: Lets a test point the server at a docker that is not there, and lets an
54
+ #: operator point it at a non-PATH install, without either one editing code.
55
+ DOCKER_BIN_ENV = "QAAS_DOCKER_BIN"
56
+
57
+ DEFAULT_UP_TIMEOUT_S = 180
58
+ POLL_INTERVAL_S = 2.0
59
+ SEED_TIMEOUT_S = 120
60
+ SHORT_TIMEOUT_S = 30
61
+ LOGIN_TIMEOUT_S = 15
62
+
63
+ #: Impersonation is a real login against the running API rather than a
64
+ #: hand-minted JWT, so the token an agent receives is exactly the token a
65
+ #: browser would receive — an auth bug in issuance is visible to the agent
66
+ #: instead of bypassed by it. That part was always right.
67
+ #:
68
+ #: What was wrong: the accounts and the password were hardcoded to the bundled
69
+ #: demo. `profile.auth.roles` has existed all along, and `Role.password()`
70
+ #: already reads from an environment variable precisely so that a committed
71
+ #: profile never carries a credential. Until this was wired up, pointing qaas at
72
+ #: a real application with `auth.mode: login` would POST the literal string
73
+ #: below at that application's login endpoint. These are fallbacks for the
74
+ #: bundled demo now, used only when the profile declares no roles.
75
+ FALLBACK_USERS: dict[str, str] = {
76
+ "admin": "admin@northwind.test",
77
+ "member": "member@northwind.test",
78
+ "viewer": "viewer@northwind.test",
79
+ }
80
+ FALLBACK_PASSWORD = "password123"
81
+
82
+ SEEDED_USERS = FALLBACK_USERS # old name, kept for importers
83
+ SEEDED_PASSWORD = FALLBACK_PASSWORD
84
+
85
+ _FIXTURE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
86
+ _FLAG_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
87
+ _DML_RE = re.compile(r"\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+([A-Za-z_][A-Za-z0-9_.]*)", re.I)
88
+ _TAG_RE = re.compile(r"^(INSERT)\s+\d+\s+(\d+)$|^(UPDATE|DELETE)\s+(\d+)$")
89
+
90
+
91
+ # --------------------------------------------------------------------------
92
+ # subprocess plumbing
93
+ # --------------------------------------------------------------------------
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class _Proc:
98
+ """The result of one subprocess run. Never an exception."""
99
+
100
+ argv: list[str]
101
+ code: int
102
+ out: str
103
+ err: str
104
+ timed_out: bool = False
105
+
106
+ @property
107
+ def okay(self) -> bool:
108
+ return self.code == 0 and not self.timed_out
109
+
110
+ def tail(self, limit: int = 1200) -> str:
111
+ blob = (self.err or self.out).strip()
112
+ return blob[-limit:] if blob else "(no output)"
113
+
114
+
115
+ async def _exec(argv: list[str], timeout: float, *, stdin: bytes | None = None, cwd: Path | None = None) -> _Proc:
116
+ """Run argv with a hard deadline, capturing output. Kills on timeout."""
117
+ try:
118
+ proc = await asyncio.create_subprocess_exec(
119
+ *argv,
120
+ stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,
121
+ stdout=subprocess.PIPE,
122
+ stderr=subprocess.PIPE,
123
+ cwd=str(cwd) if cwd else None,
124
+ )
125
+ except (OSError, ValueError) as exc: # binary vanished between check and exec
126
+ return _Proc(argv, 127, "", str(exc))
127
+
128
+ try:
129
+ out, errout = await asyncio.wait_for(proc.communicate(stdin), timeout=timeout)
130
+ except (asyncio.TimeoutError, TimeoutError):
131
+ proc.kill()
132
+ try:
133
+ await proc.communicate()
134
+ except Exception: # noqa: BLE001 - the process is already gone; nothing to salvage
135
+ pass
136
+ return _Proc(argv, -1, "", f"timed out after {timeout:.0f}s", timed_out=True)
137
+
138
+ return _Proc(argv, proc.returncode or 0, out.decode(errors="replace"), errout.decode(errors="replace"))
139
+
140
+
141
+ def docker_bin() -> str | None:
142
+ """Path to the docker binary, or None if there is nothing usable."""
143
+ override = os.environ.get(DOCKER_BIN_ENV)
144
+ if override:
145
+ path = Path(override)
146
+ return override if path.is_file() and os.access(override, os.X_OK) else None
147
+ return shutil.which("docker")
148
+
149
+
150
+ # --------------------------------------------------------------------------
151
+ # compose introspection
152
+ # --------------------------------------------------------------------------
153
+
154
+
155
+ def _environment(ctx: ToolContext):
156
+ """The target's environment block, or None when no profile is loaded."""
157
+ profile = getattr(ctx.config, "profile", None)
158
+ return getattr(profile, "environment", None) if profile else None
159
+
160
+
161
+ def _compose_path(ctx: ToolContext) -> Path:
162
+ """The compose file this target declares, falling back to the usual name."""
163
+ env = _environment(ctx)
164
+ declared = getattr(env, "compose_file", None) if env else None
165
+ return ctx.target_root / (declared or DEFAULT_COMPOSE_FILENAME)
166
+
167
+
168
+ def _seed_dir(ctx: ToolContext) -> Path:
169
+ """Where fixture SQL lives for this target.
170
+
171
+ `environment.seed_sql` names a file; its directory is the fixture
172
+ directory, which lets a project keep fixtures anywhere without this server
173
+ having an opinion about `api/seed`.
174
+ """
175
+ env = _environment(ctx)
176
+ declared = getattr(env, "seed_sql", None) if env else None
177
+ if declared:
178
+ return (ctx.target_root / declared).parent.resolve()
179
+ return (ctx.target_root / DEFAULT_SEED_DIR).resolve()
180
+
181
+
182
+ def _login_path(ctx: ToolContext) -> str:
183
+ """The login path this target declares. `auth.login_endpoint` is written as
184
+ "POST /v1/auth/login", so the verb is stripped off."""
185
+ profile = getattr(ctx.config, "profile", None)
186
+ auth = getattr(profile, "auth", None) if profile else None
187
+ declared = getattr(auth, "login_endpoint", None) if auth else None
188
+ if not declared:
189
+ return "/v1/auth/login"
190
+ path = declared.split(None, 1)[-1].strip() if " " in declared else declared.strip()
191
+ return path if path.startswith("/") else f"/{path}"
192
+
193
+
194
+ def _roles(ctx: ToolContext) -> dict[str, tuple[str, str | None]]:
195
+ """role -> (username, password), from the profile where one declares them.
196
+
197
+ `profile.auth.roles` and `Role.password()` have existed since the schema was
198
+ written; nothing read them, so impersonation was welded to three demo
199
+ accounts and a literal password. Pointing qaas at a real application with
200
+ `auth.mode: login` would have POSTed that literal at its login endpoint.
201
+
202
+ Passwords come from the environment variable each role names, never from the
203
+ profile itself, because a profile is committed to a repository.
204
+ """
205
+ profile = getattr(ctx.config, "profile", None)
206
+ auth = getattr(profile, "auth", None) if profile else None
207
+ declared = getattr(auth, "roles", None) or {}
208
+ if declared:
209
+ return {name: (role.username, role.password()) for name, role in declared.items()}
210
+ # No roles declared: the bundled demo, whose fixture password is public and
211
+ # whose accounts exist only inside a throwaway container.
212
+ return {name: (email, FALLBACK_PASSWORD) for name, email in FALLBACK_USERS.items()}
213
+
214
+
215
+ def _default_fixture(ctx: ToolContext) -> str:
216
+ """The filename `seed(fixture="default")` means for this target."""
217
+ env = _environment(ctx)
218
+ declared = getattr(env, "seed_sql", None) if env else None
219
+ return Path(declared).name if declared else DEFAULT_FIXTURE_FILE
220
+
221
+
222
+ def _load_compose(path: Path) -> dict[str, Any]:
223
+ try:
224
+ doc = yaml.safe_load(path.read_text()) or {}
225
+ except (OSError, yaml.YAMLError):
226
+ return {}
227
+ return doc if isinstance(doc, dict) else {}
228
+
229
+
230
+ def _host_port(entry: Any) -> int | None:
231
+ """First host port from a compose `ports` entry, in any of its spellings."""
232
+ if isinstance(entry, int):
233
+ return entry
234
+ if isinstance(entry, dict):
235
+ published = entry.get("published")
236
+ return int(published) if str(published).isdigit() else None
237
+ if isinstance(entry, str):
238
+ head = entry.split(":")[0].split("/")[0]
239
+ return int(head) if head.isdigit() else None
240
+ return None
241
+
242
+
243
+ def _service_urls(compose: dict[str, Any]) -> dict[str, str]:
244
+ """Reachable URL per service, derived from the published ports.
245
+
246
+ A service with POSTGRES_* environment gets a libpq URL rather than an http
247
+ one, because that is what a caller would actually connect with.
248
+ """
249
+ urls: dict[str, str] = {}
250
+ for name, spec in (compose.get("services") or {}).items():
251
+ if not isinstance(spec, dict):
252
+ continue
253
+ ports = spec.get("ports") or []
254
+ port = next((p for p in (_host_port(e) for e in ports) if p), None)
255
+ if port is None:
256
+ continue
257
+ env = spec.get("environment") or {}
258
+ env = env if isinstance(env, dict) else {}
259
+ if "POSTGRES_DB" in env:
260
+ urls[name] = (
261
+ f"postgresql://{env.get('POSTGRES_USER', 'postgres')}:"
262
+ f"{env.get('POSTGRES_PASSWORD', '')}@localhost:{port}/{env.get('POSTGRES_DB')}"
263
+ )
264
+ else:
265
+ urls[name] = f"http://localhost:{port}"
266
+ return urls
267
+
268
+
269
+ def _db_service(compose: dict[str, Any]) -> tuple[str, dict[str, str]] | None:
270
+ """The postgres service and its credentials, if the compose file has one."""
271
+ for name, spec in (compose.get("services") or {}).items():
272
+ env = (spec or {}).get("environment") or {}
273
+ if isinstance(env, dict) and "POSTGRES_DB" in env:
274
+ return name, {str(k): str(v) for k, v in env.items()}
275
+ return None
276
+
277
+
278
+ def _parse_ps(raw: str) -> list[dict[str, Any]]:
279
+ """`docker compose ps --format json` — array in some versions, JSONL in others."""
280
+ text = raw.strip()
281
+ if not text:
282
+ return []
283
+ try:
284
+ doc = json.loads(text)
285
+ return doc if isinstance(doc, list) else [doc]
286
+ except json.JSONDecodeError:
287
+ pass
288
+ rows: list[dict[str, Any]] = []
289
+ for line in text.splitlines():
290
+ line = line.strip()
291
+ if not line:
292
+ continue
293
+ try:
294
+ row = json.loads(line)
295
+ except json.JSONDecodeError:
296
+ continue
297
+ if isinstance(row, dict):
298
+ rows.append(row)
299
+ return rows
300
+
301
+
302
+ def _service_state(row: dict[str, Any]) -> tuple[str, str]:
303
+ state = str(row.get("State") or row.get("Status") or "unknown").lower()
304
+ health = str(row.get("Health") or "").lower()
305
+ return state, health
306
+
307
+
308
+ def _is_ready(state: str, health: str) -> bool:
309
+ # No healthcheck means compose reports an empty Health; running is the best
310
+ # signal available, and pretending otherwise would hang forever on `web`.
311
+ if not state.startswith("running") and not state.startswith("up"):
312
+ return False
313
+ return health in ("", "healthy")
314
+
315
+
316
+ # --------------------------------------------------------------------------
317
+ # run-scoped state
318
+ # --------------------------------------------------------------------------
319
+
320
+
321
+ def _flags_path(ctx: ToolContext) -> Path:
322
+ """The flags file for this run.
323
+
324
+ Kept inside the run directory rather than in the app tree so a run cannot
325
+ leave residue in the repository under test, and so two runs never fight over
326
+ one file. The target API does not read it yet — see the note in `set_flag`.
327
+ """
328
+ return ctx.store.dir / "flags.json"
329
+
330
+
331
+ def _state_path(ctx: ToolContext) -> Path:
332
+ return ctx.store.dir / "env-state.json"
333
+
334
+
335
+ def _read_json(path: Path, default: dict[str, Any]) -> dict[str, Any]:
336
+ if not path.exists():
337
+ return dict(default)
338
+ try:
339
+ doc = json.loads(path.read_text())
340
+ except (OSError, json.JSONDecodeError):
341
+ return dict(default)
342
+ return doc if isinstance(doc, dict) else dict(default)
343
+
344
+
345
+ def _write_json(path: Path, payload: dict[str, Any]) -> None:
346
+ path.parent.mkdir(parents=True, exist_ok=True)
347
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True))
348
+
349
+
350
+ def _read_flags(ctx: ToolContext) -> dict[str, Any]:
351
+ return _read_json(_flags_path(ctx), {"flags": {}, "clock": None})
352
+
353
+
354
+ def _read_state(ctx: ToolContext) -> dict[str, Any]:
355
+ return _read_json(_state_path(ctx), {"fixture": None, "branch": None, "services": []})
356
+
357
+
358
+ def _current_branch(target_root: Path) -> str | None:
359
+ """Best-effort git branch. Absent git is not an error here."""
360
+ git = shutil.which("git")
361
+ if git is None:
362
+ return None
363
+ try:
364
+ proc = subprocess.run( # noqa: S603 - fixed argv, no shell
365
+ [git, "rev-parse", "--abbrev-ref", "HEAD"],
366
+ cwd=str(target_root), capture_output=True, text=True, timeout=10,
367
+ )
368
+ except (OSError, subprocess.SubprocessError):
369
+ return None
370
+ return proc.stdout.strip() or None
371
+
372
+
373
+ # --------------------------------------------------------------------------
374
+ # tools
375
+ # --------------------------------------------------------------------------
376
+
377
+
378
+ def build_tools(ctx: ToolContext) -> list:
379
+ """The env_control tools, bound to one agent's run context.
380
+
381
+ Split from `build` so tests can call the handlers directly without standing
382
+ up an MCP transport.
383
+ """
384
+
385
+ compose_file = _compose_path(ctx)
386
+
387
+ def preflight(*, needs_docker: bool = True) -> dict[str, Any] | None:
388
+ """One gate for the two ways this server can be unusable."""
389
+ if not compose_file.exists():
390
+ return err(
391
+ f"No compose file at {compose_file}. This server drives the target app "
392
+ "through docker compose; without that file there is no environment to control."
393
+ )
394
+ if needs_docker and docker_bin() is None:
395
+ override = os.environ.get(DOCKER_BIN_ENV)
396
+ where = f"{DOCKER_BIN_ENV}={override}" if override else "PATH"
397
+ return err(
398
+ f"The `docker` binary was not found ({where}). Every environment tool here "
399
+ "bottoms out in `docker compose`, so nothing can be started, seeded or reset. "
400
+ "Install Docker or unset the override, then retry."
401
+ )
402
+ return None
403
+
404
+ def compose_argv(*args: str) -> list[str]:
405
+ return [docker_bin() or "docker", "compose", "-f", str(compose_file), *args]
406
+
407
+ async def ps_rows(timeout: float = SHORT_TIMEOUT_S) -> tuple[list[dict[str, Any]], _Proc]:
408
+ proc = await _exec(compose_argv("ps", "--all", "--format", "json"), timeout, cwd=ctx.target_root)
409
+ return (_parse_ps(proc.out) if proc.okay else []), proc
410
+
411
+ async def wait_ready(services: list[str], deadline: float) -> tuple[dict[str, dict[str, str]], str | None]:
412
+ """Poll compose until every named service is ready or the deadline passes."""
413
+ loop = asyncio.get_running_loop()
414
+ observed: dict[str, dict[str, str]] = {}
415
+ while True:
416
+ rows, proc = await ps_rows()
417
+ if not proc.okay:
418
+ return observed, f"`docker compose ps` failed: {proc.tail()}"
419
+ observed = {}
420
+ for row in rows:
421
+ name = str(row.get("Service") or row.get("Name") or "")
422
+ if not name:
423
+ continue
424
+ state, health = _service_state(row)
425
+ observed[name] = {"state": state, "health": health}
426
+
427
+ dead = [
428
+ n for n in services
429
+ if n in observed and observed[n]["state"].startswith(("exited", "dead"))
430
+ ]
431
+ if dead:
432
+ return observed, f"service(s) exited during startup: {', '.join(sorted(dead))}"
433
+
434
+ pending = [
435
+ n for n in services
436
+ if n not in observed or not _is_ready(observed[n]["state"], observed[n]["health"])
437
+ ]
438
+ if not pending:
439
+ return observed, None
440
+ if loop.time() >= deadline:
441
+ return observed, f"timed out waiting for: {', '.join(sorted(pending))}"
442
+ await asyncio.sleep(POLL_INTERVAL_S)
443
+
444
+ async def run_seed(fixture: str) -> dict[str, Any]:
445
+ """Pipe a fixture file into psql inside the db container."""
446
+ compose = _load_compose(compose_file)
447
+ db = _db_service(compose)
448
+ if db is None:
449
+ return err(f"{compose_file} declares no postgres service, so there is nothing to seed.")
450
+ db_name, db_env = db
451
+
452
+ if not _FIXTURE_RE.match(fixture):
453
+ return err(
454
+ f"Fixture name '{fixture}' is not a bare filename. "
455
+ "Pass a name like 'default' or 'refunds', never a path."
456
+ )
457
+ filename = _default_fixture(ctx) if fixture == "default" else (
458
+ fixture if fixture.endswith(".sql") else f"{fixture}.sql"
459
+ )
460
+ seed_dir = _seed_dir(ctx)
461
+ path = (seed_dir / filename).resolve()
462
+ if not path.is_relative_to(seed_dir):
463
+ return err(f"Fixture '{fixture}' resolves outside {seed_dir}.")
464
+ if not path.exists():
465
+ available = sorted(p.stem for p in seed_dir.glob("*.sql")) if seed_dir.exists() else []
466
+ return err(
467
+ f"No fixture file at {path}. "
468
+ + (f"Available: {', '.join(available)}." if available else "The seed directory is empty or absent.")
469
+ )
470
+
471
+ sql = path.read_bytes()
472
+ proc = await _exec(
473
+ compose_argv(
474
+ "exec", "-T", db_name,
475
+ "psql", "-U", db_env.get("POSTGRES_USER", "postgres"),
476
+ "-d", db_env.get("POSTGRES_DB", "postgres"),
477
+ "-v", "ON_ERROR_STOP=1",
478
+ ),
479
+ SEED_TIMEOUT_S,
480
+ stdin=sql,
481
+ cwd=ctx.target_root,
482
+ )
483
+ if proc.timed_out:
484
+ return err(f"Seeding timed out after {SEED_TIMEOUT_S}s. Is the '{db_name}' service healthy?")
485
+ if not proc.okay:
486
+ return err(f"Seeding '{fixture}' failed: {proc.tail()}")
487
+
488
+ rows = _rows_affected(sql.decode(errors="replace"), proc.out)
489
+ state = _read_state(ctx)
490
+ state["fixture"] = fixture
491
+ _write_json(_state_path(ctx), state)
492
+ ctx.store.log("env", agent=ctx.agent.name, action="seed", fixture=fixture, rows=rows)
493
+ summary = ", ".join(f"{t}={n}" for t, n in sorted(rows.items())) or "no row counts reported by psql"
494
+ return ok(f"Seeded fixture '{fixture}' from {path.name}. Rows affected: {summary}.",
495
+ fixture=fixture, rows_affected=rows, path=str(path))
496
+
497
+ @tool(
498
+ "spin_up",
499
+ "Start the target app with docker compose and wait for its health checks. "
500
+ "Returns each service's status and URL. Call this before anything that touches the app.",
501
+ {
502
+ "type": "object",
503
+ "properties": {
504
+ "branch": {"type": "string", "description": "Branch this environment is meant to represent. Checked against the working tree, never checked out for you."},
505
+ "services": {"type": "array", "items": {"type": "string"}, "description": "Subset of services to start. Default: all of them."},
506
+ "timeout_s": {"type": "integer", "minimum": 10, "maximum": 900, "description": f"Overall budget. Default {DEFAULT_UP_TIMEOUT_S}s."},
507
+ },
508
+ },
509
+ )
510
+ async def spin_up(args: dict[str, Any]) -> dict[str, Any]:
511
+ gate = preflight()
512
+ if gate:
513
+ return gate
514
+
515
+ compose = _load_compose(compose_file)
516
+ declared = list((compose.get("services") or {}).keys())
517
+ if not declared:
518
+ return err(f"{compose_file} declares no services; it is empty or malformed.")
519
+
520
+ requested = args.get("services") or declared
521
+ unknown = [s for s in requested if s not in declared]
522
+ if unknown:
523
+ return err(f"Unknown service(s): {', '.join(unknown)}. This compose file has: {', '.join(declared)}.")
524
+
525
+ branch = args.get("branch")
526
+ if branch:
527
+ actual = _current_branch(ctx.target_root)
528
+ if actual and actual != branch:
529
+ return err(
530
+ f"You asked for branch '{branch}' but the working tree is on '{actual}'. "
531
+ "This server builds whatever is checked out; it will not move your tree. "
532
+ "Check the branch out first, or call spin_up without `branch` and accept "
533
+ f"'{actual}' as the environment identity."
534
+ )
535
+
536
+ timeout = float(args.get("timeout_s") or DEFAULT_UP_TIMEOUT_S)
537
+ deadline = asyncio.get_running_loop().time() + timeout
538
+
539
+ up = await _exec(compose_argv("up", "-d", *requested), timeout, cwd=ctx.target_root)
540
+ if up.timed_out:
541
+ return err(
542
+ f"`docker compose up -d` did not finish within {timeout:.0f}s. "
543
+ "The image build is the usual culprit; build it once by hand "
544
+ "(`docker compose build`) and retry, or raise timeout_s."
545
+ )
546
+ if not up.okay:
547
+ return err(
548
+ "`docker compose up -d` failed. The target app may not be built yet. "
549
+ f"Compose said: {up.tail()}"
550
+ )
551
+
552
+ observed, problem = await wait_ready(list(requested), deadline)
553
+ urls = _service_urls(compose)
554
+ report = {
555
+ name: {
556
+ "state": observed.get(name, {}).get("state", "missing"),
557
+ "health": observed.get(name, {}).get("health") or "n/a",
558
+ "url": urls.get(name),
559
+ }
560
+ for name in requested
561
+ }
562
+
563
+ state = _read_state(ctx)
564
+ state["services"] = list(requested)
565
+ state["branch"] = branch or _current_branch(ctx.target_root)
566
+ _write_json(_state_path(ctx), state)
567
+ ctx.store.log("env", agent=ctx.agent.name, action="spin_up", services=list(requested), problem=problem)
568
+
569
+ if problem:
570
+ logs = await _exec(compose_argv("logs", "--tail", "40", *requested), SHORT_TIMEOUT_S, cwd=ctx.target_root)
571
+ return err(
572
+ f"Environment did not come up cleanly: {problem}. "
573
+ f"Status: {json.dumps(report)}. Last log lines: {logs.tail(2000)}"
574
+ )
575
+
576
+ lines = "; ".join(f"{n}: {v['state']}/{v['health']} {v['url'] or ''}".strip() for n, v in report.items())
577
+ return ok(f"Environment up ({lines}).", services=report, branch=state["branch"])
578
+
579
+ @tool(
580
+ "seed",
581
+ "Load a named fixture into the database. 'default' means fixtures.sql. "
582
+ "Reports rows affected per table where psql tells us.",
583
+ {
584
+ "type": "object",
585
+ "required": ["fixture"],
586
+ "properties": {"fixture": {"type": "string", "description": "Bare fixture name, e.g. 'default'."}},
587
+ },
588
+ )
589
+ async def seed(args: dict[str, Any]) -> dict[str, Any]:
590
+ gate = preflight()
591
+ if gate:
592
+ return gate
593
+ return await run_seed(str(args["fixture"]))
594
+
595
+ @tool(
596
+ "reset",
597
+ "Return the database to its post-seed state: recreate the db container (its data is tmpfs, "
598
+ "so this really is a wipe), then re-apply the fixture that was last seeded.",
599
+ {"type": "object", "properties": {"fixture": {"type": "string", "description": "Override the fixture to re-apply. Default: whatever seed() last loaded."}}},
600
+ )
601
+ async def reset(args: dict[str, Any]) -> dict[str, Any]:
602
+ gate = preflight()
603
+ if gate:
604
+ return gate
605
+
606
+ compose = _load_compose(compose_file)
607
+ db = _db_service(compose)
608
+ if db is None:
609
+ return err(f"{compose_file} declares no postgres service, so there is nothing to reset.")
610
+ db_name, _ = db
611
+
612
+ state = _read_state(ctx)
613
+ fixture = str(args.get("fixture") or state.get("fixture") or "default")
614
+
615
+ deadline = asyncio.get_running_loop().time() + DEFAULT_UP_TIMEOUT_S
616
+ down = await _exec(compose_argv("rm", "--stop", "--force", "--volumes", db_name), SHORT_TIMEOUT_S * 2, cwd=ctx.target_root)
617
+ if not down.okay and not down.timed_out:
618
+ return err(f"Could not remove the '{db_name}' container: {down.tail()}")
619
+
620
+ up = await _exec(compose_argv("up", "-d", db_name), SHORT_TIMEOUT_S * 4, cwd=ctx.target_root)
621
+ if not up.okay:
622
+ return err(f"Could not restart '{db_name}': {up.tail()}")
623
+
624
+ observed, problem = await wait_ready([db_name], deadline)
625
+ if problem:
626
+ return err(f"'{db_name}' did not become healthy after reset: {problem}. Status: {json.dumps(observed)}")
627
+
628
+ # The API holds a connection pool that the recreated database just
629
+ # invalidated. Restarting it is part of "back to post-seed state"; a
630
+ # stale pool would surface as spurious 500s and a bogus finding.
631
+ restarted = False
632
+ rows, _ = await ps_rows()
633
+ api_services = [
634
+ str(r.get("Service") or "")
635
+ for r in rows
636
+ if str(r.get("Service") or "") not in ("", db_name) and _service_state(r)[0].startswith(("running", "up"))
637
+ ]
638
+ if api_services:
639
+ restart = await _exec(compose_argv("restart", *api_services), SHORT_TIMEOUT_S * 2, cwd=ctx.target_root)
640
+ restarted = restart.okay
641
+ _, problem = await wait_ready(api_services, asyncio.get_running_loop().time() + DEFAULT_UP_TIMEOUT_S)
642
+ if problem:
643
+ return err(f"Dependent services did not recover after the db reset: {problem}")
644
+
645
+ seeded = await run_seed(fixture)
646
+ if seeded.get("isError"):
647
+ return seeded
648
+
649
+ ctx.store.log("env", agent=ctx.agent.name, action="reset", fixture=fixture)
650
+ detail = seeded.get("structuredContent", {})
651
+ return ok(
652
+ f"Reset complete: '{db_name}' recreated, "
653
+ f"{'dependents restarted, ' if restarted else ''}fixture '{fixture}' re-applied.",
654
+ fixture=fixture,
655
+ rows_affected=detail.get("rows_affected", {}),
656
+ restarted=api_services if restarted else [],
657
+ )
658
+
659
+ @tool(
660
+ "set_flag",
661
+ "Set one feature flag for this run. Flags are part of the environment an envelope names, "
662
+ "so anything you toggle here belongs in reproduction.environment.flags.",
663
+ {
664
+ "type": "object",
665
+ "required": ["key", "value"],
666
+ "properties": {
667
+ "key": {"type": "string", "description": "Flag name, e.g. 'checkout.new_review_step'."},
668
+ "value": {"description": "Any JSON value: boolean, string, number."},
669
+ },
670
+ },
671
+ )
672
+ async def set_flag(args: dict[str, Any]) -> dict[str, Any]:
673
+ gate = preflight(needs_docker=False)
674
+ if gate:
675
+ return gate
676
+ key = str(args["key"])
677
+ if not _FLAG_KEY_RE.match(key):
678
+ return err(f"Flag key '{key}' is not a plain identifier (letters, digits, . _ -, max 64 chars).")
679
+ doc = _read_flags(ctx)
680
+ doc.setdefault("flags", {})[key] = args["value"]
681
+ _write_json(_flags_path(ctx), doc)
682
+ ctx.store.log("env", agent=ctx.agent.name, action="set_flag", key=key, value=args["value"])
683
+ return ok(
684
+ f"Flag '{key}' = {json.dumps(args['value'])} recorded in {_flags_path(ctx)}. "
685
+ "Note: the target API does not read this file yet, so treat the flag as run metadata "
686
+ "until the app is wired to it — do not claim behaviour changed because of it.",
687
+ flags=doc["flags"], path=str(_flags_path(ctx)),
688
+ )
689
+
690
+ @tool(
691
+ "get_flags",
692
+ "Read the flags and clock override in force for this run.",
693
+ {"type": "object", "properties": {}},
694
+ )
695
+ async def get_flags(args: dict[str, Any]) -> dict[str, Any]:
696
+ gate = preflight(needs_docker=False)
697
+ if gate:
698
+ return gate
699
+ doc = _read_flags(ctx)
700
+ return ok(
701
+ json.dumps(doc, indent=2, sort_keys=True),
702
+ flags=doc.get("flags", {}), clock=doc.get("clock"), path=str(_flags_path(ctx)),
703
+ )
704
+
705
+ @tool(
706
+ "set_clock",
707
+ "Record a clock override for this run, so a time-dependent repro names the time it assumed.",
708
+ {
709
+ "type": "object",
710
+ "required": ["iso_timestamp"],
711
+ "properties": {"iso_timestamp": {"type": "string", "description": "ISO-8601, e.g. '2026-01-31T23:59:59Z'."}},
712
+ },
713
+ )
714
+ async def set_clock(args: dict[str, Any]) -> dict[str, Any]:
715
+ gate = preflight(needs_docker=False)
716
+ if gate:
717
+ return gate
718
+ raw = str(args["iso_timestamp"]).strip()
719
+ try:
720
+ datetime.fromisoformat(raw.replace("Z", "+00:00"))
721
+ except ValueError:
722
+ return err(f"'{raw}' is not an ISO-8601 timestamp. Use e.g. '2026-01-31T23:59:59Z'.")
723
+ doc = _read_flags(ctx)
724
+ doc["clock"] = raw
725
+ _write_json(_flags_path(ctx), doc)
726
+ ctx.store.log("env", agent=ctx.agent.name, action="set_clock", clock=raw)
727
+ return ok(
728
+ f"Clock override for this run recorded as {raw} in {_flags_path(ctx)}. "
729
+ "The target app does not consume the override yet; record it in the envelope environment "
730
+ "rather than asserting the app moved in time.",
731
+ clock=raw, path=str(_flags_path(ctx)),
732
+ )
733
+
734
+ @tool(
735
+ "impersonate",
736
+ "Get a real bearer token for a seeded user with the given role, by logging in against the "
737
+ "running API. Use the token in an Authorization header exactly as a browser would.",
738
+ {
739
+ "type": "object",
740
+ "required": ["role"],
741
+ "properties": {"role": {"type": "string", "description": "A role this target declares under auth.roles."}},
742
+ },
743
+ )
744
+ async def impersonate(args: dict[str, Any]) -> dict[str, Any]:
745
+ gate = preflight(needs_docker=False)
746
+ if gate:
747
+ return gate
748
+ role = str(args["role"]).lower()
749
+ roles = _roles(ctx)
750
+ entry = roles.get(role)
751
+ if entry is None:
752
+ return err(
753
+ f"No role '{role}' for this target. Declared roles: "
754
+ f"{', '.join(sorted(roles)) or '(none -- set auth.roles in the target profile)'}."
755
+ )
756
+ email, password = entry
757
+ if not password:
758
+ profile = getattr(ctx.config, "profile", None)
759
+ auth = getattr(profile, "auth", None) if profile else None
760
+ var = getattr(getattr(auth, "roles", {}).get(role, None), "password_env", "QAAS_PASSWORD")
761
+ return err(
762
+ f"Role '{role}' names environment variable {var} for its password and it is unset. "
763
+ "Export it and try again -- credentials are never read from the profile itself."
764
+ )
765
+
766
+ compose = _load_compose(compose_file)
767
+ urls = _service_urls(compose)
768
+ base = os.environ.get("QAAS_TARGET_BASE_URL") or next(
769
+ (u for n, u in urls.items() if u.startswith("http") and n != "web"), None
770
+ )
771
+ if base is None:
772
+ return err("No HTTP service with a published port in the compose file; nowhere to log in.")
773
+
774
+ # Field names come from the profile: not every API calls them
775
+ # "email" and "password".
776
+ profile = getattr(ctx.config, "profile", None)
777
+ auth = getattr(profile, "auth", None) if profile else None
778
+ user_field = getattr(auth, "username_field", None) or "email"
779
+ pass_field = getattr(auth, "password_field", None) or "password"
780
+ payload = json.dumps({user_field: email, pass_field: password}).encode()
781
+ request = urllib.request.Request(
782
+ f"{base.rstrip('/')}{_login_path(ctx)}", data=payload,
783
+ headers={"Content-Type": "application/json"}, method="POST",
784
+ )
785
+
786
+ def _post() -> tuple[int, str]:
787
+ try:
788
+ with urllib.request.urlopen(request, timeout=LOGIN_TIMEOUT_S) as resp: # noqa: S310 - fixed http scheme
789
+ return resp.status, resp.read().decode(errors="replace")
790
+ except urllib.error.HTTPError as exc:
791
+ return exc.code, exc.read().decode(errors="replace")
792
+ except (urllib.error.URLError, OSError, TimeoutError) as exc:
793
+ return 0, str(exc)
794
+
795
+ status, body = await asyncio.to_thread(_post)
796
+ if status == 0:
797
+ return err(
798
+ f"Could not reach the API at {base} ({body}). Call spin_up first; "
799
+ "if it is up, the api service is not healthy."
800
+ )
801
+ if status != 200:
802
+ return err(
803
+ f"Login as {email} returned HTTP {status}: {body[:400]}. "
804
+ "The database may not be seeded — call seed('default')."
805
+ )
806
+ try:
807
+ doc = json.loads(body)
808
+ except json.JSONDecodeError:
809
+ return err(f"Login returned HTTP 200 but not JSON: {body[:200]}")
810
+ token = doc.get("access_token")
811
+ if not token:
812
+ return err(f"Login response has no access_token: {body[:200]}")
813
+
814
+ ctx.store.log("env", agent=ctx.agent.name, action="impersonate", role=role, email=email)
815
+ return ok(
816
+ f"Signed in as {email} ({doc.get('role', role)}). Send header: Authorization: Bearer <token>.",
817
+ role=doc.get("role", role), email=email, token=token, base_url=base,
818
+ )
819
+
820
+ @tool(
821
+ "status",
822
+ "What is running, which fixture is loaded, which flags are set. "
823
+ "Read this before assuming the environment is in the state you left it.",
824
+ {"type": "object", "properties": {}},
825
+ )
826
+ async def status(args: dict[str, Any]) -> dict[str, Any]:
827
+ gate = preflight()
828
+ if gate:
829
+ return gate
830
+ compose = _load_compose(compose_file)
831
+ urls = _service_urls(compose)
832
+ rows, proc = await ps_rows()
833
+ if not proc.okay:
834
+ return err(
835
+ "`docker compose ps` failed, so the environment state is unknown: "
836
+ f"{proc.tail()}"
837
+ )
838
+ services = {}
839
+ for row in rows:
840
+ name = str(row.get("Service") or row.get("Name") or "")
841
+ if not name:
842
+ continue
843
+ state, health = _service_state(row)
844
+ services[name] = {"state": state, "health": health or "n/a", "url": urls.get(name)}
845
+
846
+ env_state = _read_state(ctx)
847
+ flags = _read_flags(ctx)
848
+ payload = {
849
+ "services": services,
850
+ "fixture": env_state.get("fixture"),
851
+ "branch": env_state.get("branch") or _current_branch(ctx.target_root),
852
+ "flags": flags.get("flags", {}),
853
+ "clock": flags.get("clock"),
854
+ }
855
+ running = [n for n, v in services.items() if v["state"].startswith(("running", "up"))]
856
+ headline = f"{len(running)} service(s) running: {', '.join(sorted(running))}." if running else "Nothing is running."
857
+ return ok(f"{headline} Fixture: {payload['fixture'] or 'none loaded'}.", **payload)
858
+
859
+ @tool(
860
+ "tear_down",
861
+ "Stop the target app and delete its volumes. Call when you are done; leaving it up costs "
862
+ "the next run a confusing dirty state.",
863
+ {"type": "object", "properties": {}},
864
+ )
865
+ async def tear_down(args: dict[str, Any]) -> dict[str, Any]:
866
+ gate = preflight()
867
+ if gate:
868
+ return gate
869
+ proc = await _exec(compose_argv("down", "-v"), SHORT_TIMEOUT_S * 4, cwd=ctx.target_root)
870
+ if proc.timed_out:
871
+ return err(f"`docker compose down -v` timed out after {SHORT_TIMEOUT_S * 4}s; containers may still be up.")
872
+ if not proc.okay:
873
+ return err(f"`docker compose down -v` failed: {proc.tail()}")
874
+ state = _read_state(ctx)
875
+ state.update({"services": [], "fixture": None})
876
+ _write_json(_state_path(ctx), state)
877
+ ctx.store.log("env", agent=ctx.agent.name, action="tear_down")
878
+ return ok("Environment torn down and volumes removed.")
879
+
880
+ return [spin_up, seed, reset, set_flag, get_flags, set_clock, impersonate, status, tear_down]
881
+
882
+
883
+ def _rows_affected(sql: str, psql_output: str) -> dict[str, int]:
884
+ """Pair psql's command tags with the DML statements that produced them.
885
+
886
+ psql reports `INSERT 0 12` without naming the table, so the only way to
887
+ attribute counts is positionally: the nth tag belongs to the nth DML
888
+ statement. If the two sequences disagree in length we report the prefix we
889
+ can trust rather than guessing — a wrong row count is worse than none.
890
+ """
891
+ targets = [m.group(2) for m in _DML_RE.finditer(sql)]
892
+ counts: list[int] = []
893
+ for line in psql_output.splitlines():
894
+ match = _TAG_RE.match(line.strip())
895
+ if match:
896
+ counts.append(int(match.group(2) or match.group(4) or 0))
897
+ rows: dict[str, int] = {}
898
+ for table, count in zip(targets, counts):
899
+ rows[table] = rows.get(table, 0) + count
900
+ return rows
901
+
902
+
903
+ def build(ctx: ToolContext):
904
+ """Construct the env_control MCP server bound to one agent's run context."""
905
+ return create_sdk_mcp_server(name="env_control", version="1.0.0", tools=build_tools(ctx))