assertion-cli 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.
session.py ADDED
@@ -0,0 +1,220 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import ValidationError
4
+
5
+ from api import AssertionClient
6
+ from git import (
7
+ exit_with_error,
8
+ find_git_root,
9
+ get_origin_github_repo,
10
+ require_head_pushed,
11
+ )
12
+ from models import MetadataPayload, SessionContext, render_stack_list
13
+
14
+ ASSERTION_DIR_NAME = ".assertion"
15
+ PROMPTS_FILE_NAME = "prompts"
16
+ CHECKPOINT_FILE_NAME = "checkpoint"
17
+ METADATA_FILE_NAME = "metadata.json"
18
+
19
+
20
+ def _require_repo_ready(start_path: Path) -> Path:
21
+ repo_root = find_git_root(start_path)
22
+ require_head_pushed(repo_root)
23
+ return repo_root
24
+
25
+
26
+ def _load_metadata(metadata_path: Path) -> MetadataPayload | None:
27
+ if not metadata_path.exists():
28
+ return None
29
+ try:
30
+ return MetadataPayload.model_validate_json(
31
+ metadata_path.read_text(encoding="utf-8")
32
+ )
33
+ except (ValidationError, ValueError) as exc:
34
+ exit_with_error(
35
+ f"Corrupt {metadata_path.name}: {exc}. "
36
+ "Delete .assertion/ and start a new session."
37
+ )
38
+
39
+
40
+ def _build_context(repo_root: Path) -> SessionContext:
41
+ assertion_dir = repo_root / ASSERTION_DIR_NAME
42
+ return SessionContext(
43
+ repo_root=repo_root,
44
+ assertion_dir=assertion_dir,
45
+ prompts_path=assertion_dir / PROMPTS_FILE_NAME,
46
+ checkpoint_path=assertion_dir / CHECKPOINT_FILE_NAME,
47
+ metadata_path=assertion_dir / METADATA_FILE_NAME,
48
+ )
49
+
50
+
51
+ def _create_session(
52
+ repo_root: Path,
53
+ session_id: str,
54
+ stack_id: str,
55
+ ) -> SessionContext:
56
+ ctx = _build_context(repo_root)
57
+ ctx.assertion_dir.mkdir(exist_ok=True)
58
+
59
+ # Don't touch the prompts file — the agent appends to it via `asrt prompt`.
60
+ ctx.checkpoint_path.write_text("", encoding="utf-8")
61
+ ctx.metadata_path.write_text(
62
+ MetadataPayload(
63
+ session_id=session_id,
64
+ stack_id=stack_id,
65
+ ).model_dump_json(indent=2)
66
+ + "\n",
67
+ encoding="utf-8",
68
+ )
69
+
70
+ return ctx
71
+
72
+
73
+ def _require_prompts_file(ctx: SessionContext) -> None:
74
+ if (
75
+ not ctx.prompts_path.exists()
76
+ or not ctx.prompts_path.read_text(encoding="utf-8").strip()
77
+ ):
78
+ exit_with_error(
79
+ f"ERROR: {ASSERTION_DIR_NAME}/{PROMPTS_FILE_NAME} is missing or empty.\n"
80
+ 'Record at least one customer prompt with `asrt prompt "<message>"` before running checkpoint.'
81
+ )
82
+
83
+
84
+ def _stacks_hint() -> str:
85
+ """Fetch available stacks and return a formatted hint for error messages."""
86
+ try:
87
+ client = AssertionClient()
88
+ stacks = client.stacks()
89
+ return (
90
+ "\n\nAvailable stacks:\n"
91
+ + render_stack_list(stacks)
92
+ + "\n\nTo start a new session:\n"
93
+ ' asrt checkpoint --stack <STACK_ID> "<progress message>"'
94
+ )
95
+ except SystemExit:
96
+ return (
97
+ "\n\nRun `asrt stacks` to see available stack IDs, then:\n"
98
+ ' asrt checkpoint --stack <STACK_ID> "<progress message>"'
99
+ )
100
+
101
+
102
+ def resolve_stack_id_for_repo(repo_root: Path) -> str:
103
+ """Pick the stack whose `repo` field matches the current repo's origin.
104
+
105
+ Exits with a clear error if zero or more than one stack matches. The
106
+ enforcement is here (in the CLI) so the coding agent does not have to
107
+ follow a compliance rule — the rule is mechanical.
108
+ """
109
+ owner_name = get_origin_github_repo(repo_root)
110
+ if owner_name is None:
111
+ exit_with_error(
112
+ "Could not determine this repo's GitHub `owner/name` from the `origin` "
113
+ "remote. Pass `--stack <id>` explicitly to override."
114
+ )
115
+
116
+ client = AssertionClient()
117
+ stacks = client.stacks()
118
+
119
+ if not stacks:
120
+ exit_with_error(
121
+ "No verification stacks exist in this workspace yet. Open the "
122
+ "Assertion web app, create a stack, and attach this repo "
123
+ f"(`{owner_name}`) to it. Then re-run the command."
124
+ )
125
+
126
+ matches = [s for s in stacks if s.repo.strip() == owner_name]
127
+
128
+ if len(matches) == 1:
129
+ return matches[0].id
130
+ if len(matches) == 0:
131
+ bound_repos = sorted({s.repo.strip() for s in stacks})
132
+ exit_with_error(
133
+ f"No verification stack is attached to this repo (`{owner_name}`)."
134
+ f" Other stacks in this workspace are attached to: {', '.join(bound_repos)}."
135
+ " Open the Assertion web app, choose a stack,"
136
+ " and attach this repo to it. Then re-run the command."
137
+ )
138
+ # More than one match — let the agent / user pick explicitly.
139
+ names = ", ".join(f"{s.name} ({s.id})" for s in matches)
140
+ exit_with_error(
141
+ f"Multiple stacks are attached to `{owner_name}`: {names}. "
142
+ "Pass `--stack <id>` to disambiguate."
143
+ )
144
+
145
+
146
+ def start_new_session(
147
+ *,
148
+ start_path: Path,
149
+ stack_id: str,
150
+ ) -> tuple[SessionContext, MetadataPayload]:
151
+ """Create a new verification session with --stack."""
152
+ repo_root = _require_repo_ready(start_path)
153
+
154
+ client = AssertionClient()
155
+ init_resp = client.init()
156
+
157
+ valid_ids = {s.id for s in init_resp.stacks}
158
+ if stack_id not in valid_ids:
159
+ exit_with_error(f"ERROR: Unknown stack '{stack_id}'." + _stacks_hint())
160
+
161
+ ctx = _create_session(
162
+ repo_root,
163
+ session_id=init_resp.session_id,
164
+ stack_id=stack_id,
165
+ )
166
+ _require_prompts_file(ctx)
167
+ metadata = _load_metadata(ctx.metadata_path)
168
+ assert metadata is not None
169
+ return ctx, metadata
170
+
171
+
172
+ def continue_session(
173
+ *,
174
+ start_path: Path,
175
+ ) -> tuple[SessionContext, MetadataPayload]:
176
+ """Continue an existing session with --continue."""
177
+ repo_root = _require_repo_ready(start_path)
178
+ ctx = _build_context(repo_root)
179
+
180
+ existing = _load_metadata(ctx.metadata_path)
181
+ if existing is None:
182
+ exit_with_error("ERROR: No active session to continue." + _stacks_hint())
183
+
184
+ _require_prompts_file(ctx)
185
+ return ctx, existing
186
+
187
+
188
+ def load_existing_session(start_path: Path) -> tuple[SessionContext, MetadataPayload]:
189
+ """Load an existing session or fail. Used by verify."""
190
+ repo_root = _require_repo_ready(start_path)
191
+ ctx = _build_context(repo_root)
192
+
193
+ existing = _load_metadata(ctx.metadata_path)
194
+ if existing is None:
195
+ exit_with_error(
196
+ "ERROR: No active session. Run a checkpoint first." + _stacks_hint()
197
+ )
198
+
199
+ return ctx, existing
200
+
201
+
202
+ def append_checkpoint_entry(checkpoint_path: Path, message: str) -> None:
203
+ normalized = message.rstrip("\n")
204
+ if not normalized.strip():
205
+ exit_with_error("Checkpoint message must not be empty.")
206
+ with checkpoint_path.open("a", encoding="utf-8") as f:
207
+ f.write(f"{normalized}\n\n")
208
+
209
+
210
+ def update_metadata_head(
211
+ metadata_path: Path,
212
+ metadata: MetadataPayload,
213
+ head_sha: str,
214
+ head_branch: str | None = None,
215
+ ) -> MetadataPayload:
216
+ updated = metadata.model_copy(
217
+ update={"head_sha": head_sha, "head_branch": head_branch}
218
+ )
219
+ metadata_path.write_text(updated.model_dump_json(indent=2) + "\n", encoding="utf-8")
220
+ return updated