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.
- qaas/adapters/__init__.py +19 -0
- qaas/adapters/tracker.py +1350 -0
- qaas/adapters/vcs.py +494 -0
- qaas/cli.py +1564 -0
- qaas/conductor.py +527 -0
- qaas/config.py +407 -0
- qaas/defaults/config/agents/arbiter.yaml +19 -0
- qaas/defaults/config/agents/cartographer.yaml +20 -0
- qaas/defaults/config/agents/clerk.yaml +21 -0
- qaas/defaults/config/agents/conduit.yaml +19 -0
- qaas/defaults/config/agents/forge.yaml +22 -0
- qaas/defaults/config/agents/mender.yaml +56 -0
- qaas/defaults/config/agents/proof.yaml +21 -0
- qaas/defaults/config/agents/surface.yaml +16 -0
- qaas/defaults/config/system.yaml +69 -0
- qaas/discover.py +227 -0
- qaas/envelope.py +290 -0
- qaas/guardrails.py +431 -0
- qaas/mcp/__init__.py +0 -0
- qaas/mcp/context.py +70 -0
- qaas/mcp/contract_diff.py +937 -0
- qaas/mcp/defect_memory.py +495 -0
- qaas/mcp/env_control.py +905 -0
- qaas/mcp/envelope_server.py +463 -0
- qaas/mcp/test_runner.py +773 -0
- qaas/mcp/tracker.py +412 -0
- qaas/mcp/vcs.py +506 -0
- qaas/paths.py +317 -0
- qaas/plugin/.claude-plugin/plugin.json +9 -0
- qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
- qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
- qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
- qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
- qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
- qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
- qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
- qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
- qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
- qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
- qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
- qaas/plugin/skills/flake-detection/SKILL.md +39 -0
- qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
- qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
- qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
- qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
- qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
- qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
- qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
- qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
- qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
- qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
- qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
- qaas/plugin/skills/routing-rules/SKILL.md +34 -0
- qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
- qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
- qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
- qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
- qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
- qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
- qaas/prompts/ARBITER.md +53 -0
- qaas/prompts/CARTOGRAPHER.md +46 -0
- qaas/prompts/CLERK.md +45 -0
- qaas/prompts/CONDUIT.md +44 -0
- qaas/prompts/FORGE.md +43 -0
- qaas/prompts/MENDER.md +55 -0
- qaas/prompts/PROOF.md +41 -0
- qaas/prompts/SURFACE.md +46 -0
- qaas/prompts/_shared.md +45 -0
- qaas/registry.py +465 -0
- qaas/runner.py +192 -0
- qaas/scorecard.py +425 -0
- qaas/sdk_compat.py +52 -0
- qaas/store.py +290 -0
- qaas/target.py +261 -0
- qaas/tasks.py +361 -0
- qaas/trace.py +270 -0
- qaas_python-0.1.0.dist-info/METADATA +388 -0
- qaas_python-0.1.0.dist-info/RECORD +81 -0
- qaas_python-0.1.0.dist-info/WHEEL +4 -0
- qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
- qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
qaas/store.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Run state on disk: the ledger, the artifact store, the versioned system map.
|
|
2
|
+
|
|
3
|
+
Everything an agent produces lands here. The ledger is the audit trail the
|
|
4
|
+
architecture asks for (§2, §8) — every agent invocation, every cost, every
|
|
5
|
+
guardrail denial, appended and never rewritten.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Iterator
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
20
|
+
|
|
21
|
+
from qaas.envelope import DefectEnvelope
|
|
22
|
+
|
|
23
|
+
DEFAULT_ROOT = Path(".qaas")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _utcnow() -> datetime:
|
|
27
|
+
return datetime.now(timezone.utc)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LedgerKind(StrEnum):
|
|
31
|
+
"""Every kind of line the ledger may contain.
|
|
32
|
+
|
|
33
|
+
This was a bare `str` whose comment named 7 of the 28 kinds actually
|
|
34
|
+
written, which left the ledger unreadable by anything but grep: nothing
|
|
35
|
+
could enumerate what a run might contain, and a typo at a `store.log()`
|
|
36
|
+
call site invented a 29th kind that no reader would ever look for. It is a
|
|
37
|
+
closed set now, so a misspelling fails at the write instead of vanishing.
|
|
38
|
+
|
|
39
|
+
`StrEnum` and not a plain `Enum` on purpose: members *are* their strings, so
|
|
40
|
+
`entry.kind == "denial"` still holds, `model_dump_json()` still writes
|
|
41
|
+
`"kind":"denial"`, and every ledger already on disk still parses. Adding a
|
|
42
|
+
kind means adding a member here -- deliberately a visible act, because the
|
|
43
|
+
conductor reads several of these back for control flow (`_latest_verdict`,
|
|
44
|
+
`_latest_review`, `_branch_written_since`), so a rename is a breaking
|
|
45
|
+
change to a wire format, not a rename.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
# run lifecycle (conductor)
|
|
49
|
+
RUN_STARTED = "run_started"
|
|
50
|
+
RUN_FINISHED = "run_finished"
|
|
51
|
+
SKIPPED = "skipped"
|
|
52
|
+
ESCALATION = "escalation"
|
|
53
|
+
|
|
54
|
+
# agent lifecycle (runner, store)
|
|
55
|
+
AGENT_STARTED = "agent_started"
|
|
56
|
+
AGENT_FINISHED = "agent_finished"
|
|
57
|
+
AGENT_ERROR = "agent_error"
|
|
58
|
+
SKILLS_MISSING = "skills_missing"
|
|
59
|
+
|
|
60
|
+
# tool traffic and its refusals (guardrails, registry)
|
|
61
|
+
TOOL_CALL = "tool_call"
|
|
62
|
+
TOOL_ERROR = "tool_error"
|
|
63
|
+
DENIAL = "denial"
|
|
64
|
+
STOP_BLOCKED = "stop_blocked"
|
|
65
|
+
CONTRACT_UNMET = "contract_unmet"
|
|
66
|
+
|
|
67
|
+
# findings and the evidence behind them
|
|
68
|
+
ENVELOPE = "envelope"
|
|
69
|
+
REPRODUCTION = "reproduction"
|
|
70
|
+
CONTRACT_TEST = "contract_test"
|
|
71
|
+
SYSTEM_MAP = "system_map"
|
|
72
|
+
DEFECT_MEMORY = "defect_memory"
|
|
73
|
+
REGRESSION = "regression"
|
|
74
|
+
|
|
75
|
+
# the file/verify/fix loop
|
|
76
|
+
TICKET = "ticket"
|
|
77
|
+
VERDICT = "verdict"
|
|
78
|
+
VERIFIED = "verified"
|
|
79
|
+
REOPENED = "reopened"
|
|
80
|
+
REVIEW = "review"
|
|
81
|
+
REVIEW_ROUND_TRIP = "review_round_trip"
|
|
82
|
+
|
|
83
|
+
# side effects on the world outside the run
|
|
84
|
+
VCS = "vcs"
|
|
85
|
+
ENV = "env"
|
|
86
|
+
DRY_RUN = "dry_run"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class LedgerEntry(BaseModel):
|
|
90
|
+
"""One line in the run ledger. Append-only."""
|
|
91
|
+
|
|
92
|
+
model_config = ConfigDict(extra="forbid")
|
|
93
|
+
|
|
94
|
+
at: datetime = Field(default_factory=_utcnow)
|
|
95
|
+
kind: LedgerKind
|
|
96
|
+
agent: str | None = None
|
|
97
|
+
detail: dict[str, Any] = Field(default_factory=dict)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class AgentResult(BaseModel):
|
|
101
|
+
"""What one agent invocation cost and produced."""
|
|
102
|
+
|
|
103
|
+
model_config = ConfigDict(extra="forbid")
|
|
104
|
+
|
|
105
|
+
agent: str
|
|
106
|
+
subtype: str = "success"
|
|
107
|
+
cost_usd: float = 0.0
|
|
108
|
+
num_turns: int = 0
|
|
109
|
+
duration_s: float = 0.0
|
|
110
|
+
envelope_ids: list[str] = Field(default_factory=list)
|
|
111
|
+
error: str | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class RunStore:
|
|
115
|
+
"""The filesystem home of a single run.
|
|
116
|
+
|
|
117
|
+
Layout:
|
|
118
|
+
.qaas/runs/<run_id>/ledger.jsonl
|
|
119
|
+
envelopes/<envelope_id>.json
|
|
120
|
+
artifacts/<name>
|
|
121
|
+
results/<AGENT>.json
|
|
122
|
+
.qaas/system-map/<version>.json (shared across runs)
|
|
123
|
+
.qaas/system-map/latest (pointer file)
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, run_id: str, root: Path | str = DEFAULT_ROOT):
|
|
127
|
+
self.run_id = run_id
|
|
128
|
+
self.root = Path(root)
|
|
129
|
+
self.dir = self.root / "runs" / run_id
|
|
130
|
+
for sub in ("envelopes", "artifacts", "results"):
|
|
131
|
+
(self.dir / sub).mkdir(parents=True, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def new(cls, root: Path | str = DEFAULT_ROOT, prefix: str = "run") -> "RunStore":
|
|
135
|
+
run_id = f"{prefix}-{_utcnow():%Y%m%dT%H%M%S}-{uuid.uuid4().hex[:6]}"
|
|
136
|
+
return cls(run_id, root)
|
|
137
|
+
|
|
138
|
+
# -- ledger -----------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def ledger_path(self) -> Path:
|
|
142
|
+
return self.dir / "ledger.jsonl"
|
|
143
|
+
|
|
144
|
+
def log(self, kind: LedgerKind | str, agent: str | None = None, **detail: Any) -> LedgerEntry:
|
|
145
|
+
# `str` stays in the signature because ~60 call sites pass a literal and
|
|
146
|
+
# reading `store.log("denial", ...)` at the call site beats reading
|
|
147
|
+
# `store.log(LedgerKind.DENIAL, ...)`. Pydantic converts and, crucially,
|
|
148
|
+
# rejects: an unknown kind raises here rather than appending a line no
|
|
149
|
+
# reader will ever ask for.
|
|
150
|
+
entry = LedgerEntry(kind=kind, agent=agent, detail=detail)
|
|
151
|
+
with self.ledger_path.open("a") as fh:
|
|
152
|
+
fh.write(entry.model_dump_json() + "\n")
|
|
153
|
+
return entry
|
|
154
|
+
|
|
155
|
+
def ledger(self, kind: LedgerKind | str | None = None) -> Iterator[LedgerEntry]:
|
|
156
|
+
if not self.ledger_path.exists():
|
|
157
|
+
return
|
|
158
|
+
for line in self.ledger_path.read_text().splitlines():
|
|
159
|
+
if not line.strip():
|
|
160
|
+
continue
|
|
161
|
+
entry = LedgerEntry.model_validate_json(line)
|
|
162
|
+
if kind is None or entry.kind == kind:
|
|
163
|
+
yield entry
|
|
164
|
+
|
|
165
|
+
# -- envelopes --------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def put_envelope(self, envelope: DefectEnvelope) -> Path:
|
|
168
|
+
"""Persist an envelope, stamping its fingerprint if absent."""
|
|
169
|
+
if envelope.dedupe.fingerprint is None:
|
|
170
|
+
envelope = envelope.with_fingerprint()
|
|
171
|
+
path = self.dir / "envelopes" / f"{envelope.id}.json"
|
|
172
|
+
path.write_text(envelope.to_json())
|
|
173
|
+
self.log(
|
|
174
|
+
"envelope",
|
|
175
|
+
agent=envelope.discovered_by,
|
|
176
|
+
envelope_id=envelope.id,
|
|
177
|
+
domain=envelope.domain.value,
|
|
178
|
+
severity=envelope.severity.value,
|
|
179
|
+
confidence=envelope.confidence,
|
|
180
|
+
fingerprint=envelope.dedupe.fingerprint,
|
|
181
|
+
)
|
|
182
|
+
return path
|
|
183
|
+
|
|
184
|
+
def envelopes(self) -> list[DefectEnvelope]:
|
|
185
|
+
paths = sorted((self.dir / "envelopes").glob("*.json"))
|
|
186
|
+
return [DefectEnvelope.from_json(p.read_text()) for p in paths]
|
|
187
|
+
|
|
188
|
+
def get_envelope(self, envelope_id: str) -> DefectEnvelope | None:
|
|
189
|
+
path = self.dir / "envelopes" / f"{envelope_id}.json"
|
|
190
|
+
return DefectEnvelope.from_json(path.read_text()) if path.exists() else None
|
|
191
|
+
|
|
192
|
+
# -- artifacts --------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def put_artifact(self, name: str, content: str | bytes) -> str:
|
|
195
|
+
"""Store evidence and return the artifact:// uri that references it."""
|
|
196
|
+
safe = name.replace("/", "_").replace("..", "_")
|
|
197
|
+
path = self.dir / "artifacts" / safe
|
|
198
|
+
if isinstance(content, bytes):
|
|
199
|
+
path.write_bytes(content)
|
|
200
|
+
else:
|
|
201
|
+
path.write_text(content)
|
|
202
|
+
return f"artifact://{self.run_id}/{safe}"
|
|
203
|
+
|
|
204
|
+
def copy_artifact(self, name: str, source: Path | str) -> str:
|
|
205
|
+
safe = name.replace("/", "_").replace("..", "_")
|
|
206
|
+
shutil.copy2(source, self.dir / "artifacts" / safe)
|
|
207
|
+
return f"artifact://{self.run_id}/{safe}"
|
|
208
|
+
|
|
209
|
+
def resolve_artifact(self, uri: str) -> Path:
|
|
210
|
+
"""artifact://<run_id>/<name> -> a real path. Raises if it escapes the store."""
|
|
211
|
+
if not uri.startswith("artifact://"):
|
|
212
|
+
raise ValueError(f"not an artifact uri: {uri}")
|
|
213
|
+
run_id, _, name = uri[len("artifact://"):].partition("/")
|
|
214
|
+
path = (self.root / "runs" / run_id / "artifacts" / name).resolve()
|
|
215
|
+
base = (self.root / "runs" / run_id / "artifacts").resolve()
|
|
216
|
+
if not path.is_relative_to(base):
|
|
217
|
+
raise ValueError(f"artifact uri escapes the store: {uri}")
|
|
218
|
+
return path
|
|
219
|
+
|
|
220
|
+
# -- per-agent results ------------------------------------------------
|
|
221
|
+
|
|
222
|
+
def put_result(self, result: AgentResult) -> None:
|
|
223
|
+
# One file per invocation, not per agent. FORGE runs once per finding and
|
|
224
|
+
# MENDER once per review round trip, so a per-agent filename silently
|
|
225
|
+
# keeps only the last one — and the persisted cost of a run then
|
|
226
|
+
# under-reports by however much the repeated agents actually spent.
|
|
227
|
+
existing = len(list((self.dir / "results").glob(f"{result.agent}-*.json")))
|
|
228
|
+
path = self.dir / "results" / f"{result.agent}-{existing + 1:02d}.json"
|
|
229
|
+
path.write_text(result.model_dump_json(indent=2))
|
|
230
|
+
self.log(
|
|
231
|
+
"agent_finished",
|
|
232
|
+
agent=result.agent,
|
|
233
|
+
subtype=result.subtype,
|
|
234
|
+
cost_usd=result.cost_usd,
|
|
235
|
+
num_turns=result.num_turns,
|
|
236
|
+
envelopes=len(result.envelope_ids),
|
|
237
|
+
error=result.error,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def results(self) -> list[AgentResult]:
|
|
241
|
+
paths = sorted((self.dir / "results").glob("*.json"))
|
|
242
|
+
return [AgentResult.model_validate_json(p.read_text()) for p in paths]
|
|
243
|
+
|
|
244
|
+
def total_cost_usd(self) -> float:
|
|
245
|
+
return sum(r.cost_usd for r in self.results())
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class SystemMapStore:
|
|
249
|
+
"""Versioned Cartographer output, shared across runs.
|
|
250
|
+
|
|
251
|
+
Agents pin a map version for the length of a run so a bad map cannot
|
|
252
|
+
half-propagate mid-run (§10, context poisoning).
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
def __init__(self, root: Path | str = DEFAULT_ROOT):
|
|
256
|
+
self.dir = Path(root) / "system-map"
|
|
257
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
|
|
259
|
+
def put(self, payload: dict[str, Any]) -> str:
|
|
260
|
+
# The suffix is not decoration: a bare second-resolution timestamp lets
|
|
261
|
+
# two maps written in the same second collide, which would silently
|
|
262
|
+
# rewrite a version another run had already pinned.
|
|
263
|
+
version = f"{_utcnow():%Y%m%dT%H%M%S}-{uuid.uuid4().hex[:6]}"
|
|
264
|
+
path = self.dir / f"{version}.json"
|
|
265
|
+
if path.exists():
|
|
266
|
+
raise RuntimeError(f"system map version {version} already exists")
|
|
267
|
+
path.write_text(json.dumps(payload, indent=2, sort_keys=True))
|
|
268
|
+
tmp = self.dir / "latest.tmp"
|
|
269
|
+
tmp.write_text(version)
|
|
270
|
+
os.replace(tmp, self.dir / "latest")
|
|
271
|
+
return version
|
|
272
|
+
|
|
273
|
+
def latest_version(self) -> str | None:
|
|
274
|
+
pointer = self.dir / "latest"
|
|
275
|
+
return pointer.read_text().strip() if pointer.exists() else None
|
|
276
|
+
|
|
277
|
+
def get(self, version: str | None = None) -> dict[str, Any] | None:
|
|
278
|
+
version = version or self.latest_version()
|
|
279
|
+
if not version:
|
|
280
|
+
return None
|
|
281
|
+
path = self.dir / f"{version}.json"
|
|
282
|
+
return json.loads(path.read_text()) if path.exists() else None
|
|
283
|
+
|
|
284
|
+
def versions(self) -> list[str]:
|
|
285
|
+
return sorted(p.stem for p in self.dir.glob("*.json"))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def list_runs(root: Path | str = DEFAULT_ROOT) -> list[str]:
|
|
289
|
+
runs = Path(root) / "runs"
|
|
290
|
+
return sorted((p.name for p in runs.iterdir() if p.is_dir()), reverse=True) if runs.exists() else []
|
qaas/target.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""What the system is pointed at.
|
|
2
|
+
|
|
3
|
+
Everything the agents need to know about an application they have never seen:
|
|
4
|
+
where its code lives, how to reach it, who its users are. Without this the
|
|
5
|
+
system can only ever run against the app it was built alongside — which is the
|
|
6
|
+
difference between a demo and a tool.
|
|
7
|
+
|
|
8
|
+
A profile is deliberately small and mostly optional. An agent can discover a
|
|
9
|
+
great deal on its own; what it cannot discover is anything requiring a
|
|
10
|
+
credential, a URL that is not in the repository, or a judgement about which of
|
|
11
|
+
three directories is "the backend". Those are what a profile supplies.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
23
|
+
|
|
24
|
+
from qaas.paths import project_root
|
|
25
|
+
|
|
26
|
+
# Directories that are never product code. Excluded by default so a profile does
|
|
27
|
+
# not have to restate them and an agent does not waste a turn reading vendored
|
|
28
|
+
# dependencies.
|
|
29
|
+
DEFAULT_EXCLUDES = [
|
|
30
|
+
"node_modules", "vendor", "dist", "build", ".venv", "venv", "__pycache__",
|
|
31
|
+
".git", ".next", "target", "coverage", ".pytest_cache", "site-packages",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Layout(BaseModel):
|
|
36
|
+
"""Where things are. Every field is a hint; agents verify what they find."""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(extra="forbid")
|
|
39
|
+
|
|
40
|
+
backend: list[str] = Field(default_factory=list)
|
|
41
|
+
frontend: list[str] = Field(default_factory=list)
|
|
42
|
+
tests: list[str] = Field(default_factory=list)
|
|
43
|
+
migrations: list[str] = Field(default_factory=list)
|
|
44
|
+
spec: str | None = Field(default=None, description="OpenAPI document, if one exists.")
|
|
45
|
+
ownership: str | None = Field(default=None, description="CODEOWNERS or equivalent.")
|
|
46
|
+
docs: list[str] = Field(default_factory=list)
|
|
47
|
+
exclude: list[str] = Field(default_factory=lambda: list(DEFAULT_EXCLUDES))
|
|
48
|
+
|
|
49
|
+
def described(self) -> str:
|
|
50
|
+
"""A prose sketch of the layout for an agent's task prompt."""
|
|
51
|
+
bits = []
|
|
52
|
+
for label, paths in (
|
|
53
|
+
("backend", self.backend), ("frontend", self.frontend),
|
|
54
|
+
("tests", self.tests), ("migrations", self.migrations),
|
|
55
|
+
):
|
|
56
|
+
if paths:
|
|
57
|
+
bits.append(f"{label}: {', '.join(paths)}")
|
|
58
|
+
if self.spec:
|
|
59
|
+
bits.append(f"API spec: {self.spec}")
|
|
60
|
+
if self.ownership:
|
|
61
|
+
bits.append(f"ownership: {self.ownership}")
|
|
62
|
+
return "; ".join(bits) or "not recorded — discover it yourself"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Role(BaseModel):
|
|
66
|
+
"""One account an agent can act as.
|
|
67
|
+
|
|
68
|
+
Credentials are read from the environment, never stored here: a profile is
|
|
69
|
+
committed to a repository and a password in it is a leak, not a convenience.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
model_config = ConfigDict(extra="forbid")
|
|
73
|
+
|
|
74
|
+
username: str
|
|
75
|
+
password_env: str = "QAAS_PASSWORD"
|
|
76
|
+
description: str = ""
|
|
77
|
+
|
|
78
|
+
def password(self) -> str | None:
|
|
79
|
+
return os.environ.get(self.password_env)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Auth(BaseModel):
|
|
83
|
+
"""How an agent gets a credential for the running app."""
|
|
84
|
+
|
|
85
|
+
model_config = ConfigDict(extra="forbid")
|
|
86
|
+
|
|
87
|
+
mode: Literal["none", "login", "token"] = "none"
|
|
88
|
+
login_endpoint: str | None = Field(
|
|
89
|
+
default=None, description="e.g. 'POST /v1/auth/login'"
|
|
90
|
+
)
|
|
91
|
+
username_field: str = "email"
|
|
92
|
+
password_field: str = "password"
|
|
93
|
+
token_path: str = "access_token"
|
|
94
|
+
token_env: str | None = Field(
|
|
95
|
+
default=None, description="For mode=token: env var holding a bearer token."
|
|
96
|
+
)
|
|
97
|
+
roles: dict[str, Role] = Field(default_factory=dict)
|
|
98
|
+
|
|
99
|
+
@model_validator(mode="after")
|
|
100
|
+
def _coherent(self) -> "Auth":
|
|
101
|
+
if self.mode == "login" and not self.login_endpoint:
|
|
102
|
+
raise ValueError("auth.mode is 'login' but no login_endpoint is set")
|
|
103
|
+
if self.mode == "login" and not self.roles:
|
|
104
|
+
raise ValueError("auth.mode is 'login' but no roles are defined")
|
|
105
|
+
if self.mode == "token" and not self.token_env:
|
|
106
|
+
raise ValueError("auth.mode is 'token' but no token_env is set")
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def missing_secrets(self) -> list[str]:
|
|
110
|
+
"""Env vars this profile needs that are not set. Checked before a run."""
|
|
111
|
+
missing = []
|
|
112
|
+
if self.mode == "token" and self.token_env and not os.environ.get(self.token_env):
|
|
113
|
+
missing.append(self.token_env)
|
|
114
|
+
if self.mode == "login":
|
|
115
|
+
for role in self.roles.values():
|
|
116
|
+
if not role.password() and role.password_env not in missing:
|
|
117
|
+
missing.append(role.password_env)
|
|
118
|
+
return missing
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Environment(BaseModel):
|
|
122
|
+
"""How to get a running instance of the application.
|
|
123
|
+
|
|
124
|
+
Three modes, because real projects differ and pretending otherwise is what
|
|
125
|
+
makes a tool unusable:
|
|
126
|
+
|
|
127
|
+
compose — this system brings the app up and owns its lifecycle.
|
|
128
|
+
external — the app is already running somewhere (staging, a dev server).
|
|
129
|
+
Agents may read and exercise it but never reset or reseed it.
|
|
130
|
+
none — there is no reachable instance. Discovery is static only:
|
|
131
|
+
code, spec and schema. Most first runs against a real repo
|
|
132
|
+
start here, and that is a perfectly useful mode.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
model_config = ConfigDict(extra="forbid")
|
|
136
|
+
|
|
137
|
+
mode: Literal["compose", "external", "none"] = "none"
|
|
138
|
+
api_url: str | None = None
|
|
139
|
+
web_url: str | None = None
|
|
140
|
+
health_path: str = "/health"
|
|
141
|
+
|
|
142
|
+
compose_file: str | None = None
|
|
143
|
+
services: list[str] = Field(default_factory=list)
|
|
144
|
+
seed_sql: str | None = None
|
|
145
|
+
db_service: str | None = None
|
|
146
|
+
db_user: str | None = None
|
|
147
|
+
db_name: str | None = None
|
|
148
|
+
|
|
149
|
+
startup_timeout_s: int = 180
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def is_managed(self) -> bool:
|
|
153
|
+
"""Whether this system may create, reset and destroy the environment."""
|
|
154
|
+
return self.mode == "compose"
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def is_reachable(self) -> bool:
|
|
158
|
+
return self.mode in {"compose", "external"}
|
|
159
|
+
|
|
160
|
+
@model_validator(mode="after")
|
|
161
|
+
def _coherent(self) -> "Environment":
|
|
162
|
+
if self.mode == "compose" and not self.compose_file:
|
|
163
|
+
raise ValueError("environment.mode is 'compose' but no compose_file is set")
|
|
164
|
+
if self.mode == "external" and not (self.api_url or self.web_url):
|
|
165
|
+
raise ValueError(
|
|
166
|
+
"environment.mode is 'external' but neither api_url nor web_url is set"
|
|
167
|
+
)
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TargetProfile(BaseModel):
|
|
172
|
+
"""One application this system can be pointed at."""
|
|
173
|
+
|
|
174
|
+
model_config = ConfigDict(extra="forbid")
|
|
175
|
+
|
|
176
|
+
name: str
|
|
177
|
+
root: str = Field(description="Path to the repository, relative to cwd or absolute.")
|
|
178
|
+
description: str = ""
|
|
179
|
+
repo_url: str | None = None
|
|
180
|
+
default_branch: str = "main"
|
|
181
|
+
|
|
182
|
+
layout: Layout = Field(default_factory=Layout)
|
|
183
|
+
environment: Environment = Field(default_factory=Environment)
|
|
184
|
+
auth: Auth = Field(default_factory=Auth)
|
|
185
|
+
|
|
186
|
+
#: Golden ledger for calibration. Absent for real applications — you only
|
|
187
|
+
#: have one for an app whose defects you planted yourself.
|
|
188
|
+
ledger: str | None = None
|
|
189
|
+
|
|
190
|
+
@model_validator(mode="after")
|
|
191
|
+
def _name_is_a_slug(self) -> "TargetProfile":
|
|
192
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,40}", self.name):
|
|
193
|
+
raise ValueError("target name must be a lowercase slug, e.g. 'my-app'")
|
|
194
|
+
return self
|
|
195
|
+
|
|
196
|
+
def root_path(self, base: Path | None = None) -> Path:
|
|
197
|
+
"""Where the application under test actually is, on this machine.
|
|
198
|
+
|
|
199
|
+
This is *the* answer to "what am I testing", and it is deliberately not
|
|
200
|
+
the process cwd. `qaas run --repo <url>` clones into
|
|
201
|
+
`.qaas/targets/<slug>`, so the target is routinely somewhere the qaas
|
|
202
|
+
project is not; an absolute `root` in a profile is honoured as written.
|
|
203
|
+
"""
|
|
204
|
+
root = Path(self.root)
|
|
205
|
+
if root.is_absolute():
|
|
206
|
+
return root
|
|
207
|
+
return (base if base is not None else project_root()) / root
|
|
208
|
+
|
|
209
|
+
def readiness(self, base: Path | None = None) -> list[str]:
|
|
210
|
+
"""Everything that would stop a run right now. Empty means ready."""
|
|
211
|
+
problems: list[str] = []
|
|
212
|
+
root = self.root_path(base)
|
|
213
|
+
if not root.exists():
|
|
214
|
+
problems.append(f"repository root does not exist: {root}")
|
|
215
|
+
elif not root.is_dir():
|
|
216
|
+
problems.append(f"repository root is not a directory: {root}")
|
|
217
|
+
|
|
218
|
+
for missing in self.auth.missing_secrets():
|
|
219
|
+
problems.append(f"environment variable {missing} is not set")
|
|
220
|
+
|
|
221
|
+
if self.environment.mode == "compose":
|
|
222
|
+
compose = root / (self.environment.compose_file or "")
|
|
223
|
+
if not compose.exists():
|
|
224
|
+
problems.append(f"compose file not found: {compose}")
|
|
225
|
+
if self.layout.spec:
|
|
226
|
+
spec = root / self.layout.spec
|
|
227
|
+
if not spec.exists():
|
|
228
|
+
problems.append(f"API spec not found: {spec}")
|
|
229
|
+
return problems
|
|
230
|
+
|
|
231
|
+
def capabilities(self) -> dict[str, bool]:
|
|
232
|
+
"""What this profile makes possible. Drives which agents can usefully run."""
|
|
233
|
+
return {
|
|
234
|
+
"static_analysis": True,
|
|
235
|
+
"spec_diff": self.layout.spec is not None,
|
|
236
|
+
"live_api": self.environment.is_reachable and self.environment.api_url is not None,
|
|
237
|
+
"live_ui": self.environment.is_reachable and self.environment.web_url is not None,
|
|
238
|
+
"reset_state": self.environment.is_managed,
|
|
239
|
+
"impersonate": self.auth.mode != "none",
|
|
240
|
+
"scored": self.ledger is not None,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def load_target(name: str, targets_dir: Path | str = "config/targets") -> TargetProfile:
|
|
245
|
+
path = Path(targets_dir) / f"{name}.yaml"
|
|
246
|
+
if not path.exists():
|
|
247
|
+
available = sorted(p.stem for p in Path(targets_dir).glob("*.yaml"))
|
|
248
|
+
raise FileNotFoundError(
|
|
249
|
+
f"no target profile '{name}' at {path}. "
|
|
250
|
+
f"Available: {', '.join(available) or 'none'}. "
|
|
251
|
+
"Create one with `qaas init <path-to-repo>`."
|
|
252
|
+
)
|
|
253
|
+
raw: dict[str, Any] = yaml.safe_load(path.read_text()) or {}
|
|
254
|
+
raw.setdefault("name", name)
|
|
255
|
+
return TargetProfile.model_validate(raw)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# `list_targets(one_dir)` lived here and is gone. Listing profiles from a single
|
|
259
|
+
# directory is the bug that hid `<project>/config/targets/` the moment anything
|
|
260
|
+
# wrote into `.qaas/config/targets/`; profiles layer across every config
|
|
261
|
+
# directory, and `config.target_files(dirs)` is the one place that knows it.
|