backbone-conductor 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.
- backbone_conductor/__init__.py +3 -0
- backbone_conductor/__main__.py +3 -0
- backbone_conductor/api.py +651 -0
- backbone_conductor/audit.py +27 -0
- backbone_conductor/audit_history.py +134 -0
- backbone_conductor/auth.py +359 -0
- backbone_conductor/cli.py +1078 -0
- backbone_conductor/conflicts.py +292 -0
- backbone_conductor/dsh_agent.py +608 -0
- backbone_conductor/ledger.py +243 -0
- backbone_conductor/mcp_http.py +81 -0
- backbone_conductor/mcp_server.py +317 -0
- backbone_conductor/member_client.py +111 -0
- backbone_conductor/models.py +301 -0
- backbone_conductor/private_token.py +36 -0
- backbone_conductor/remote_http.py +66 -0
- backbone_conductor/remote_member_bridge.py +105 -0
- backbone_conductor/review_attempts.py +151 -0
- backbone_conductor/reviewer_client.py +327 -0
- backbone_conductor/runtime.py +147 -0
- backbone_conductor/service.py +1306 -0
- backbone_conductor/storage.py +1284 -0
- backbone_conductor/web/decision_map.css +196 -0
- backbone_conductor/web/decision_map.html +97 -0
- backbone_conductor/web/decision_map.js +431 -0
- backbone_conductor/web/lifecycle_map.html +94 -0
- backbone_conductor/web/lifecycle_map.js +483 -0
- backbone_conductor-0.1.0.dist-info/METADATA +98 -0
- backbone_conductor-0.1.0.dist-info/RECORD +32 -0
- backbone_conductor-0.1.0.dist-info/WHEEL +4 -0
- backbone_conductor-0.1.0.dist-info/entry_points.txt +2 -0
- backbone_conductor-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"""HTTP API with optional bearer authentication and member authorization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI, Query, Request
|
|
12
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .audit import bind_http_actor, reset_http_actor
|
|
17
|
+
from .auth import Principal, TokenAuth
|
|
18
|
+
from .service import Conductor
|
|
19
|
+
|
|
20
|
+
_DECISION_MAP_FILES = {
|
|
21
|
+
"/decision-map": "decision_map.html",
|
|
22
|
+
"/decision-map.css": "decision_map.css",
|
|
23
|
+
"/decision-map.js": "decision_map.js",
|
|
24
|
+
"/lifecycle-map": "lifecycle_map.html",
|
|
25
|
+
"/lifecycle-map.js": "lifecycle_map.js",
|
|
26
|
+
}
|
|
27
|
+
_DECISION_MAP_CSP = (
|
|
28
|
+
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; "
|
|
29
|
+
"base-uri 'none'; form-action 'none'; object-src 'none'; frame-ancestors 'none'"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _member_route(method: str, path: str) -> bool:
|
|
34
|
+
if (method, path) in {
|
|
35
|
+
("GET", "/schema"),
|
|
36
|
+
("GET", "/whoami"),
|
|
37
|
+
("GET", "/decision-map/data"),
|
|
38
|
+
("GET", "/lifecycle-map/data"),
|
|
39
|
+
("GET", "/tasks"),
|
|
40
|
+
("GET", "/sync"),
|
|
41
|
+
("POST", "/intents"),
|
|
42
|
+
("POST", "/decisions"),
|
|
43
|
+
("POST", "/artifacts"),
|
|
44
|
+
}:
|
|
45
|
+
return True
|
|
46
|
+
if method == "GET":
|
|
47
|
+
return re.fullmatch(r"/tasks/[^/]+", path) is not None
|
|
48
|
+
if method == "POST":
|
|
49
|
+
return re.fullmatch(r"/tasks/[^/]+/(start|rebase|submit|fetch)", path) is not None
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _reviewer_route(method: str, path: str) -> bool:
|
|
54
|
+
if method == "GET":
|
|
55
|
+
if path in {
|
|
56
|
+
"/state",
|
|
57
|
+
"/schema",
|
|
58
|
+
"/whoami",
|
|
59
|
+
"/intents",
|
|
60
|
+
"/decisions",
|
|
61
|
+
"/decision-map/data",
|
|
62
|
+
"/lifecycle-map/data",
|
|
63
|
+
"/tasks",
|
|
64
|
+
"/conflicts",
|
|
65
|
+
"/timeline",
|
|
66
|
+
"/audit/verify",
|
|
67
|
+
"/audit/snapshot",
|
|
68
|
+
"/audit/history",
|
|
69
|
+
"/sync",
|
|
70
|
+
"/docs",
|
|
71
|
+
"/redoc",
|
|
72
|
+
"/openapi.json",
|
|
73
|
+
}:
|
|
74
|
+
return True
|
|
75
|
+
return (
|
|
76
|
+
re.fullmatch(r"/(intents|decisions|tasks)/[^/]+", path) is not None
|
|
77
|
+
or re.fullmatch(r"/tasks/[^/]+/inspection", path) is not None
|
|
78
|
+
)
|
|
79
|
+
if method == "POST":
|
|
80
|
+
return (
|
|
81
|
+
re.fullmatch(r"/intents/[^/]+/review", path) is not None
|
|
82
|
+
or re.fullmatch(r"/decisions/[^/]+/revert", path) is not None
|
|
83
|
+
or re.fullmatch(r"/tasks/[^/]+/merge", path) is not None
|
|
84
|
+
or re.fullmatch(r"/conflicts/[^/]+/resolve", path) is not None
|
|
85
|
+
)
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Action(BaseModel):
|
|
90
|
+
model_config = ConfigDict(extra="forbid")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Transition(Action):
|
|
94
|
+
status: str = Field(min_length=1)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Dispatch(Action):
|
|
98
|
+
intent_id: str = Field(min_length=1)
|
|
99
|
+
member_id: str = Field(min_length=1)
|
|
100
|
+
spec: str = ""
|
|
101
|
+
forbidden_paths: list[str] = Field(default_factory=list)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Member(Action):
|
|
105
|
+
member_id: str = Field(min_length=1)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Submission(Member):
|
|
109
|
+
artifact: dict[str, Any]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class BranchFetch(Member):
|
|
113
|
+
branch: str = Field(min_length=1)
|
|
114
|
+
expected_sha: str = Field(min_length=1)
|
|
115
|
+
remote: str = "origin"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Approval(Action):
|
|
119
|
+
author: str = Field(min_length=1)
|
|
120
|
+
rationale: str | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class MergeApproval(Approval):
|
|
124
|
+
expected_version: str = Field(min_length=1)
|
|
125
|
+
expected_target_sha: str = Field(min_length=1)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class IntentRevision(Approval):
|
|
129
|
+
patch: dict[str, Any]
|
|
130
|
+
expected_version: str = Field(min_length=1)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class IntentReplacement(IntentRevision):
|
|
134
|
+
reason: str = Field(min_length=1)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class IntentReviewAction(Approval):
|
|
138
|
+
outcome: str = Field(min_length=1)
|
|
139
|
+
rationale: str = Field(min_length=1)
|
|
140
|
+
expected_version: str = Field(min_length=1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class DecisionRevertAction(Approval):
|
|
144
|
+
rationale: str = Field(min_length=1)
|
|
145
|
+
expected_version: str = Field(min_length=1)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Cancellation(Approval):
|
|
149
|
+
reason: str = Field(min_length=1)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TaskRebase(Member):
|
|
153
|
+
expected_version: str = Field(min_length=1)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Resolution(Approval):
|
|
157
|
+
action: str = Field(min_length=1)
|
|
158
|
+
rationale: str = Field(min_length=1)
|
|
159
|
+
expected_version: str = Field(min_length=1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Sync(Action):
|
|
163
|
+
remote: str = "origin"
|
|
164
|
+
branch: str | None = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Reconciliation(Sync):
|
|
168
|
+
local_head: str = Field(min_length=1)
|
|
169
|
+
remote_head: str = Field(min_length=1)
|
|
170
|
+
author: str = Field(min_length=1)
|
|
171
|
+
rationale: str = Field(min_length=1)
|
|
172
|
+
resolutions: dict[str, dict[str, dict[str, Any]]] | None = None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def create_app(
|
|
176
|
+
repo: str | Path,
|
|
177
|
+
*,
|
|
178
|
+
auth_file: str | Path | None = None,
|
|
179
|
+
ledger_branch: str | None = None,
|
|
180
|
+
mcp_http: bool = False,
|
|
181
|
+
mcp_allowed_hosts: tuple[str, ...] = (),
|
|
182
|
+
) -> FastAPI:
|
|
183
|
+
"""Create a local admin API or an authenticated admin/member/reviewer API."""
|
|
184
|
+
if mcp_http and auth_file is None:
|
|
185
|
+
raise ValueError("Streamable HTTP MCP requires --auth-file")
|
|
186
|
+
if mcp_allowed_hosts and not mcp_http:
|
|
187
|
+
raise ValueError("MCP allowed hosts require Streamable HTTP MCP")
|
|
188
|
+
conductor = Conductor(repo, ledger_branch=ledger_branch)
|
|
189
|
+
auth = TokenAuth(auth_file, conductor.code_store.root) if auth_file is not None else None
|
|
190
|
+
member_mcp = None
|
|
191
|
+
member_mcp_app = None
|
|
192
|
+
if mcp_http:
|
|
193
|
+
from .mcp_http import create_member_http_app
|
|
194
|
+
|
|
195
|
+
assert auth is not None
|
|
196
|
+
member_mcp, member_mcp_app = create_member_http_app(
|
|
197
|
+
repo, auth, ledger_branch=ledger_branch, allowed_hosts=mcp_allowed_hosts
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
@asynccontextmanager
|
|
201
|
+
async def lifespan(_app: FastAPI):
|
|
202
|
+
if member_mcp is None:
|
|
203
|
+
yield
|
|
204
|
+
else:
|
|
205
|
+
async with member_mcp.session_manager.run():
|
|
206
|
+
yield
|
|
207
|
+
|
|
208
|
+
app = FastAPI(
|
|
209
|
+
title="Backbone Conductor",
|
|
210
|
+
version=__version__,
|
|
211
|
+
description=(
|
|
212
|
+
"Without --auth-file, bind to loopback for trusted local administrators. "
|
|
213
|
+
"With --auth-file, bearer tokens authorize admin, member and reviewer operations. "
|
|
214
|
+
"Use TLS at a trusted reverse proxy or configure direct HTTPS for remote access. "
|
|
215
|
+
"Merge approval records require an actual Git merge and human semantic review."
|
|
216
|
+
),
|
|
217
|
+
lifespan=lifespan,
|
|
218
|
+
)
|
|
219
|
+
app.state.conductor = conductor
|
|
220
|
+
app.state.member_mcp = member_mcp
|
|
221
|
+
|
|
222
|
+
@app.middleware("http")
|
|
223
|
+
async def authenticate(request: Request, call_next):
|
|
224
|
+
if mcp_http and request.url.path == "/mcp":
|
|
225
|
+
# The mounted MCP app authenticates every request, including initialize.
|
|
226
|
+
return await call_next(request)
|
|
227
|
+
if request.method == "GET" and request.url.path in _DECISION_MAP_FILES:
|
|
228
|
+
# The shell contains no ledger data. Browser clients provide a bearer
|
|
229
|
+
# token in memory when fetching the role-protected projection below.
|
|
230
|
+
return await call_next(request)
|
|
231
|
+
if auth is None:
|
|
232
|
+
request.state.principal = Principal("local", "admin")
|
|
233
|
+
elif request.url.path == "/health":
|
|
234
|
+
request.state.principal = None
|
|
235
|
+
else:
|
|
236
|
+
try:
|
|
237
|
+
principal = auth.authenticate(request.headers.get("authorization"))
|
|
238
|
+
except ValueError:
|
|
239
|
+
return JSONResponse(
|
|
240
|
+
status_code=503,
|
|
241
|
+
content={"detail": "HTTP credentials are unavailable or invalid"},
|
|
242
|
+
)
|
|
243
|
+
if principal is None:
|
|
244
|
+
return JSONResponse(
|
|
245
|
+
status_code=401,
|
|
246
|
+
content={"detail": "Valid bearer token required"},
|
|
247
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
248
|
+
)
|
|
249
|
+
request.state.principal = principal
|
|
250
|
+
if principal.role == "member" and not _member_route(request.method, request.url.path):
|
|
251
|
+
return JSONResponse(status_code=403, content={"detail": "Admin role required"})
|
|
252
|
+
if principal.role == "reviewer" and not _reviewer_route(
|
|
253
|
+
request.method, request.url.path
|
|
254
|
+
):
|
|
255
|
+
return JSONResponse(
|
|
256
|
+
status_code=403, content={"detail": "Reviewer role cannot access this endpoint"}
|
|
257
|
+
)
|
|
258
|
+
if auth is None or request.state.principal is None:
|
|
259
|
+
return await call_next(request)
|
|
260
|
+
token = bind_http_actor(request.state.principal.name, request.state.principal.role)
|
|
261
|
+
try:
|
|
262
|
+
return await call_next(request)
|
|
263
|
+
finally:
|
|
264
|
+
reset_http_actor(token)
|
|
265
|
+
|
|
266
|
+
def bind_member(request: Request, member_id: str | None) -> str | None:
|
|
267
|
+
principal = request.state.principal
|
|
268
|
+
if principal.role == "member":
|
|
269
|
+
if member_id is not None and member_id != principal.name:
|
|
270
|
+
raise PermissionError("Member identity is bound to the bearer token")
|
|
271
|
+
return principal.name
|
|
272
|
+
return member_id
|
|
273
|
+
|
|
274
|
+
def bind_author(request: Request, data: dict[str, Any], required_status: str) -> dict:
|
|
275
|
+
principal = request.state.principal
|
|
276
|
+
if auth is None:
|
|
277
|
+
return data
|
|
278
|
+
if data.get("author", principal.name) != principal.name:
|
|
279
|
+
raise PermissionError("Author identity is bound to the bearer token")
|
|
280
|
+
if principal.role == "member" and data.get("status", required_status) != required_status:
|
|
281
|
+
raise PermissionError(f"Members may create {required_status} records only")
|
|
282
|
+
return {**data, "author": principal.name}
|
|
283
|
+
|
|
284
|
+
def actor(request: Request, claimed: str) -> str:
|
|
285
|
+
if auth is not None and claimed != request.state.principal.name:
|
|
286
|
+
raise PermissionError("Author identity is bound to the bearer token")
|
|
287
|
+
return claimed
|
|
288
|
+
|
|
289
|
+
def visible_task(request: Request, task_id: str) -> dict:
|
|
290
|
+
result = conductor.state()["tasks"][task_id]
|
|
291
|
+
bind_member(request, result["member_id"])
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
def member_projection(snapshot: dict, name: str) -> tuple[list[dict], list[dict], list[dict]]:
|
|
295
|
+
tasks = [task for task in snapshot["tasks"].values() if task["member_id"] == name]
|
|
296
|
+
visible_intents = {task["intent_id"] for task in tasks}
|
|
297
|
+
visible_intents.update(
|
|
298
|
+
intent["id"] for intent in snapshot["intents"].values() if intent["author"] == name
|
|
299
|
+
)
|
|
300
|
+
intents = [
|
|
301
|
+
intent for intent in snapshot["intents"].values() if intent["id"] in visible_intents
|
|
302
|
+
]
|
|
303
|
+
visible_decisions = {
|
|
304
|
+
decision_id for task in tasks for decision_id in task["decisions_at_fork"]
|
|
305
|
+
}
|
|
306
|
+
visible_decisions.update(
|
|
307
|
+
task["approval"]["decision_id"] for task in tasks if task["approval"] is not None
|
|
308
|
+
)
|
|
309
|
+
decisions = [
|
|
310
|
+
decision
|
|
311
|
+
for decision in snapshot["decisions"].values()
|
|
312
|
+
if decision["id"] in visible_decisions
|
|
313
|
+
or visible_intents.intersection(decision["related_intents"])
|
|
314
|
+
]
|
|
315
|
+
return intents, tasks, decisions
|
|
316
|
+
|
|
317
|
+
async def domain_error(_request: Request, exc: Exception) -> JSONResponse:
|
|
318
|
+
if isinstance(exc, PermissionError):
|
|
319
|
+
status = 403
|
|
320
|
+
elif isinstance(exc, (KeyError, FileNotFoundError)):
|
|
321
|
+
status = 404
|
|
322
|
+
elif isinstance(exc, ValueError):
|
|
323
|
+
status = 422
|
|
324
|
+
else:
|
|
325
|
+
status = 409
|
|
326
|
+
message = str(exc.args[0]) if isinstance(exc, KeyError) else str(exc)
|
|
327
|
+
return JSONResponse(status_code=status, content={"detail": message})
|
|
328
|
+
|
|
329
|
+
for error in (ValueError, KeyError, PermissionError, FileNotFoundError, RuntimeError):
|
|
330
|
+
app.add_exception_handler(error, domain_error)
|
|
331
|
+
|
|
332
|
+
@app.get("/health")
|
|
333
|
+
def health() -> Any:
|
|
334
|
+
if auth is not None:
|
|
335
|
+
try:
|
|
336
|
+
auth.check_available()
|
|
337
|
+
except ValueError:
|
|
338
|
+
return JSONResponse(status_code=503, content={"status": "unavailable"})
|
|
339
|
+
return {"status": "ok"}
|
|
340
|
+
|
|
341
|
+
def map_file(path: str) -> FileResponse:
|
|
342
|
+
return FileResponse(
|
|
343
|
+
Path(__file__).with_name("web") / _DECISION_MAP_FILES[path],
|
|
344
|
+
headers={
|
|
345
|
+
"Cache-Control": "no-store",
|
|
346
|
+
"Content-Security-Policy": _DECISION_MAP_CSP,
|
|
347
|
+
"X-Content-Type-Options": "nosniff",
|
|
348
|
+
},
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
@app.get("/decision-map", include_in_schema=False)
|
|
352
|
+
def decision_map() -> FileResponse:
|
|
353
|
+
return map_file("/decision-map")
|
|
354
|
+
|
|
355
|
+
@app.get("/decision-map.css", include_in_schema=False)
|
|
356
|
+
def decision_map_css() -> FileResponse:
|
|
357
|
+
return map_file("/decision-map.css")
|
|
358
|
+
|
|
359
|
+
@app.get("/decision-map.js", include_in_schema=False)
|
|
360
|
+
def decision_map_js() -> FileResponse:
|
|
361
|
+
return map_file("/decision-map.js")
|
|
362
|
+
|
|
363
|
+
@app.get("/decision-map/data")
|
|
364
|
+
def decision_map_data(request: Request) -> JSONResponse:
|
|
365
|
+
snapshot = conductor.state()
|
|
366
|
+
decisions = list(snapshot["decisions"].values())
|
|
367
|
+
principal = request.state.principal
|
|
368
|
+
if principal.role == "member":
|
|
369
|
+
_, _, decisions = member_projection(snapshot, principal.name)
|
|
370
|
+
return JSONResponse(
|
|
371
|
+
content={
|
|
372
|
+
"version": snapshot["version"],
|
|
373
|
+
"decisions": decisions,
|
|
374
|
+
},
|
|
375
|
+
headers={"Cache-Control": "no-store"},
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
@app.get("/lifecycle-map", include_in_schema=False)
|
|
379
|
+
def lifecycle_map() -> FileResponse:
|
|
380
|
+
return map_file("/lifecycle-map")
|
|
381
|
+
|
|
382
|
+
@app.get("/lifecycle-map.js", include_in_schema=False)
|
|
383
|
+
def lifecycle_map_js() -> FileResponse:
|
|
384
|
+
return map_file("/lifecycle-map.js")
|
|
385
|
+
|
|
386
|
+
@app.get("/lifecycle-map/data")
|
|
387
|
+
def lifecycle_map_data(request: Request) -> JSONResponse:
|
|
388
|
+
snapshot = conductor.state()
|
|
389
|
+
intents = list(snapshot["intents"].values())
|
|
390
|
+
tasks = list(snapshot["tasks"].values())
|
|
391
|
+
decisions = list(snapshot["decisions"].values())
|
|
392
|
+
principal = request.state.principal
|
|
393
|
+
if principal.role == "member":
|
|
394
|
+
intents, tasks, decisions = member_projection(snapshot, principal.name)
|
|
395
|
+
return JSONResponse(
|
|
396
|
+
content={
|
|
397
|
+
"version": snapshot["version"],
|
|
398
|
+
"intents": intents,
|
|
399
|
+
"tasks": tasks,
|
|
400
|
+
"decisions": decisions,
|
|
401
|
+
},
|
|
402
|
+
headers={"Cache-Control": "no-store"},
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
@app.post("/initialize")
|
|
406
|
+
def initialize() -> dict:
|
|
407
|
+
return conductor.initialize()
|
|
408
|
+
|
|
409
|
+
@app.get("/state")
|
|
410
|
+
def state() -> dict:
|
|
411
|
+
return conductor.state()
|
|
412
|
+
|
|
413
|
+
@app.get("/whoami")
|
|
414
|
+
def whoami(request: Request) -> dict[str, str]:
|
|
415
|
+
principal = request.state.principal
|
|
416
|
+
return {"name": principal.name, "role": principal.role}
|
|
417
|
+
|
|
418
|
+
@app.get("/schema")
|
|
419
|
+
def schema() -> dict:
|
|
420
|
+
from .models import BackboneState
|
|
421
|
+
|
|
422
|
+
return BackboneState.model_json_schema()
|
|
423
|
+
|
|
424
|
+
@app.get("/intents")
|
|
425
|
+
def intents() -> list[dict]:
|
|
426
|
+
return list(conductor.state()["intents"].values())
|
|
427
|
+
|
|
428
|
+
@app.post("/intents", status_code=201)
|
|
429
|
+
def create_intent(data: dict[str, Any], request: Request) -> dict:
|
|
430
|
+
return conductor.create_intent(bind_author(request, data, "draft"))
|
|
431
|
+
|
|
432
|
+
@app.get("/intents/{intent_id}")
|
|
433
|
+
def intent(intent_id: str) -> dict:
|
|
434
|
+
return conductor.state()["intents"][intent_id]
|
|
435
|
+
|
|
436
|
+
@app.post("/intents/{intent_id}/transition")
|
|
437
|
+
def transition_intent(intent_id: str, data: Transition) -> dict:
|
|
438
|
+
return conductor.transition_intent(intent_id, data.status)
|
|
439
|
+
|
|
440
|
+
@app.post("/intents/{intent_id}/review")
|
|
441
|
+
def review_intent(intent_id: str, data: IntentReviewAction, request: Request) -> dict:
|
|
442
|
+
return conductor.review_intent(
|
|
443
|
+
intent_id,
|
|
444
|
+
data.outcome,
|
|
445
|
+
actor(request, data.author),
|
|
446
|
+
data.rationale,
|
|
447
|
+
data.expected_version,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
@app.post("/intents/{intent_id}/revise")
|
|
451
|
+
def revise_intent(intent_id: str, data: IntentRevision, request: Request) -> dict:
|
|
452
|
+
return conductor.revise_intent(
|
|
453
|
+
intent_id, data.patch, actor(request, data.author), data.expected_version
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
@app.post("/intents/{intent_id}/replace", status_code=201)
|
|
457
|
+
def replace_intent(intent_id: str, data: IntentReplacement, request: Request) -> dict:
|
|
458
|
+
return conductor.replace_intent(
|
|
459
|
+
intent_id, data.patch, actor(request, data.author), data.reason, data.expected_version
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
@app.get("/decisions")
|
|
463
|
+
def decisions() -> list[dict]:
|
|
464
|
+
return list(conductor.state()["decisions"].values())
|
|
465
|
+
|
|
466
|
+
@app.post("/decisions", status_code=201)
|
|
467
|
+
def create_decision(data: dict[str, Any], request: Request) -> dict:
|
|
468
|
+
return conductor.log_decision(bind_author(request, data, "proposed"))
|
|
469
|
+
|
|
470
|
+
@app.get("/decisions/{decision_id}")
|
|
471
|
+
def decision(decision_id: str) -> dict:
|
|
472
|
+
return conductor.state()["decisions"][decision_id]
|
|
473
|
+
|
|
474
|
+
@app.post("/decisions/{decision_id}/transition")
|
|
475
|
+
def transition_decision(decision_id: str, data: Transition) -> dict:
|
|
476
|
+
return conductor.transition_decision(decision_id, data.status)
|
|
477
|
+
|
|
478
|
+
@app.post("/decisions/{decision_id}/revert")
|
|
479
|
+
def revert_decision(decision_id: str, data: DecisionRevertAction, request: Request) -> dict:
|
|
480
|
+
return conductor.revert_decision(
|
|
481
|
+
decision_id, actor(request, data.author), data.rationale, data.expected_version
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
@app.get("/tasks")
|
|
485
|
+
def tasks(request: Request, member_id: str | None = None) -> dict:
|
|
486
|
+
member_id = bind_member(request, member_id)
|
|
487
|
+
if member_id is not None:
|
|
488
|
+
return conductor.get_my_task(member_id)
|
|
489
|
+
return {"tasks": list(conductor.state()["tasks"].values())}
|
|
490
|
+
|
|
491
|
+
@app.post("/tasks", status_code=201)
|
|
492
|
+
def dispatch_task(data: Dispatch) -> dict:
|
|
493
|
+
return conductor.dispatch_task(
|
|
494
|
+
data.intent_id, data.member_id, data.spec, data.forbidden_paths
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
@app.get("/tasks/{task_id}")
|
|
498
|
+
def task(task_id: str, request: Request) -> dict:
|
|
499
|
+
return visible_task(request, task_id)
|
|
500
|
+
|
|
501
|
+
@app.get("/tasks/{task_id}/inspection")
|
|
502
|
+
def task_inspection(task_id: str, full_patch: bool = False) -> dict:
|
|
503
|
+
return conductor.inspect_task(task_id, full_patch=full_patch)
|
|
504
|
+
|
|
505
|
+
@app.post("/tasks/{task_id}/start")
|
|
506
|
+
def start_task(task_id: str, data: Member, request: Request) -> dict:
|
|
507
|
+
return conductor.start_task(task_id, bind_member(request, data.member_id))
|
|
508
|
+
|
|
509
|
+
@app.post("/tasks/{task_id}/rebase")
|
|
510
|
+
def rebase_task(task_id: str, data: TaskRebase, request: Request) -> dict:
|
|
511
|
+
return conductor.rebase_task(
|
|
512
|
+
task_id, bind_member(request, data.member_id), data.expected_version
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
@app.post("/tasks/{task_id}/fetch")
|
|
516
|
+
def fetch_task_branch(task_id: str, data: BranchFetch, request: Request) -> dict:
|
|
517
|
+
return conductor.fetch_artifact_branch(
|
|
518
|
+
task_id,
|
|
519
|
+
bind_member(request, data.member_id),
|
|
520
|
+
data.branch,
|
|
521
|
+
data.expected_sha,
|
|
522
|
+
data.remote,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
@app.post("/tasks/{task_id}/cancel")
|
|
526
|
+
def cancel_task(task_id: str, data: Cancellation, request: Request) -> dict:
|
|
527
|
+
return conductor.cancel_task(task_id, actor(request, data.author), data.reason)
|
|
528
|
+
|
|
529
|
+
@app.post("/artifacts", status_code=201)
|
|
530
|
+
def submit_artifact(data: Submission, request: Request) -> dict:
|
|
531
|
+
return conductor.submit_artifact(bind_member(request, data.member_id), data.artifact)
|
|
532
|
+
|
|
533
|
+
@app.post("/tasks/{task_id}/submit")
|
|
534
|
+
def submit_task(task_id: str, data: Submission, request: Request) -> dict:
|
|
535
|
+
assigned = visible_task(request, task_id)
|
|
536
|
+
bind_member(request, data.member_id)
|
|
537
|
+
if assigned["member_id"] != data.member_id:
|
|
538
|
+
raise PermissionError("Task belongs to another member")
|
|
539
|
+
artifact = dict(data.artifact)
|
|
540
|
+
if artifact.get("intent_id", assigned["intent_id"]) != assigned["intent_id"]:
|
|
541
|
+
raise ValueError("artifact.intent_id must match the task in the request path")
|
|
542
|
+
artifact["intent_id"] = assigned["intent_id"]
|
|
543
|
+
return conductor.submit_artifact(data.member_id, artifact)
|
|
544
|
+
|
|
545
|
+
@app.post("/tasks/{task_id}/merge")
|
|
546
|
+
def merge_task(task_id: str, data: MergeApproval, request: Request) -> dict:
|
|
547
|
+
"""Record human approval after performing the actual Git merge externally."""
|
|
548
|
+
principal = request.state.principal
|
|
549
|
+
if principal.role == "reviewer":
|
|
550
|
+
if not data.rationale or not data.rationale.strip():
|
|
551
|
+
raise ValueError("Reviewer approval requires a rationale")
|
|
552
|
+
if conductor.state()["tasks"][task_id]["member_id"] == principal.name:
|
|
553
|
+
raise PermissionError("Reviewers cannot approve their own assigned task")
|
|
554
|
+
return conductor.merge_task(
|
|
555
|
+
task_id,
|
|
556
|
+
actor(request, data.author),
|
|
557
|
+
data.rationale,
|
|
558
|
+
expected_version=data.expected_version,
|
|
559
|
+
expected_target_sha=data.expected_target_sha,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
@app.get("/conflicts")
|
|
563
|
+
def conflicts() -> list[dict]:
|
|
564
|
+
return list(conductor.state()["conflicts"].values())
|
|
565
|
+
|
|
566
|
+
@app.post("/conflicts/check")
|
|
567
|
+
def detect_conflicts() -> dict:
|
|
568
|
+
return conductor.detect_conflicts()
|
|
569
|
+
|
|
570
|
+
@app.post("/conflicts/{conflict_id}/resolve")
|
|
571
|
+
def resolve_conflict(conflict_id: str, data: Resolution, request: Request) -> dict:
|
|
572
|
+
return conductor.resolve_conflict(
|
|
573
|
+
conflict_id,
|
|
574
|
+
actor(request, data.author),
|
|
575
|
+
data.action,
|
|
576
|
+
data.rationale,
|
|
577
|
+
data.expected_version,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
@app.get("/sync")
|
|
581
|
+
def check_sync(
|
|
582
|
+
request: Request, member_id: str | None = None, since_version: str | None = None
|
|
583
|
+
) -> dict:
|
|
584
|
+
return conductor.check_backbone_sync(bind_member(request, member_id), since_version)
|
|
585
|
+
|
|
586
|
+
@app.post("/sync")
|
|
587
|
+
def sync(data: Sync) -> dict:
|
|
588
|
+
return conductor.sync(data.remote, data.branch)
|
|
589
|
+
|
|
590
|
+
@app.post("/refresh")
|
|
591
|
+
def refresh(data: Sync) -> dict:
|
|
592
|
+
return conductor.refresh(data.remote, data.branch)
|
|
593
|
+
|
|
594
|
+
@app.post("/reconcile")
|
|
595
|
+
def reconcile(data: Reconciliation, request: Request) -> dict:
|
|
596
|
+
return conductor.reconcile(
|
|
597
|
+
data.local_head,
|
|
598
|
+
data.remote_head,
|
|
599
|
+
actor(request, data.author),
|
|
600
|
+
data.rationale,
|
|
601
|
+
data.remote,
|
|
602
|
+
data.branch,
|
|
603
|
+
data.resolutions,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
@app.get("/timeline")
|
|
607
|
+
def timeline(
|
|
608
|
+
limit: int = Query(default=50, ge=1, le=1000),
|
|
609
|
+
author: str | None = None,
|
|
610
|
+
http_principal: str | None = None,
|
|
611
|
+
event_type: str | None = Query(
|
|
612
|
+
default=None,
|
|
613
|
+
pattern="^(initialize|intent|decision|task|artifact|conflict|reconcile|migrate|other)$",
|
|
614
|
+
),
|
|
615
|
+
since: datetime | None = None,
|
|
616
|
+
until: datetime | None = None,
|
|
617
|
+
) -> list[dict]:
|
|
618
|
+
if (since is not None and since.tzinfo is None) or (
|
|
619
|
+
until is not None and until.tzinfo is None
|
|
620
|
+
):
|
|
621
|
+
raise ValueError("Timeline dates must include a timezone")
|
|
622
|
+
if since is not None and until is not None and since > until:
|
|
623
|
+
raise ValueError("since must not be after until")
|
|
624
|
+
return conductor.log(
|
|
625
|
+
limit,
|
|
626
|
+
author=author,
|
|
627
|
+
http_principal=http_principal,
|
|
628
|
+
event_type=event_type,
|
|
629
|
+
since=since.isoformat() if since is not None else None,
|
|
630
|
+
until=until.isoformat() if until is not None else None,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
@app.get("/audit/verify")
|
|
634
|
+
def verify_audit(limit: int = Query(default=50, ge=1, le=1000)) -> dict:
|
|
635
|
+
return conductor.verify_audit_signatures(limit)
|
|
636
|
+
|
|
637
|
+
@app.get("/audit/snapshot")
|
|
638
|
+
def verify_snapshot() -> dict:
|
|
639
|
+
return conductor.verify_current_snapshot()
|
|
640
|
+
|
|
641
|
+
@app.get("/audit/history")
|
|
642
|
+
def verify_history(
|
|
643
|
+
limit: int = Query(default=50, ge=1, le=1000),
|
|
644
|
+
offset: int = Query(default=0, ge=0),
|
|
645
|
+
expected_head: str | None = None,
|
|
646
|
+
) -> dict:
|
|
647
|
+
return conductor.verify_audit_history(limit, offset, expected_head)
|
|
648
|
+
|
|
649
|
+
if member_mcp_app is not None:
|
|
650
|
+
app.mount("/", member_mcp_app)
|
|
651
|
+
return app
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Request-scoped attribution for commits made through authenticated HTTP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextvars import ContextVar, Token
|
|
6
|
+
|
|
7
|
+
_HTTP_ACTOR: ContextVar[tuple[str, str] | None] = ContextVar("backbone_http_actor", default=None)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def bind_http_actor(name: str, role: str) -> Token[tuple[str, str] | None]:
|
|
11
|
+
"""Bind the already authenticated request principal until its response completes."""
|
|
12
|
+
return _HTTP_ACTOR.set((name, role))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def reset_http_actor(token: Token[tuple[str, str] | None]) -> None:
|
|
16
|
+
_HTTP_ACTOR.reset(token)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def attributed_message(message: str) -> str:
|
|
20
|
+
"""Append a Git trailer without claiming that Git itself verified the token."""
|
|
21
|
+
actor = _HTTP_ACTOR.get()
|
|
22
|
+
if actor is None:
|
|
23
|
+
return message.rstrip("\n") + "\n"
|
|
24
|
+
name, role = actor
|
|
25
|
+
return (
|
|
26
|
+
message.rstrip("\n") + f"\n\nBackbone-HTTP-Principal: {name}\nBackbone-HTTP-Role: {role}\n"
|
|
27
|
+
)
|