@arbiterforge/ca-pi 0.14.6 → 0.14.8
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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +36 -0
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_prerequisitelib.py +971 -0
- package/plugins/ca-pi/hooks/_releaselib.py +274 -12
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/prompt-submit.py +15 -1
- package/plugins/ca-pi/hooks/releasehash.py +17 -4
- package/plugins/ca-pi/includes/artifacts.md +44 -0
- package/plugins/ca-pi/package.json +1 -1
- package/plugins/ca-pi/routines/release/SKILL.md +89 -26
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — host-owned prerequisite confirmation for structured plans.
|
|
3
|
+
"""Capture an exact host-observed user decision and satisfy one prerequisite.
|
|
4
|
+
|
|
5
|
+
arm_user_prerequisite(...) -> dict
|
|
6
|
+
consume_user_prerequisite(...) -> dict
|
|
7
|
+
cancel_user_prerequisite(...) -> dict
|
|
8
|
+
supersede_user_prerequisite(...) -> dict
|
|
9
|
+
consume_from_hook(...) -> str
|
|
10
|
+
|
|
11
|
+
The adapter fixes the authority kind, verdict, actor, event shape, and engine
|
|
12
|
+
operation. Callers select only an approved plan and one of its exact current
|
|
13
|
+
prerequisite records. Confirmation is persisted before authority publication;
|
|
14
|
+
retries reuse the same event, receipt, operation ID, and mutation request.
|
|
15
|
+
|
|
16
|
+
The user-visible confirmation nonce is deliberately non-secret correlation
|
|
17
|
+
data, not a bearer credential. Possession never confers authority: the adapter
|
|
18
|
+
accepts it only in an exact prompt observed by the trusted UserPromptSubmit
|
|
19
|
+
boundary while the reviewed plan, approval, and prerequisite remain current.
|
|
20
|
+
The observed prompt is retained verbatim as the authority source because it is
|
|
21
|
+
the decision evidence; nonce unpredictability is not a security boundary.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
from contextlib import contextmanager
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
import re
|
|
33
|
+
import secrets
|
|
34
|
+
import stat
|
|
35
|
+
import sys
|
|
36
|
+
import tempfile
|
|
37
|
+
import time
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
import _artifactlib
|
|
41
|
+
from _hooklib import acquire_lock, release_lock
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
PENDING_DIR = Path(".codearbiter/.markers/pending-prerequisites")
|
|
45
|
+
SOURCE_DIR = Path(".codearbiter/.artifacts/authority-sources")
|
|
46
|
+
FORMAT = "codearbiter.pending-user-prerequisite/0.1.0"
|
|
47
|
+
POLICY = "explicit-user-prerequisite-confirmation/0.1.0"
|
|
48
|
+
CONFIRMATION_LIFETIME_SECONDS = 15 * 60
|
|
49
|
+
CONFIRMATION_RE = re.compile(r"[A-Za-z0-9_-]{12,128}")
|
|
50
|
+
ID_RE = re.compile(r"[A-Z][A-Z0-9_-]{0,127}")
|
|
51
|
+
HOST_RE = re.compile(r"[A-Za-z0-9_-]{1,32}")
|
|
52
|
+
SESSION_RE = re.compile(r"[A-Za-z0-9._:-]{1,256}")
|
|
53
|
+
SHA256_RE = re.compile(r"[0-9a-f]{64}")
|
|
54
|
+
RECEIPT_RE = re.compile(
|
|
55
|
+
r"\.codearbiter/\.artifacts/receipts/[0-9a-f]{64}\.json"
|
|
56
|
+
)
|
|
57
|
+
STATES = frozenset({"ARMED", "CONFIRMED", "CAPTURED"})
|
|
58
|
+
STATE_FIELDS = {
|
|
59
|
+
"format", "state", "request_id", "artifact_id", "prerequisite_id",
|
|
60
|
+
"kind", "revision", "model_sha256", "normative_sha256", "record",
|
|
61
|
+
"review_packet_sha256", "confirmation_sha256", "issued_at", "expires_at",
|
|
62
|
+
"event", "authority_source", "receipt", "mutation_request",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PrerequisiteError(RuntimeError):
|
|
67
|
+
def __init__(self, code: str, message: str):
|
|
68
|
+
self.code = code
|
|
69
|
+
super().__init__(f"{code}: {message}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
73
|
+
value: dict[str, Any] = {}
|
|
74
|
+
for key, item in pairs:
|
|
75
|
+
if key in value:
|
|
76
|
+
raise ValueError("duplicate JSON key")
|
|
77
|
+
value[key] = item
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _canonical(value: Any) -> bytes:
|
|
82
|
+
return json.dumps(
|
|
83
|
+
value, ensure_ascii=True, allow_nan=False, sort_keys=True, separators=(",", ":")
|
|
84
|
+
).encode("utf-8")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _digest(data: bytes) -> str:
|
|
88
|
+
return hashlib.sha256(data).hexdigest()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _pending_relative(artifact_id: str) -> Path:
|
|
92
|
+
return PENDING_DIR / f"{_digest(artifact_id.encode('utf-8'))}.json"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _prerequisite_busy() -> PrerequisiteError:
|
|
96
|
+
return PrerequisiteError(
|
|
97
|
+
"PREREQUISITE_BUSY",
|
|
98
|
+
"another session is resolving this plan's prerequisite request",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _repository_lock_key(root: Path) -> bytes:
|
|
103
|
+
"""Return one key for every path alias to the same repository directory."""
|
|
104
|
+
info = os.stat(root)
|
|
105
|
+
device = int(getattr(info, "st_dev", 0))
|
|
106
|
+
inode = int(getattr(info, "st_ino", 0))
|
|
107
|
+
if device or inode:
|
|
108
|
+
return f"stat:{device}:{inode}".encode("ascii")
|
|
109
|
+
|
|
110
|
+
# Some Windows filesystems report no stable inode. Their path comparison is
|
|
111
|
+
# case-insensitive, so casefold closes aliases that normcase alone may miss
|
|
112
|
+
# on a non-native test host.
|
|
113
|
+
resolved = str(root.resolve(strict=True))
|
|
114
|
+
return b"path:" + os.path.normcase(resolved).casefold().encode("utf-8")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _pending_lock_directory_name(temporary: Path | None = None) -> str:
|
|
118
|
+
if hasattr(os, "getuid"):
|
|
119
|
+
namespace = f"uid-{os.getuid()}"
|
|
120
|
+
else:
|
|
121
|
+
if temporary is None:
|
|
122
|
+
temporary = Path(tempfile.gettempdir()).resolve(strict=True)
|
|
123
|
+
temp_identity = (
|
|
124
|
+
os.path.normcase(str(temporary)).casefold().encode("utf-8")
|
|
125
|
+
)
|
|
126
|
+
namespace = f"temp-{_digest(temp_identity)[:24]}"
|
|
127
|
+
return f"codearbiter-prerequisite-locks-{namespace}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _pending_lock_root() -> Path:
|
|
131
|
+
"""Create and validate the private cross-process lock namespace."""
|
|
132
|
+
try:
|
|
133
|
+
temporary = Path(tempfile.gettempdir()).resolve(strict=True)
|
|
134
|
+
lock_root = temporary / _pending_lock_directory_name(temporary)
|
|
135
|
+
lock_root.mkdir(mode=0o700, exist_ok=True)
|
|
136
|
+
info = lock_root.lstat()
|
|
137
|
+
reparse = getattr(info, "st_file_attributes", 0) & 0x400
|
|
138
|
+
if stat.S_ISLNK(info.st_mode) or reparse or not stat.S_ISDIR(info.st_mode):
|
|
139
|
+
raise OSError("prerequisite lock root is not a real directory")
|
|
140
|
+
if hasattr(os, "getuid"):
|
|
141
|
+
if info.st_uid != os.getuid():
|
|
142
|
+
raise OSError("prerequisite lock root has the wrong owner")
|
|
143
|
+
os.chmod(lock_root, 0o700)
|
|
144
|
+
info = lock_root.lstat()
|
|
145
|
+
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
|
|
146
|
+
raise OSError("prerequisite lock root is not private")
|
|
147
|
+
return lock_root
|
|
148
|
+
except OSError as error:
|
|
149
|
+
raise _prerequisite_busy() from error
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@contextmanager
|
|
153
|
+
def _pending_transition_lock(root: Path, artifact_id: str):
|
|
154
|
+
"""Serialize one artifact's load-to-cleanup prerequisite transition."""
|
|
155
|
+
# Keep the live OS lock outside the repository. Verification snapshots
|
|
156
|
+
# recursively read ordinary marker files, and Windows denies that read
|
|
157
|
+
# while acquire_lock holds its byte-range lock. The resolved repository
|
|
158
|
+
# filesystem identity makes path aliases share a key, while the private
|
|
159
|
+
# per-user namespace prevents another local account from redirecting or
|
|
160
|
+
# pre-seeding the lock sidecar.
|
|
161
|
+
repository_key = _repository_lock_key(root)
|
|
162
|
+
lock_name = _digest(repository_key + b"\0" + artifact_id.encode("utf-8"))
|
|
163
|
+
lock_root = _pending_lock_root()
|
|
164
|
+
handle = acquire_lock(lock_root / lock_name)
|
|
165
|
+
if handle is None:
|
|
166
|
+
raise _prerequisite_busy()
|
|
167
|
+
try:
|
|
168
|
+
yield
|
|
169
|
+
finally:
|
|
170
|
+
release_lock(handle)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _safe_directory(root: Path, relative: Path, *, create: bool) -> Path:
|
|
174
|
+
current = root
|
|
175
|
+
for part in relative.parts:
|
|
176
|
+
current = current / part
|
|
177
|
+
try:
|
|
178
|
+
info = current.lstat()
|
|
179
|
+
except FileNotFoundError:
|
|
180
|
+
if not create:
|
|
181
|
+
raise
|
|
182
|
+
current.mkdir()
|
|
183
|
+
info = current.lstat()
|
|
184
|
+
reparse = getattr(info, "st_file_attributes", 0) & 0x400
|
|
185
|
+
if stat.S_ISLNK(info.st_mode) or reparse or not stat.S_ISDIR(info.st_mode):
|
|
186
|
+
raise PrerequisiteError(
|
|
187
|
+
"UNSAFE_PREREQUISITE_PATH",
|
|
188
|
+
"prerequisite state directories must be real directories",
|
|
189
|
+
)
|
|
190
|
+
return current
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _write_new(root: Path, relative: Path, data: bytes) -> None:
|
|
194
|
+
parent = _safe_directory(root, relative.parent, create=True)
|
|
195
|
+
path = parent / relative.name
|
|
196
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
197
|
+
if hasattr(os, "O_BINARY"):
|
|
198
|
+
flags |= os.O_BINARY
|
|
199
|
+
fd = os.open(path, flags, 0o600)
|
|
200
|
+
completed = False
|
|
201
|
+
try:
|
|
202
|
+
view = memoryview(data)
|
|
203
|
+
while view:
|
|
204
|
+
written = os.write(fd, view)
|
|
205
|
+
if written <= 0:
|
|
206
|
+
raise OSError("short write")
|
|
207
|
+
view = view[written:]
|
|
208
|
+
os.fsync(fd)
|
|
209
|
+
os.close(fd)
|
|
210
|
+
fd = None
|
|
211
|
+
completed = True
|
|
212
|
+
finally:
|
|
213
|
+
if fd is not None:
|
|
214
|
+
try:
|
|
215
|
+
os.close(fd)
|
|
216
|
+
except OSError:
|
|
217
|
+
pass
|
|
218
|
+
if not completed:
|
|
219
|
+
try:
|
|
220
|
+
path.unlink()
|
|
221
|
+
except OSError:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _replace_state(root: Path, relative: Path, value: dict[str, Any]) -> None:
|
|
226
|
+
suffix = secrets.token_hex(8)
|
|
227
|
+
temporary = relative.with_name(f".{relative.name}.{suffix}.tmp")
|
|
228
|
+
_write_new(root, temporary, _canonical(value))
|
|
229
|
+
source = root / temporary
|
|
230
|
+
target = root / relative
|
|
231
|
+
try:
|
|
232
|
+
os.replace(source, target)
|
|
233
|
+
if os.name != "nt":
|
|
234
|
+
directory = os.open(target.parent, os.O_RDONLY)
|
|
235
|
+
try:
|
|
236
|
+
os.fsync(directory)
|
|
237
|
+
finally:
|
|
238
|
+
os.close(directory)
|
|
239
|
+
finally:
|
|
240
|
+
try:
|
|
241
|
+
source.unlink()
|
|
242
|
+
except FileNotFoundError:
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _publish_new(root: Path, relative: Path, data: bytes) -> None:
|
|
247
|
+
"""Publish complete bytes atomically without replacing an existing name."""
|
|
248
|
+
suffix = secrets.token_hex(8)
|
|
249
|
+
temporary = relative.with_name(f".{relative.name}.{suffix}.tmp")
|
|
250
|
+
_write_new(root, temporary, data)
|
|
251
|
+
source = root / temporary
|
|
252
|
+
target = root / relative
|
|
253
|
+
linked = False
|
|
254
|
+
try:
|
|
255
|
+
os.link(source, target)
|
|
256
|
+
linked = True
|
|
257
|
+
if os.name != "nt":
|
|
258
|
+
directory = os.open(target.parent, os.O_RDONLY)
|
|
259
|
+
try:
|
|
260
|
+
os.fsync(directory)
|
|
261
|
+
finally:
|
|
262
|
+
os.close(directory)
|
|
263
|
+
finally:
|
|
264
|
+
try:
|
|
265
|
+
source.unlink()
|
|
266
|
+
except FileNotFoundError:
|
|
267
|
+
pass
|
|
268
|
+
if not linked:
|
|
269
|
+
raise OSError("content-addressed publication did not complete")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _read_bounded_regular(root: Path, relative: Path, *, limit: int) -> bytes:
|
|
273
|
+
parent = _safe_directory(root, relative.parent, create=False)
|
|
274
|
+
path = parent / relative.name
|
|
275
|
+
info = path.lstat()
|
|
276
|
+
reparse = getattr(info, "st_file_attributes", 0) & 0x400
|
|
277
|
+
if (
|
|
278
|
+
stat.S_ISLNK(info.st_mode)
|
|
279
|
+
or reparse
|
|
280
|
+
or not stat.S_ISREG(info.st_mode)
|
|
281
|
+
or info.st_size > limit
|
|
282
|
+
):
|
|
283
|
+
raise PrerequisiteError(
|
|
284
|
+
"INVALID_PENDING_PREREQUISITE",
|
|
285
|
+
"prerequisite state is not bounded regular data",
|
|
286
|
+
)
|
|
287
|
+
data = path.read_bytes()
|
|
288
|
+
if len(data) > limit:
|
|
289
|
+
raise PrerequisiteError(
|
|
290
|
+
"INVALID_PENDING_PREREQUISITE", "prerequisite state exceeds its bound"
|
|
291
|
+
)
|
|
292
|
+
return data
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _identity(client: Any, artifact_id: str) -> dict[str, Any]:
|
|
296
|
+
identity = client.call("identity", {"artifact_id": artifact_id})
|
|
297
|
+
required = {
|
|
298
|
+
"artifact_id": str,
|
|
299
|
+
"kind": str,
|
|
300
|
+
"revision": int,
|
|
301
|
+
"model_sha256": str,
|
|
302
|
+
"normative_sha256": str,
|
|
303
|
+
}
|
|
304
|
+
if any(type(identity.get(key)) is not kind for key, kind in required.items()):
|
|
305
|
+
raise PrerequisiteError("INVALID_IDENTITY", "artifact identity is incomplete")
|
|
306
|
+
if identity["artifact_id"] != artifact_id or identity["kind"] != "plan":
|
|
307
|
+
raise PrerequisiteError("INVALID_IDENTITY", "prerequisites require the exact plan")
|
|
308
|
+
for key in ("model_sha256", "normative_sha256"):
|
|
309
|
+
if SHA256_RE.fullmatch(identity[key]) is None:
|
|
310
|
+
raise PrerequisiteError("INVALID_IDENTITY", f"{key} is malformed")
|
|
311
|
+
return identity
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _approved(client: Any, identity: dict[str, Any]) -> None:
|
|
315
|
+
result = client.call(
|
|
316
|
+
"validate",
|
|
317
|
+
{"artifact_id": identity["artifact_id"], "gate": "approved"},
|
|
318
|
+
permit_invalid=True,
|
|
319
|
+
)
|
|
320
|
+
authority = result.get("authority")
|
|
321
|
+
if (
|
|
322
|
+
result.get("valid") is not True
|
|
323
|
+
or result.get("artifact_id") != identity["artifact_id"]
|
|
324
|
+
or result.get("model_sha256") != identity["model_sha256"]
|
|
325
|
+
or result.get("normative_sha256") != identity["normative_sha256"]
|
|
326
|
+
or not isinstance(authority, dict)
|
|
327
|
+
or authority.get("state") != "approved"
|
|
328
|
+
or authority.get("authority_verified") is not True
|
|
329
|
+
):
|
|
330
|
+
raise PrerequisiteError(
|
|
331
|
+
"AUTHORITY_UNVERIFIED",
|
|
332
|
+
"only the exact current approved plan can arm a prerequisite",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _prerequisite_record(
|
|
337
|
+
client: Any, identity: dict[str, Any], prerequisite_id: str
|
|
338
|
+
) -> dict[str, Any]:
|
|
339
|
+
result = client.call(
|
|
340
|
+
"read",
|
|
341
|
+
{
|
|
342
|
+
"artifact_id": identity["artifact_id"],
|
|
343
|
+
"symbol": prerequisite_id,
|
|
344
|
+
"mode": "exact",
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
if any(result.get(field) != identity[field] for field in (
|
|
348
|
+
"artifact_id", "revision", "model_sha256", "normative_sha256"
|
|
349
|
+
)):
|
|
350
|
+
raise PrerequisiteError("STALE_PREREQUISITE", "prerequisite read changed identity")
|
|
351
|
+
record = result.get("record")
|
|
352
|
+
if (
|
|
353
|
+
not isinstance(record, dict)
|
|
354
|
+
or set(record) != {"id", "title", "requirement", "source_refs"}
|
|
355
|
+
or record.get("id") != prerequisite_id
|
|
356
|
+
or not isinstance(record.get("title"), str)
|
|
357
|
+
or not record["title"]
|
|
358
|
+
or not isinstance(record.get("requirement"), str)
|
|
359
|
+
or not record["requirement"]
|
|
360
|
+
or not isinstance(record.get("source_refs"), list)
|
|
361
|
+
or any(not isinstance(value, str) for value in record["source_refs"])
|
|
362
|
+
):
|
|
363
|
+
raise PrerequisiteError(
|
|
364
|
+
"UNKNOWN_PREREQUISITE", "symbol is not an exact prerequisite record"
|
|
365
|
+
)
|
|
366
|
+
return record
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _review_packet(identity: dict[str, Any], record: dict[str, Any]) -> dict[str, Any]:
|
|
370
|
+
return {
|
|
371
|
+
"policy": POLICY,
|
|
372
|
+
"artifact": {
|
|
373
|
+
key: identity[key]
|
|
374
|
+
for key in (
|
|
375
|
+
"artifact_id", "kind", "revision", "model_sha256", "normative_sha256"
|
|
376
|
+
)
|
|
377
|
+
},
|
|
378
|
+
"prerequisite": record,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _validate_record(value: Any, prerequisite_id: str) -> bool:
|
|
383
|
+
return (
|
|
384
|
+
isinstance(value, dict)
|
|
385
|
+
and set(value) == {"id", "title", "requirement", "source_refs"}
|
|
386
|
+
and value.get("id") == prerequisite_id
|
|
387
|
+
and isinstance(value.get("title"), str)
|
|
388
|
+
and bool(value["title"])
|
|
389
|
+
and isinstance(value.get("requirement"), str)
|
|
390
|
+
and bool(value["requirement"])
|
|
391
|
+
and isinstance(value.get("source_refs"), list)
|
|
392
|
+
and all(isinstance(item, str) for item in value["source_refs"])
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _validate_confirmed_state(value: dict[str, Any]) -> None:
|
|
397
|
+
event = value["event"]
|
|
398
|
+
expected_subject = {
|
|
399
|
+
"artifact_id": value["artifact_id"],
|
|
400
|
+
"normative_sha256": value["normative_sha256"],
|
|
401
|
+
"record_id": value["prerequisite_id"],
|
|
402
|
+
}
|
|
403
|
+
expected_payload = {
|
|
404
|
+
"adapter_policy": POLICY,
|
|
405
|
+
"request_id": value["request_id"],
|
|
406
|
+
"review_packet_sha256": value["review_packet_sha256"],
|
|
407
|
+
"prerequisite": value["record"],
|
|
408
|
+
}
|
|
409
|
+
if (
|
|
410
|
+
not isinstance(event, dict)
|
|
411
|
+
or set(event) != {
|
|
412
|
+
"format", "kind", "authority_kind", "subject", "actor", "origin",
|
|
413
|
+
"verdict", "payload", "source_text",
|
|
414
|
+
}
|
|
415
|
+
or event.get("format") != "codearbiter.workflow-event/0.1.0"
|
|
416
|
+
or event.get("kind") != "prerequisite"
|
|
417
|
+
or event.get("authority_kind") != "user_workflow"
|
|
418
|
+
or event.get("subject") != expected_subject
|
|
419
|
+
or event.get("actor") != "interactive repository user"
|
|
420
|
+
or not isinstance(event.get("origin"), str)
|
|
421
|
+
or re.fullmatch(
|
|
422
|
+
r"[A-Za-z0-9_-]{1,32}:UserPromptSubmit:[A-Za-z0-9._:-]{1,256}",
|
|
423
|
+
event["origin"],
|
|
424
|
+
) is None
|
|
425
|
+
or event.get("verdict") != "satisfied"
|
|
426
|
+
or event.get("payload") != expected_payload
|
|
427
|
+
):
|
|
428
|
+
raise PrerequisiteError(
|
|
429
|
+
"INVALID_PENDING_PREREQUISITE",
|
|
430
|
+
"confirmed event does not match the fixed prerequisite policy",
|
|
431
|
+
)
|
|
432
|
+
match = _match_prompt(event.get("source_text"))
|
|
433
|
+
if (
|
|
434
|
+
match is None
|
|
435
|
+
or match[:2] != (value["artifact_id"], value["prerequisite_id"])
|
|
436
|
+
or not secrets.compare_digest(
|
|
437
|
+
_digest(match[2].encode("ascii")), value["confirmation_sha256"]
|
|
438
|
+
)
|
|
439
|
+
):
|
|
440
|
+
raise PrerequisiteError(
|
|
441
|
+
"INVALID_PENDING_PREREQUISITE",
|
|
442
|
+
"confirmed source text does not match the armed request",
|
|
443
|
+
)
|
|
444
|
+
expected_source = (
|
|
445
|
+
SOURCE_DIR / f"{_digest(_canonical(event))}.json"
|
|
446
|
+
).as_posix()
|
|
447
|
+
if value["authority_source"] != expected_source:
|
|
448
|
+
raise PrerequisiteError(
|
|
449
|
+
"INVALID_PENDING_PREREQUISITE",
|
|
450
|
+
"confirmed authority source does not match the event",
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _expected_mutation_request(value: dict[str, Any]) -> dict[str, Any]:
|
|
455
|
+
operation_suffix = _digest(value["request_id"].encode("ascii"))[:24]
|
|
456
|
+
return {
|
|
457
|
+
"artifact_id": value["artifact_id"],
|
|
458
|
+
"operation_id": f"host-user-prerequisite-{operation_suffix}",
|
|
459
|
+
"expected": {
|
|
460
|
+
"revision": value["revision"],
|
|
461
|
+
"model_sha256": value["model_sha256"],
|
|
462
|
+
},
|
|
463
|
+
"prerequisite": value["prerequisite_id"],
|
|
464
|
+
"receipt": value["receipt"],
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _load_pending(root: Path, artifact_id: str) -> dict[str, Any] | None:
|
|
469
|
+
relative = _pending_relative(artifact_id)
|
|
470
|
+
try:
|
|
471
|
+
raw = _read_bounded_regular(root, relative, limit=65536)
|
|
472
|
+
except FileNotFoundError:
|
|
473
|
+
return None
|
|
474
|
+
except (OSError, UnicodeError) as exc:
|
|
475
|
+
raise PrerequisiteError(
|
|
476
|
+
"INVALID_PENDING_PREREQUISITE", "prerequisite state is unreadable"
|
|
477
|
+
) from exc
|
|
478
|
+
try:
|
|
479
|
+
value = json.loads(raw.decode("utf-8", "strict"), object_pairs_hook=_object)
|
|
480
|
+
except (ValueError, UnicodeError, RecursionError) as exc:
|
|
481
|
+
raise PrerequisiteError(
|
|
482
|
+
"INVALID_PENDING_PREREQUISITE", "prerequisite state is invalid JSON"
|
|
483
|
+
) from exc
|
|
484
|
+
if not isinstance(value, dict) or set(value) != STATE_FIELDS:
|
|
485
|
+
raise PrerequisiteError(
|
|
486
|
+
"INVALID_PENDING_PREREQUISITE", "prerequisite state shape is invalid"
|
|
487
|
+
)
|
|
488
|
+
if (
|
|
489
|
+
value["format"] != FORMAT
|
|
490
|
+
or value["state"] not in STATES
|
|
491
|
+
or value["artifact_id"] != artifact_id
|
|
492
|
+
or any(not isinstance(value[key], str) or not value[key] for key in (
|
|
493
|
+
"request_id", "artifact_id", "prerequisite_id", "kind",
|
|
494
|
+
"model_sha256", "normative_sha256", "review_packet_sha256",
|
|
495
|
+
"confirmation_sha256",
|
|
496
|
+
))
|
|
497
|
+
or type(value["revision"]) is not int
|
|
498
|
+
or type(value["issued_at"]) is not int
|
|
499
|
+
or type(value["expires_at"]) is not int
|
|
500
|
+
or value["expires_at"] <= value["issued_at"]
|
|
501
|
+
or any(SHA256_RE.fullmatch(value[key]) is None for key in (
|
|
502
|
+
"model_sha256", "normative_sha256", "review_packet_sha256", "confirmation_sha256"
|
|
503
|
+
))
|
|
504
|
+
or not _validate_record(value["record"], value["prerequisite_id"])
|
|
505
|
+
):
|
|
506
|
+
raise PrerequisiteError(
|
|
507
|
+
"INVALID_PENDING_PREREQUISITE", "prerequisite state values are invalid"
|
|
508
|
+
)
|
|
509
|
+
if value["state"] == "ARMED":
|
|
510
|
+
if any(value[key] is not None for key in (
|
|
511
|
+
"event", "authority_source", "receipt", "mutation_request"
|
|
512
|
+
)):
|
|
513
|
+
raise PrerequisiteError(
|
|
514
|
+
"INVALID_PENDING_PREREQUISITE", "armed state contains later-phase data"
|
|
515
|
+
)
|
|
516
|
+
elif value["state"] == "CONFIRMED":
|
|
517
|
+
if (
|
|
518
|
+
not isinstance(value["event"], dict)
|
|
519
|
+
or not isinstance(value["authority_source"], str)
|
|
520
|
+
or value["receipt"] is not None
|
|
521
|
+
or value["mutation_request"] is not None
|
|
522
|
+
):
|
|
523
|
+
raise PrerequisiteError(
|
|
524
|
+
"INVALID_PENDING_PREREQUISITE", "confirmed state is incomplete"
|
|
525
|
+
)
|
|
526
|
+
_validate_confirmed_state(value)
|
|
527
|
+
elif (
|
|
528
|
+
not isinstance(value["event"], dict)
|
|
529
|
+
or not isinstance(value["authority_source"], str)
|
|
530
|
+
or not isinstance(value["receipt"], str)
|
|
531
|
+
or RECEIPT_RE.fullmatch(value["receipt"]) is None
|
|
532
|
+
or not isinstance(value["mutation_request"], dict)
|
|
533
|
+
):
|
|
534
|
+
raise PrerequisiteError(
|
|
535
|
+
"INVALID_PENDING_PREREQUISITE", "captured state is incomplete"
|
|
536
|
+
)
|
|
537
|
+
else:
|
|
538
|
+
_validate_confirmed_state(value)
|
|
539
|
+
if value["mutation_request"] != _expected_mutation_request(value):
|
|
540
|
+
raise PrerequisiteError(
|
|
541
|
+
"INVALID_PENDING_PREREQUISITE",
|
|
542
|
+
"captured mutation request does not match the confirmed decision",
|
|
543
|
+
)
|
|
544
|
+
return value
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def arm_user_prerequisite(
|
|
548
|
+
root: str | Path,
|
|
549
|
+
client: Any,
|
|
550
|
+
artifact_id: str,
|
|
551
|
+
prerequisite_id: str,
|
|
552
|
+
*,
|
|
553
|
+
confirmation_nonce: str | None = None,
|
|
554
|
+
now: int | None = None,
|
|
555
|
+
) -> dict[str, Any]:
|
|
556
|
+
root = Path(root).resolve(strict=True)
|
|
557
|
+
if ID_RE.fullmatch(artifact_id) is None or ID_RE.fullmatch(prerequisite_id) is None:
|
|
558
|
+
raise PrerequisiteError("INVALID_PREREQUISITE", "artifact and prerequisite IDs are invalid")
|
|
559
|
+
identity = _identity(client, artifact_id)
|
|
560
|
+
_approved(client, identity)
|
|
561
|
+
record = _prerequisite_record(client, identity, prerequisite_id)
|
|
562
|
+
final_identity = _identity(client, artifact_id)
|
|
563
|
+
if final_identity != identity:
|
|
564
|
+
raise PrerequisiteError("STALE_PREREQUISITE", "plan changed while arming")
|
|
565
|
+
_approved(client, final_identity)
|
|
566
|
+
packet_sha256 = _digest(_canonical(_review_packet(identity, record)))
|
|
567
|
+
confirmation_nonce = confirmation_nonce or secrets.token_urlsafe(32)
|
|
568
|
+
if CONFIRMATION_RE.fullmatch(confirmation_nonce) is None:
|
|
569
|
+
raise PrerequisiteError(
|
|
570
|
+
"INVALID_CONFIRMATION", "prerequisite confirmation nonce has an invalid shape"
|
|
571
|
+
)
|
|
572
|
+
issued_at = int(time.time()) if now is None else now
|
|
573
|
+
if type(issued_at) is not int or issued_at < 0:
|
|
574
|
+
raise PrerequisiteError("INVALID_TIME", "prerequisite issue time is invalid")
|
|
575
|
+
request_id = secrets.token_urlsafe(18)
|
|
576
|
+
pending = {
|
|
577
|
+
"format": FORMAT,
|
|
578
|
+
"state": "ARMED",
|
|
579
|
+
"request_id": request_id,
|
|
580
|
+
"artifact_id": artifact_id,
|
|
581
|
+
"prerequisite_id": prerequisite_id,
|
|
582
|
+
"kind": identity["kind"],
|
|
583
|
+
"revision": identity["revision"],
|
|
584
|
+
"model_sha256": identity["model_sha256"],
|
|
585
|
+
"normative_sha256": identity["normative_sha256"],
|
|
586
|
+
"record": record,
|
|
587
|
+
"review_packet_sha256": packet_sha256,
|
|
588
|
+
"confirmation_sha256": _digest(confirmation_nonce.encode("ascii")),
|
|
589
|
+
"issued_at": issued_at,
|
|
590
|
+
"expires_at": issued_at + CONFIRMATION_LIFETIME_SECONDS,
|
|
591
|
+
"event": None,
|
|
592
|
+
"authority_source": None,
|
|
593
|
+
"receipt": None,
|
|
594
|
+
"mutation_request": None,
|
|
595
|
+
}
|
|
596
|
+
relative = _pending_relative(artifact_id)
|
|
597
|
+
try:
|
|
598
|
+
_write_new(root, relative, _canonical(pending))
|
|
599
|
+
except FileExistsError as exc:
|
|
600
|
+
raise PrerequisiteError(
|
|
601
|
+
"PENDING_PREREQUISITE",
|
|
602
|
+
"resolve or cancel this plan's existing prerequisite request first",
|
|
603
|
+
) from exc
|
|
604
|
+
return {
|
|
605
|
+
"artifact_id": artifact_id,
|
|
606
|
+
"prerequisite_id": prerequisite_id,
|
|
607
|
+
"title": record["title"],
|
|
608
|
+
"requirement": record["requirement"],
|
|
609
|
+
"review_packet_sha256": packet_sha256,
|
|
610
|
+
"reply": (
|
|
611
|
+
f"satisfy-prerequisite {artifact_id} {prerequisite_id} "
|
|
612
|
+
f"{confirmation_nonce}"
|
|
613
|
+
),
|
|
614
|
+
"pending": relative.as_posix(),
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _match_prompt(prompt: Any) -> tuple[str, str, str] | None:
|
|
619
|
+
if not isinstance(prompt, str):
|
|
620
|
+
return None
|
|
621
|
+
parts = prompt.split(" ")
|
|
622
|
+
if (
|
|
623
|
+
len(parts) != 4
|
|
624
|
+
or parts[0] != "satisfy-prerequisite"
|
|
625
|
+
or ID_RE.fullmatch(parts[1]) is None
|
|
626
|
+
or ID_RE.fullmatch(parts[2]) is None
|
|
627
|
+
or CONFIRMATION_RE.fullmatch(parts[3]) is None
|
|
628
|
+
):
|
|
629
|
+
return None
|
|
630
|
+
return parts[1], parts[2], parts[3]
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _confirm(
|
|
634
|
+
root: Path,
|
|
635
|
+
client: Any,
|
|
636
|
+
pending: dict[str, Any],
|
|
637
|
+
prompt: str,
|
|
638
|
+
*,
|
|
639
|
+
host: str,
|
|
640
|
+
session_id: str,
|
|
641
|
+
now: int,
|
|
642
|
+
) -> dict[str, Any]:
|
|
643
|
+
if now > pending["expires_at"]:
|
|
644
|
+
raise PrerequisiteError(
|
|
645
|
+
"EXPIRED_PREREQUISITE", "the prerequisite confirmation request expired"
|
|
646
|
+
)
|
|
647
|
+
if not HOST_RE.fullmatch(host) or not SESSION_RE.fullmatch(session_id):
|
|
648
|
+
raise PrerequisiteError("INVALID_HOST_CONTEXT", "host prerequisite context is malformed")
|
|
649
|
+
current = _identity(client, pending["artifact_id"])
|
|
650
|
+
for field in ("kind", "revision", "model_sha256", "normative_sha256"):
|
|
651
|
+
if current[field] != pending[field]:
|
|
652
|
+
raise PrerequisiteError(
|
|
653
|
+
"STALE_PREREQUISITE", "plan changed after prerequisite review was armed"
|
|
654
|
+
)
|
|
655
|
+
_approved(client, current)
|
|
656
|
+
record = _prerequisite_record(client, current, pending["prerequisite_id"])
|
|
657
|
+
packet_sha256 = _digest(_canonical(_review_packet(current, record)))
|
|
658
|
+
if record != pending["record"] or packet_sha256 != pending["review_packet_sha256"]:
|
|
659
|
+
raise PrerequisiteError(
|
|
660
|
+
"STALE_PREREQUISITE", "prerequisite definition changed after review was armed"
|
|
661
|
+
)
|
|
662
|
+
event = {
|
|
663
|
+
"format": "codearbiter.workflow-event/0.1.0",
|
|
664
|
+
"kind": "prerequisite",
|
|
665
|
+
"authority_kind": "user_workflow",
|
|
666
|
+
"subject": {
|
|
667
|
+
"artifact_id": current["artifact_id"],
|
|
668
|
+
"normative_sha256": current["normative_sha256"],
|
|
669
|
+
"record_id": pending["prerequisite_id"],
|
|
670
|
+
},
|
|
671
|
+
"actor": "interactive repository user",
|
|
672
|
+
"origin": f"{host}:UserPromptSubmit:{session_id}",
|
|
673
|
+
"verdict": "satisfied",
|
|
674
|
+
"payload": {
|
|
675
|
+
"adapter_policy": POLICY,
|
|
676
|
+
"request_id": pending["request_id"],
|
|
677
|
+
"review_packet_sha256": pending["review_packet_sha256"],
|
|
678
|
+
"prerequisite": pending["record"],
|
|
679
|
+
},
|
|
680
|
+
"source_text": prompt,
|
|
681
|
+
}
|
|
682
|
+
event_bytes = _canonical(event)
|
|
683
|
+
source_rel = SOURCE_DIR / f"{_digest(event_bytes)}.json"
|
|
684
|
+
confirmed = dict(pending)
|
|
685
|
+
confirmed.update(
|
|
686
|
+
state="CONFIRMED",
|
|
687
|
+
event=event,
|
|
688
|
+
authority_source=source_rel.as_posix(),
|
|
689
|
+
)
|
|
690
|
+
_replace_state(root, _pending_relative(pending["artifact_id"]), confirmed)
|
|
691
|
+
return confirmed
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _publish_event(root: Path, pending: dict[str, Any]) -> tuple[str, str]:
|
|
695
|
+
event_bytes = _canonical(pending["event"])
|
|
696
|
+
digest = _digest(event_bytes)
|
|
697
|
+
source_rel = SOURCE_DIR / f"{digest}.json"
|
|
698
|
+
if pending["authority_source"] != source_rel.as_posix():
|
|
699
|
+
raise PrerequisiteError(
|
|
700
|
+
"INVALID_PENDING_PREREQUISITE", "authority source does not match the event"
|
|
701
|
+
)
|
|
702
|
+
try:
|
|
703
|
+
_publish_new(root, source_rel, event_bytes)
|
|
704
|
+
except FileExistsError:
|
|
705
|
+
try:
|
|
706
|
+
existing = _read_bounded_regular(root, source_rel, limit=65536)
|
|
707
|
+
except (FileNotFoundError, OSError) as exc:
|
|
708
|
+
raise PrerequisiteError(
|
|
709
|
+
"INVALID_AUTHORITY_SOURCE", "authority source is unreadable"
|
|
710
|
+
) from exc
|
|
711
|
+
if existing != event_bytes:
|
|
712
|
+
raise PrerequisiteError(
|
|
713
|
+
"INVALID_AUTHORITY_SOURCE", "content-addressed source differs"
|
|
714
|
+
)
|
|
715
|
+
return source_rel.as_posix(), digest
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def consume_user_prerequisite(
|
|
719
|
+
root: str | Path,
|
|
720
|
+
client: Any,
|
|
721
|
+
prompt: str,
|
|
722
|
+
*,
|
|
723
|
+
host: str,
|
|
724
|
+
session_id: str,
|
|
725
|
+
now: int | None = None,
|
|
726
|
+
) -> dict[str, Any]:
|
|
727
|
+
root = Path(root).resolve(strict=True)
|
|
728
|
+
match = _match_prompt(prompt)
|
|
729
|
+
if match is None:
|
|
730
|
+
return {"matched": False, "satisfied": False}
|
|
731
|
+
artifact_id, prerequisite_id, confirmation_nonce = match
|
|
732
|
+
if not (root / _pending_relative(artifact_id)).exists():
|
|
733
|
+
return {"matched": False, "satisfied": False}
|
|
734
|
+
with _pending_transition_lock(root, artifact_id):
|
|
735
|
+
pending = _load_pending(root, artifact_id)
|
|
736
|
+
if pending is None or pending["prerequisite_id"] != prerequisite_id:
|
|
737
|
+
return {"matched": False, "satisfied": False}
|
|
738
|
+
if not secrets.compare_digest(
|
|
739
|
+
_digest(confirmation_nonce.encode("ascii")), pending["confirmation_sha256"]
|
|
740
|
+
):
|
|
741
|
+
return {"matched": False, "satisfied": False}
|
|
742
|
+
observed_at = int(time.time()) if now is None else now
|
|
743
|
+
if type(observed_at) is not int or observed_at < 0:
|
|
744
|
+
raise PrerequisiteError("INVALID_TIME", "prerequisite confirmation time is invalid")
|
|
745
|
+
if pending["state"] == "ARMED":
|
|
746
|
+
pending = _confirm(
|
|
747
|
+
root,
|
|
748
|
+
client,
|
|
749
|
+
pending,
|
|
750
|
+
prompt,
|
|
751
|
+
host=host,
|
|
752
|
+
session_id=session_id,
|
|
753
|
+
now=observed_at,
|
|
754
|
+
)
|
|
755
|
+
source_ref, source_sha256 = _publish_event(root, pending)
|
|
756
|
+
if pending["state"] == "CONFIRMED":
|
|
757
|
+
captured = client.call(
|
|
758
|
+
"capture",
|
|
759
|
+
{"source_ref": source_ref, "source_sha256": source_sha256},
|
|
760
|
+
)
|
|
761
|
+
receipt = captured.get("receipt")
|
|
762
|
+
if not isinstance(receipt, str) or RECEIPT_RE.fullmatch(receipt) is None:
|
|
763
|
+
raise PrerequisiteError("INVALID_RECEIPT", "capture returned no valid receipt")
|
|
764
|
+
operation_suffix = _digest(pending["request_id"].encode("ascii"))[:24]
|
|
765
|
+
mutation_request = {
|
|
766
|
+
"artifact_id": pending["artifact_id"],
|
|
767
|
+
"operation_id": f"host-user-prerequisite-{operation_suffix}",
|
|
768
|
+
"expected": {
|
|
769
|
+
"revision": pending["revision"],
|
|
770
|
+
"model_sha256": pending["model_sha256"],
|
|
771
|
+
},
|
|
772
|
+
"prerequisite": pending["prerequisite_id"],
|
|
773
|
+
"receipt": receipt,
|
|
774
|
+
}
|
|
775
|
+
captured_state = dict(pending)
|
|
776
|
+
captured_state.update(
|
|
777
|
+
state="CAPTURED", receipt=receipt, mutation_request=mutation_request
|
|
778
|
+
)
|
|
779
|
+
_replace_state(root, _pending_relative(artifact_id), captured_state)
|
|
780
|
+
pending = captured_state
|
|
781
|
+
result = client.call("prerequisite", pending["mutation_request"])
|
|
782
|
+
(root / _pending_relative(artifact_id)).unlink()
|
|
783
|
+
return {
|
|
784
|
+
"matched": True,
|
|
785
|
+
"satisfied": True,
|
|
786
|
+
"artifact_id": artifact_id,
|
|
787
|
+
"prerequisite_id": prerequisite_id,
|
|
788
|
+
"authority_source": source_ref,
|
|
789
|
+
"receipt": pending["receipt"],
|
|
790
|
+
"revision": result.get("revision"),
|
|
791
|
+
"replayed": bool((result.get("transaction") or {}).get("replayed")),
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def cancel_user_prerequisite(
|
|
796
|
+
root: str | Path, artifact_id: str, prerequisite_id: str
|
|
797
|
+
) -> dict[str, Any]:
|
|
798
|
+
root = Path(root).resolve(strict=True)
|
|
799
|
+
if not (root / _pending_relative(artifact_id)).exists():
|
|
800
|
+
raise PrerequisiteError(
|
|
801
|
+
"NO_PENDING_PREREQUISITE", "there is no prerequisite request to cancel"
|
|
802
|
+
)
|
|
803
|
+
with _pending_transition_lock(root, artifact_id):
|
|
804
|
+
pending = _load_pending(root, artifact_id)
|
|
805
|
+
if pending is None:
|
|
806
|
+
raise PrerequisiteError(
|
|
807
|
+
"NO_PENDING_PREREQUISITE", "there is no prerequisite request to cancel"
|
|
808
|
+
)
|
|
809
|
+
if pending["prerequisite_id"] != prerequisite_id:
|
|
810
|
+
raise PrerequisiteError(
|
|
811
|
+
"PENDING_PREREQUISITE_MISMATCH",
|
|
812
|
+
"the pending request belongs to another prerequisite",
|
|
813
|
+
)
|
|
814
|
+
if pending["state"] != "ARMED":
|
|
815
|
+
raise PrerequisiteError(
|
|
816
|
+
"PREREQUISITE_RECOVERY_REQUIRED",
|
|
817
|
+
"a confirmed prerequisite decision must be recovered, not cancelled",
|
|
818
|
+
)
|
|
819
|
+
(root / _pending_relative(artifact_id)).unlink()
|
|
820
|
+
return {
|
|
821
|
+
"artifact_id": artifact_id,
|
|
822
|
+
"prerequisite_id": prerequisite_id,
|
|
823
|
+
"cancelled": True,
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def supersede_user_prerequisite(
|
|
828
|
+
root: str | Path,
|
|
829
|
+
client: Any,
|
|
830
|
+
artifact_id: str,
|
|
831
|
+
prerequisite_id: str,
|
|
832
|
+
) -> dict[str, Any]:
|
|
833
|
+
"""Clear a confirmed stale request after excluding a committed replay."""
|
|
834
|
+
root = Path(root).resolve(strict=True)
|
|
835
|
+
if not (root / _pending_relative(artifact_id)).exists():
|
|
836
|
+
raise PrerequisiteError(
|
|
837
|
+
"NO_PENDING_PREREQUISITE", "there is no prerequisite request to supersede"
|
|
838
|
+
)
|
|
839
|
+
with _pending_transition_lock(root, artifact_id):
|
|
840
|
+
pending = _load_pending(root, artifact_id)
|
|
841
|
+
if pending is None:
|
|
842
|
+
raise PrerequisiteError(
|
|
843
|
+
"NO_PENDING_PREREQUISITE", "there is no prerequisite request to supersede"
|
|
844
|
+
)
|
|
845
|
+
if pending["prerequisite_id"] != prerequisite_id:
|
|
846
|
+
raise PrerequisiteError(
|
|
847
|
+
"PENDING_PREREQUISITE_MISMATCH",
|
|
848
|
+
"the pending request belongs to another prerequisite",
|
|
849
|
+
)
|
|
850
|
+
if pending["state"] == "ARMED":
|
|
851
|
+
raise PrerequisiteError(
|
|
852
|
+
"PREREQUISITE_NOT_CONFIRMED",
|
|
853
|
+
"an unconfirmed request should be cancelled, not superseded",
|
|
854
|
+
)
|
|
855
|
+
current = _identity(client, artifact_id)
|
|
856
|
+
if all(current[field] == pending[field] for field in (
|
|
857
|
+
"kind", "revision", "model_sha256", "normative_sha256"
|
|
858
|
+
)):
|
|
859
|
+
raise PrerequisiteError(
|
|
860
|
+
"PREREQUISITE_STILL_CURRENT",
|
|
861
|
+
"the confirmed request still matches the current plan identity",
|
|
862
|
+
)
|
|
863
|
+
if pending["state"] == "CAPTURED":
|
|
864
|
+
try:
|
|
865
|
+
result = client.call("prerequisite", pending["mutation_request"])
|
|
866
|
+
except _artifactlib.ArtifactError as exc:
|
|
867
|
+
if exc.code != "REVISION_CONFLICT":
|
|
868
|
+
raise
|
|
869
|
+
else:
|
|
870
|
+
(root / _pending_relative(artifact_id)).unlink()
|
|
871
|
+
return {
|
|
872
|
+
"artifact_id": artifact_id,
|
|
873
|
+
"prerequisite_id": prerequisite_id,
|
|
874
|
+
"superseded": False,
|
|
875
|
+
"satisfied": True,
|
|
876
|
+
"receipt": pending["receipt"],
|
|
877
|
+
"revision": result.get("revision"),
|
|
878
|
+
"replayed": bool((result.get("transaction") or {}).get("replayed")),
|
|
879
|
+
}
|
|
880
|
+
(root / _pending_relative(artifact_id)).unlink()
|
|
881
|
+
return {
|
|
882
|
+
"artifact_id": artifact_id,
|
|
883
|
+
"prerequisite_id": prerequisite_id,
|
|
884
|
+
"superseded": True,
|
|
885
|
+
"satisfied": False,
|
|
886
|
+
"authority_source": pending["authority_source"],
|
|
887
|
+
"receipt": pending["receipt"],
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def consume_from_hook(
|
|
892
|
+
*,
|
|
893
|
+
root: str | Path,
|
|
894
|
+
plugin_root: str | Path,
|
|
895
|
+
prompt: str,
|
|
896
|
+
host: str,
|
|
897
|
+
session_id: str,
|
|
898
|
+
) -> str:
|
|
899
|
+
attempted = isinstance(prompt, str) and prompt.startswith("satisfy-prerequisite")
|
|
900
|
+
match = _match_prompt(prompt)
|
|
901
|
+
if match is None:
|
|
902
|
+
return (
|
|
903
|
+
"codeArbiter: prerequisite capture failed: exact confirmation syntax required"
|
|
904
|
+
if attempted
|
|
905
|
+
else ""
|
|
906
|
+
)
|
|
907
|
+
if not (Path(root) / _pending_relative(match[0])).exists():
|
|
908
|
+
return (
|
|
909
|
+
"codeArbiter: prerequisite capture failed: no matching armed request"
|
|
910
|
+
if attempted
|
|
911
|
+
else ""
|
|
912
|
+
)
|
|
913
|
+
try:
|
|
914
|
+
client = _artifactlib.ArtifactClient(
|
|
915
|
+
root, Path(plugin_root) / "helpers" / "artifacts"
|
|
916
|
+
)
|
|
917
|
+
result = consume_user_prerequisite(
|
|
918
|
+
root, client, prompt, host=host, session_id=session_id
|
|
919
|
+
)
|
|
920
|
+
except (PrerequisiteError, _artifactlib.ArtifactError, OSError) as exc:
|
|
921
|
+
return f"codeArbiter: prerequisite capture failed: {exc}"
|
|
922
|
+
if not result.get("satisfied"):
|
|
923
|
+
return "codeArbiter: prerequisite capture failed: confirmation did not match"
|
|
924
|
+
return (
|
|
925
|
+
"codeArbiter: workflow prerequisite recorded for "
|
|
926
|
+
f"{result['artifact_id']}#{result['prerequisite_id']} "
|
|
927
|
+
f"(receipt {result['receipt']})."
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def main(argv: list[str] | None = None) -> int:
|
|
932
|
+
parser = argparse.ArgumentParser()
|
|
933
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
934
|
+
arm = sub.add_parser("arm")
|
|
935
|
+
arm.add_argument("--root", required=True)
|
|
936
|
+
arm.add_argument("--artifact-id", required=True)
|
|
937
|
+
arm.add_argument("--prerequisite-id", required=True)
|
|
938
|
+
cancel = sub.add_parser("cancel")
|
|
939
|
+
cancel.add_argument("--root", required=True)
|
|
940
|
+
cancel.add_argument("--artifact-id", required=True)
|
|
941
|
+
cancel.add_argument("--prerequisite-id", required=True)
|
|
942
|
+
supersede = sub.add_parser("supersede")
|
|
943
|
+
supersede.add_argument("--root", required=True)
|
|
944
|
+
supersede.add_argument("--artifact-id", required=True)
|
|
945
|
+
supersede.add_argument("--prerequisite-id", required=True)
|
|
946
|
+
args = parser.parse_args(argv)
|
|
947
|
+
if args.command == "cancel":
|
|
948
|
+
print(json.dumps(cancel_user_prerequisite(
|
|
949
|
+
args.root, args.artifact_id, args.prerequisite_id
|
|
950
|
+
)))
|
|
951
|
+
return 0
|
|
952
|
+
client = _artifactlib.ArtifactClient(
|
|
953
|
+
args.root, _artifactlib.helper_installation(__file__)
|
|
954
|
+
)
|
|
955
|
+
if args.command == "supersede":
|
|
956
|
+
print(json.dumps(supersede_user_prerequisite(
|
|
957
|
+
args.root, client, args.artifact_id, args.prerequisite_id
|
|
958
|
+
)))
|
|
959
|
+
return 0
|
|
960
|
+
print(json.dumps(arm_user_prerequisite(
|
|
961
|
+
args.root, client, args.artifact_id, args.prerequisite_id
|
|
962
|
+
)))
|
|
963
|
+
return 0
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
if __name__ == "__main__":
|
|
967
|
+
try:
|
|
968
|
+
raise SystemExit(main())
|
|
969
|
+
except (PrerequisiteError, _artifactlib.ArtifactError) as exc:
|
|
970
|
+
sys.stderr.write(str(exc) + "\n")
|
|
971
|
+
raise SystemExit(1)
|