shiptrail 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.
- shiptrail/__init__.py +8 -0
- shiptrail/api/__init__.py +1 -0
- shiptrail/api/app.py +506 -0
- shiptrail/api/streams.py +138 -0
- shiptrail/cli.py +425 -0
- shiptrail/dashboard_dist/THIRD_PARTY_NOTICES.txt +6511 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-greek-500-normal-CuWXN6rf.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-greek-500-normal-JMMifIXV.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2 +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
- shiptrail/dashboard_dist/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
- shiptrail/dashboard_dist/assets/index-CFxUIJrK.css +1 -0
- shiptrail/dashboard_dist/assets/index-jGh4BLlJ.js +202 -0
- shiptrail/dashboard_dist/index.html +13 -0
- shiptrail/engine/__init__.py +1 -0
- shiptrail/engine/activity.py +36 -0
- shiptrail/engine/adapters/__init__.py +15 -0
- shiptrail/engine/adapters/base.py +110 -0
- shiptrail/engine/adapters/codex.py +295 -0
- shiptrail/engine/adapters/opencode.py +322 -0
- shiptrail/engine/backlog.py +182 -0
- shiptrail/engine/catalog.py +145 -0
- shiptrail/engine/changes.py +139 -0
- shiptrail/engine/config.py +388 -0
- shiptrail/engine/crash.py +11 -0
- shiptrail/engine/db.py +97 -0
- shiptrail/engine/gitops.py +312 -0
- shiptrail/engine/library.py +83 -0
- shiptrail/engine/locking.py +50 -0
- shiptrail/engine/migrations/machine/0001_init.sql +15 -0
- shiptrail/engine/migrations/repo/0001_init.sql +89 -0
- shiptrail/engine/packet.py +307 -0
- shiptrail/engine/pipeline.py +516 -0
- shiptrail/engine/ports.py +154 -0
- shiptrail/engine/registry.py +87 -0
- shiptrail/engine/reports.py +98 -0
- shiptrail/engine/roles.py +210 -0
- shiptrail/engine/runtime.py +600 -0
- shiptrail/engine/scaffold.py +449 -0
- shiptrail/engine/scheduler.py +228 -0
- shiptrail/engine/supervisor.py +837 -0
- shiptrail/engine/usage.py +52 -0
- shiptrail/engine/versioning.py +106 -0
- shiptrail/engine/winjob.py +197 -0
- shiptrail/paths.py +30 -0
- shiptrail/roles/README.md +22 -0
- shiptrail/roles/approver.md +56 -0
- shiptrail/roles/builder.md +85 -0
- shiptrail/roles/fast-approver.md +64 -0
- shiptrail/roles/reviewer-code.md +53 -0
- shiptrail/roles/reviewer-security.md +52 -0
- shiptrail/roles/reviewer-ui.md +59 -0
- shiptrail/roles/reviewer-ux.md +60 -0
- shiptrail/roles/spec-writer.md +65 -0
- shiptrail/run_cli.py +355 -0
- shiptrail/service.py +262 -0
- shiptrail/templates/AGENTS.starter.md +26 -0
- shiptrail/templates/SKILL.claude.md +23 -0
- shiptrail/templates/SKILL.codex.md +23 -0
- shiptrail/templates/SKILL.opencode.md +23 -0
- shiptrail/templates/backlog.md +3 -0
- shiptrail/templates/config.json +209 -0
- shiptrail-0.1.0.dist-info/METADATA +201 -0
- shiptrail-0.1.0.dist-info/RECORD +118 -0
- shiptrail-0.1.0.dist-info/WHEEL +4 -0
- shiptrail-0.1.0.dist-info/entry_points.txt +2 -0
- shiptrail-0.1.0.dist-info/licenses/LICENSE +21 -0
shiptrail/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Local machine HTTP API."""
|
shiptrail/api/app.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""Authenticated local API, snapshots and SSE delivery (plan §13.2)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import html
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import secrets
|
|
12
|
+
from typing import Annotated, Any, Literal
|
|
13
|
+
|
|
14
|
+
from fastapi import Body, FastAPI, HTTPException, Query
|
|
15
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
17
|
+
from starlette.requests import Request
|
|
18
|
+
from starlette.responses import StreamingResponse
|
|
19
|
+
|
|
20
|
+
from shiptrail import __version__, run_cli
|
|
21
|
+
from shiptrail.api import streams
|
|
22
|
+
from shiptrail.engine import (backlog, catalog, changes, db, gitops, library, pipeline, roles as
|
|
23
|
+
role_files, versioning)
|
|
24
|
+
from shiptrail.engine.config import (MachineSettings, load_config, load_machine_settings,
|
|
25
|
+
preview_pipeline, save_machine_settings, validate_config)
|
|
26
|
+
from shiptrail.engine.runtime import Runtime
|
|
27
|
+
|
|
28
|
+
BODY_LIMIT = 100_000
|
|
29
|
+
JobState = Literal["queued", "running", "paused", "needs_human", "approved", "merged", "cancelled"]
|
|
30
|
+
DASHBOARD_DIR = Path(__file__).resolve().parents[1] / "dashboard_dist"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Security:
|
|
34
|
+
"""ASGI body limiting counts bytes even for chunked/no-length requests."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, app: Any, *, port: int, token: str) -> None:
|
|
37
|
+
self.app, self.port, self.token = app, port, token
|
|
38
|
+
|
|
39
|
+
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
|
|
40
|
+
if scope["type"] != "http":
|
|
41
|
+
await self.app(scope, receive, send)
|
|
42
|
+
return
|
|
43
|
+
headers = scope.get("headers", [])
|
|
44
|
+
|
|
45
|
+
def values(name: bytes) -> list[str]:
|
|
46
|
+
return [v.decode("latin-1") for k, v in headers if k.lower() == name]
|
|
47
|
+
|
|
48
|
+
hosts = values(b"host")
|
|
49
|
+
status, detail = 0, ""
|
|
50
|
+
if len(hosts) != 1 or hosts[0] not in {f"127.0.0.1:{self.port}", f"localhost:{self.port}"}:
|
|
51
|
+
status, detail = 403, "Invalid Host"
|
|
52
|
+
elif scope["method"] not in {"GET", "HEAD", "OPTIONS"}:
|
|
53
|
+
origins, tokens = values(b"origin"), values(b"x-shiptrail-token")
|
|
54
|
+
if origins and origins != ["http://" + hosts[0]]:
|
|
55
|
+
status, detail = 403, "Cross-site Origin is forbidden"
|
|
56
|
+
elif len(tokens) != 1 or not secrets.compare_digest(tokens[0], self.token):
|
|
57
|
+
status, detail = 403, "Missing or invalid X-Shiptrail-Token"
|
|
58
|
+
messages, size = [], 0
|
|
59
|
+
if not status:
|
|
60
|
+
lengths = values(b"content-length")
|
|
61
|
+
if lengths and (len(lengths) != 1 or not lengths[0].isdecimal()):
|
|
62
|
+
status, detail = 400, "Invalid Content-Length"
|
|
63
|
+
elif lengths and int(lengths[0]) > BODY_LIMIT:
|
|
64
|
+
status, detail = 413, "Body exceeds 100 KB"
|
|
65
|
+
if not status:
|
|
66
|
+
while True:
|
|
67
|
+
message = await receive()
|
|
68
|
+
if message["type"] == "http.disconnect":
|
|
69
|
+
return
|
|
70
|
+
size += len(message.get("body", b""))
|
|
71
|
+
if size > BODY_LIMIT:
|
|
72
|
+
status, detail = 413, "Body exceeds 100 KB"
|
|
73
|
+
break
|
|
74
|
+
messages.append(message)
|
|
75
|
+
if not message.get("more_body", False):
|
|
76
|
+
break
|
|
77
|
+
if status:
|
|
78
|
+
await JSONResponse({"detail": detail}, status_code=status)(scope, receive, send)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
async def replay() -> dict:
|
|
82
|
+
if messages:
|
|
83
|
+
return messages.pop(0)
|
|
84
|
+
return await receive()
|
|
85
|
+
|
|
86
|
+
await self.app(scope, replay, send)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class JobBody(BaseModel):
|
|
90
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
91
|
+
title: str = Field(min_length=1, max_length=300)
|
|
92
|
+
details: str = ""
|
|
93
|
+
pipeline: str | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ActionBody(BaseModel):
|
|
97
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
98
|
+
note: str = ""
|
|
99
|
+
force: bool = False
|
|
100
|
+
confirm_sensitive: bool = False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def confined(root: Path, path: str) -> Path:
|
|
104
|
+
# resolve handles symlinks and Windows separators, drives and junctions.
|
|
105
|
+
value = Path(path)
|
|
106
|
+
target = (root / value).resolve()
|
|
107
|
+
if value.is_absolute() or target == root.resolve() or not target.is_relative_to(root.resolve()):
|
|
108
|
+
raise HTTPException(403, "File path escapes shiptrail/jobs")
|
|
109
|
+
return target
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def create_app(*,
|
|
113
|
+
port: int = 5099,
|
|
114
|
+
token: str | None = None,
|
|
115
|
+
runtime: Runtime | None = None,
|
|
116
|
+
run_engine: bool = True) -> FastAPI:
|
|
117
|
+
token = token or secrets.token_urlsafe(32)
|
|
118
|
+
|
|
119
|
+
@asynccontextmanager
|
|
120
|
+
async def lifespan(app: FastAPI):
|
|
121
|
+
app.state.runtime = runtime or Runtime()
|
|
122
|
+
current = app.state.runtime
|
|
123
|
+
stopped = asyncio.Event()
|
|
124
|
+
|
|
125
|
+
async def poll() -> None:
|
|
126
|
+
while not stopped.is_set():
|
|
127
|
+
try:
|
|
128
|
+
await asyncio.to_thread(current.tick)
|
|
129
|
+
except Exception:
|
|
130
|
+
logging.getLogger(__name__).exception("Engine tick failed")
|
|
131
|
+
try:
|
|
132
|
+
await asyncio.wait_for(stopped.wait(), timeout=0.25)
|
|
133
|
+
except TimeoutError:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
task = None
|
|
137
|
+
try:
|
|
138
|
+
await asyncio.to_thread(current.start)
|
|
139
|
+
if run_engine:
|
|
140
|
+
task = asyncio.create_task(poll())
|
|
141
|
+
yield
|
|
142
|
+
finally:
|
|
143
|
+
stopped.set()
|
|
144
|
+
if task:
|
|
145
|
+
await task # Finish any tick before closing DB writers.
|
|
146
|
+
if runtime is None:
|
|
147
|
+
await asyncio.to_thread(current.close)
|
|
148
|
+
|
|
149
|
+
app = FastAPI(lifespan=lifespan)
|
|
150
|
+
app.add_middleware(Security, port=port, token=token)
|
|
151
|
+
app.state.shutdown = lambda: None
|
|
152
|
+
|
|
153
|
+
@app.exception_handler(KeyError)
|
|
154
|
+
async def unknown(request: Request, error: KeyError) -> JSONResponse:
|
|
155
|
+
return JSONResponse({"detail": str(error)}, status_code=404)
|
|
156
|
+
|
|
157
|
+
@app.exception_handler(ValueError)
|
|
158
|
+
async def invalid(request: Request, error: ValueError) -> JSONResponse:
|
|
159
|
+
if isinstance(error, ValidationError):
|
|
160
|
+
return JSONResponse({"detail": json.loads(error.json(include_url=False))},
|
|
161
|
+
status_code=422)
|
|
162
|
+
code = getattr(error, "exit_code", 1)
|
|
163
|
+
return JSONResponse({
|
|
164
|
+
"detail": str(error),
|
|
165
|
+
"exit_code": code
|
|
166
|
+
},
|
|
167
|
+
status_code=409 if code in {3, 4} else 400)
|
|
168
|
+
|
|
169
|
+
def engine() -> Runtime:
|
|
170
|
+
return app.state.runtime
|
|
171
|
+
|
|
172
|
+
def stream_response(iterator) -> StreamingResponse:
|
|
173
|
+
return StreamingResponse(iterator,
|
|
174
|
+
media_type="text/event-stream",
|
|
175
|
+
headers={
|
|
176
|
+
"Cache-Control": "no-cache",
|
|
177
|
+
"X-Accel-Buffering": "no"
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
def cursor(request: Request, initial: int) -> int:
|
|
181
|
+
value = request.headers.get("Last-Event-ID", str(initial))
|
|
182
|
+
if len(value) > 19 or not value.isascii() or not value.isdecimal() or int(
|
|
183
|
+
value) > 2**63 - 1:
|
|
184
|
+
raise HTTPException(400, "Last-Event-ID must be a nonnegative 64-bit integer")
|
|
185
|
+
return int(value)
|
|
186
|
+
|
|
187
|
+
@app.get("/api/overview/stream")
|
|
188
|
+
async def overview_events(request: Request):
|
|
189
|
+
return stream_response(streams.overview_stream(request, engine()))
|
|
190
|
+
|
|
191
|
+
@app.get("/api/repos/{repo}/events")
|
|
192
|
+
async def events(repo: str,
|
|
193
|
+
request: Request,
|
|
194
|
+
after: Annotated[int, Query(ge=0, le=2**63 - 1)] = 0):
|
|
195
|
+
await asyncio.to_thread(engine().snapshot, repo)
|
|
196
|
+
return stream_response(streams.repo_stream(request, engine(), repo, cursor(request, after)))
|
|
197
|
+
|
|
198
|
+
@app.get("/api/repos/{repo}/attempts/{attempt_id}/stream")
|
|
199
|
+
def attempt_stream(repo: str,
|
|
200
|
+
attempt_id: int,
|
|
201
|
+
request: Request,
|
|
202
|
+
offset: Annotated[int, Query(ge=0, le=2**63 - 1)] = 0):
|
|
203
|
+
with engine().lock:
|
|
204
|
+
root, writer = engine().repo(repo)
|
|
205
|
+
|
|
206
|
+
def attempt():
|
|
207
|
+
return writer.call(lambda connection: connection.execute(
|
|
208
|
+
"SELECT a.dir,a.state,e.job_id FROM attempts a JOIN executions e "
|
|
209
|
+
"ON e.id=a.execution_id WHERE a.id=?", (attempt_id, )).fetchone())
|
|
210
|
+
|
|
211
|
+
row = attempt()
|
|
212
|
+
if row is None:
|
|
213
|
+
raise HTTPException(404, "Unknown attempt")
|
|
214
|
+
path = confined(root / "shiptrail/jobs", f"{row['job_id']}/{row['dir']}/events.jsonl")
|
|
215
|
+
|
|
216
|
+
def finished():
|
|
217
|
+
with engine().lock:
|
|
218
|
+
row = attempt()
|
|
219
|
+
return engine().stopping or row is None or row["state"] not in {
|
|
220
|
+
"launching", "running"
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return stream_response(streams.log_stream(request, path, cursor(request, offset), finished))
|
|
224
|
+
|
|
225
|
+
@app.get("/api/health")
|
|
226
|
+
def health() -> dict:
|
|
227
|
+
return {"version": __version__, "pid": os.getpid()}
|
|
228
|
+
|
|
229
|
+
@app.get("/api/overview")
|
|
230
|
+
def overview() -> dict:
|
|
231
|
+
return engine().overview()
|
|
232
|
+
|
|
233
|
+
@app.get("/api/jobs")
|
|
234
|
+
def jobs(state: Annotated[list[JobState] | None, Query()] = None,
|
|
235
|
+
repo: str | None = None,
|
|
236
|
+
limit: Annotated[int, Query(ge=1, le=1000)] = 200) -> dict:
|
|
237
|
+
return engine().jobs(state, repo, limit)
|
|
238
|
+
|
|
239
|
+
@app.get("/api/activity")
|
|
240
|
+
def activity(limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
241
|
+
repo: str | None = None,
|
|
242
|
+
cursor: str | None = None,
|
|
243
|
+
type: Annotated[list[str] | None, Query()] = None) -> dict:
|
|
244
|
+
return engine().activity(limit, repo, cursor, type)
|
|
245
|
+
|
|
246
|
+
@app.get("/api/usage")
|
|
247
|
+
def usage(days: Annotated[int, Query(ge=1, le=366)] = 30) -> dict:
|
|
248
|
+
return engine().usage(days, datetime.now(timezone.utc).date())
|
|
249
|
+
|
|
250
|
+
@app.get("/", response_class=HTMLResponse)
|
|
251
|
+
def index() -> str:
|
|
252
|
+
dashboard = DASHBOARD_DIR / "index.html"
|
|
253
|
+
if dashboard.is_file():
|
|
254
|
+
return dashboard.read_text(encoding="utf-8").replace(
|
|
255
|
+
"</head>",
|
|
256
|
+
f'<meta name="shiptrail-token" content="{html.escape(token, quote=True)}"></head>',
|
|
257
|
+
1,
|
|
258
|
+
)
|
|
259
|
+
overview = engine().overview()
|
|
260
|
+
sections = []
|
|
261
|
+
for repo in overview["repos"]:
|
|
262
|
+
jobs = []
|
|
263
|
+
if repo["status"] != "missing":
|
|
264
|
+
jobs = engine().snapshot(repo["id"])["jobs"]
|
|
265
|
+
rows = "".join(f"<li>{html.escape(j['id'])}: {html.escape(j['title'])} — "
|
|
266
|
+
f"{html.escape(j['state'])}</li>" for j in jobs)
|
|
267
|
+
sections.append(
|
|
268
|
+
f"<section><h2>{html.escape(repo['name'])}</h2>"
|
|
269
|
+
f"<p>{html.escape(repo['status'])}</p><ul>{rows or '<li>No jobs</li>'}</ul></section>"
|
|
270
|
+
)
|
|
271
|
+
return ("<!doctype html><html><head><meta charset='utf-8'><title>Shiptrail</title>"
|
|
272
|
+
"<meta http-equiv='refresh' content='5'>"
|
|
273
|
+
f"<meta name='shiptrail-token' content='{html.escape(token, quote=True)}'>"
|
|
274
|
+
"</head><body><h1>Shiptrail</h1>" + "".join(sections) + "</body></html>")
|
|
275
|
+
|
|
276
|
+
@app.get("/api/machine/settings")
|
|
277
|
+
def machine_settings() -> dict:
|
|
278
|
+
with engine().lock:
|
|
279
|
+
return engine().scheduler.machine.call(load_machine_settings).model_dump(by_alias=True)
|
|
280
|
+
|
|
281
|
+
@app.put("/api/machine/settings")
|
|
282
|
+
def put_machine(body: MachineSettings) -> dict:
|
|
283
|
+
with engine().lock:
|
|
284
|
+
result = engine().scheduler.machine.call(
|
|
285
|
+
lambda connection: save_machine_settings(connection, body))
|
|
286
|
+
for adapter in engine().supervisor.adapters.values():
|
|
287
|
+
if hasattr(adapter, "settings"):
|
|
288
|
+
adapter.settings = result
|
|
289
|
+
return result.model_dump(by_alias=True)
|
|
290
|
+
|
|
291
|
+
@app.get("/api/repos/{repo}/state")
|
|
292
|
+
def state(repo: str) -> dict:
|
|
293
|
+
return engine().snapshot(repo)
|
|
294
|
+
|
|
295
|
+
@app.get("/api/repos/{repo}/jobs/{job_id}")
|
|
296
|
+
def job(repo: str, job_id: str) -> dict:
|
|
297
|
+
return engine().snapshot(repo, job_id)
|
|
298
|
+
|
|
299
|
+
@app.post("/api/repos/{repo}/jobs", status_code=201)
|
|
300
|
+
def start(repo: str, body: JobBody) -> dict:
|
|
301
|
+
return engine().enqueue(repo, body.title, body.details, body.pipeline)
|
|
302
|
+
|
|
303
|
+
@app.post("/api/repos/{repo}/jobs/{job_id}/{action}")
|
|
304
|
+
def action(repo: str, job_id: str, action: str,
|
|
305
|
+
body: ActionBody = Body(default=ActionBody())) -> dict:
|
|
306
|
+
return engine().action(repo, job_id, action, body.model_dump())
|
|
307
|
+
|
|
308
|
+
@app.get("/api/repos/{repo}/jobs/{job_id}/files/{path:path}", response_class=PlainTextResponse)
|
|
309
|
+
def file(repo: str, job_id: str, path: str) -> str:
|
|
310
|
+
with engine().lock:
|
|
311
|
+
root, _ = engine().repo(repo)
|
|
312
|
+
engine().snapshot(repo, job_id)
|
|
313
|
+
folder = confined(root / "shiptrail/jobs", job_id)
|
|
314
|
+
target = confined(folder, path)
|
|
315
|
+
if not target.is_file():
|
|
316
|
+
raise HTTPException(404, "File not found")
|
|
317
|
+
# Reports/requests only. Tokens and command env are not public files.
|
|
318
|
+
if target.suffix.lower() != ".md":
|
|
319
|
+
raise HTTPException(403, "Only report/request Markdown files are served here")
|
|
320
|
+
return target.read_text(encoding="utf-8", errors="replace")
|
|
321
|
+
|
|
322
|
+
@app.get("/api/repos/{repo}/attempts/{attempt_id}/log")
|
|
323
|
+
def log(repo: str, attempt_id: int, offset: Annotated[int, Query(ge=0)] = 0) -> dict:
|
|
324
|
+
with engine().lock:
|
|
325
|
+
root, writer = engine().repo(repo)
|
|
326
|
+
row = writer.call(lambda connection: connection.execute(
|
|
327
|
+
"SELECT a.dir,e.job_id FROM attempts a JOIN executions e ON e.id=a.execution_id WHERE a.id=?",
|
|
328
|
+
(attempt_id, )).fetchone())
|
|
329
|
+
if row is None:
|
|
330
|
+
raise HTTPException(404, "Unknown attempt")
|
|
331
|
+
target = confined(root / "shiptrail/jobs", f"{row['job_id']}/{row['dir']}/events.jsonl")
|
|
332
|
+
try:
|
|
333
|
+
with target.open("rb") as stream:
|
|
334
|
+
stream.seek(offset)
|
|
335
|
+
chunk = stream.read(256 * 1024)
|
|
336
|
+
except FileNotFoundError:
|
|
337
|
+
chunk = b""
|
|
338
|
+
return {"text": chunk.decode("utf-8", errors="replace"), "offset": offset + len(chunk)}
|
|
339
|
+
|
|
340
|
+
@app.get("/api/repos/{repo}/config")
|
|
341
|
+
def config(repo: str) -> dict:
|
|
342
|
+
with engine().lock:
|
|
343
|
+
root, _ = engine().repo(repo)
|
|
344
|
+
return load_config(root).model_dump()
|
|
345
|
+
|
|
346
|
+
@app.post("/api/repos/{repo}/config/validate")
|
|
347
|
+
def preview_config(repo: str, body: dict = Body()) -> dict:
|
|
348
|
+
# Use the same validation as saving, without changing files or emitting events.
|
|
349
|
+
with engine().lock:
|
|
350
|
+
root, _ = engine().repo(repo)
|
|
351
|
+
parsed = validate_config(body, root=root)
|
|
352
|
+
if not versioning.matches(__version__, parsed.engine):
|
|
353
|
+
raise versioning.VersionMismatch(
|
|
354
|
+
f"Config requires shiptrail {parsed.engine}; installed {__version__}")
|
|
355
|
+
return {
|
|
356
|
+
"pipelines": {
|
|
357
|
+
name: pipeline.freeze(parsed, name, preamble="",
|
|
358
|
+
preamble_version="")["pipeline"]
|
|
359
|
+
for name in parsed.pipelines
|
|
360
|
+
},
|
|
361
|
+
"preview": {
|
|
362
|
+
name: preview_pipeline(parsed, name)
|
|
363
|
+
for name in parsed.pipelines
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
@app.put("/api/repos/{repo}/config")
|
|
368
|
+
def put_config(repo: str, body: dict = Body()) -> dict:
|
|
369
|
+
with engine().lock:
|
|
370
|
+
root, writer = engine().repo(repo)
|
|
371
|
+
versioning.check(root)
|
|
372
|
+
parsed = validate_config(body, root=root)
|
|
373
|
+
# Validate the submitted gate too, before replacing a usable config.
|
|
374
|
+
if not versioning.matches(__version__, parsed.engine):
|
|
375
|
+
raise versioning.VersionMismatch(
|
|
376
|
+
f"Config requires shiptrail {parsed.engine}; installed {__version__}")
|
|
377
|
+
run_cli.atomic_json(root / "shiptrail/config.json", parsed.model_dump())
|
|
378
|
+
|
|
379
|
+
def event(connection):
|
|
380
|
+
with db.tx(connection):
|
|
381
|
+
pipeline._event(connection, None, pipeline._now(), "config", {})
|
|
382
|
+
|
|
383
|
+
writer.call(event)
|
|
384
|
+
return parsed.model_dump()
|
|
385
|
+
|
|
386
|
+
@app.get("/api/library/pipelines")
|
|
387
|
+
def library_pipelines() -> dict:
|
|
388
|
+
return {"pipelines": library.list_pipelines()}
|
|
389
|
+
|
|
390
|
+
@app.put("/api/library/pipelines/{name}")
|
|
391
|
+
def save_library_pipeline(name: str, body: dict = Body()) -> dict:
|
|
392
|
+
steps = body.get("steps")
|
|
393
|
+
if not isinstance(steps, list):
|
|
394
|
+
raise ValueError("steps must be a list of pipeline steps")
|
|
395
|
+
return library.save(name, steps)
|
|
396
|
+
|
|
397
|
+
@app.delete("/api/library/pipelines/{name}")
|
|
398
|
+
def delete_library_pipeline(name: str) -> dict:
|
|
399
|
+
library.delete(name)
|
|
400
|
+
return {"deleted": name}
|
|
401
|
+
|
|
402
|
+
@app.get("/api/providers/models")
|
|
403
|
+
async def provider_models(refresh: bool = False) -> dict:
|
|
404
|
+
|
|
405
|
+
def read() -> dict:
|
|
406
|
+
with engine().lock:
|
|
407
|
+
settings = engine().scheduler.machine.call(load_machine_settings)
|
|
408
|
+
# The tools run outside the engine lock: OpenCode can take seconds to answer.
|
|
409
|
+
return catalog.discover(settings.cli.model_dump(), refresh=refresh)
|
|
410
|
+
|
|
411
|
+
return {"providers": await asyncio.to_thread(read)}
|
|
412
|
+
|
|
413
|
+
@app.get("/api/repos/{repo}/roles/available")
|
|
414
|
+
def available_roles(repo: str) -> dict:
|
|
415
|
+
with engine().lock:
|
|
416
|
+
root, _ = engine().repo(repo)
|
|
417
|
+
return {"roles": role_files.available_roles(root)}
|
|
418
|
+
|
|
419
|
+
@app.get("/api/repos/{repo}/roles")
|
|
420
|
+
def roles(repo: str) -> dict:
|
|
421
|
+
with engine().lock:
|
|
422
|
+
root, _ = engine().repo(repo)
|
|
423
|
+
config = load_config(root)
|
|
424
|
+
result = {}
|
|
425
|
+
for name, steps in config.pipelines.items():
|
|
426
|
+
for step in steps:
|
|
427
|
+
for entry in step.executions:
|
|
428
|
+
role = config.role_for(name, entry.id)
|
|
429
|
+
result[entry.uses, entry.provider] = {
|
|
430
|
+
"name": role.name,
|
|
431
|
+
"provider": role.provider,
|
|
432
|
+
"type": role.type,
|
|
433
|
+
"source": role.source,
|
|
434
|
+
"sha256": role.sha256,
|
|
435
|
+
**role_files.provenance(role, root=root)
|
|
436
|
+
}
|
|
437
|
+
return {"roles": list(result.values())}
|
|
438
|
+
|
|
439
|
+
@app.get("/api/repos/{repo}/backlog")
|
|
440
|
+
def list_backlog(repo: str) -> dict:
|
|
441
|
+
with engine().lock:
|
|
442
|
+
root, _ = engine().repo(repo)
|
|
443
|
+
path = root / "shiptrail/backlog.md"
|
|
444
|
+
return {"backlog": backlog.items(path), "started": backlog.started(path)}
|
|
445
|
+
|
|
446
|
+
@app.put("/api/repos/{repo}/backlog/meta")
|
|
447
|
+
def edit_backlog_meta(repo: str, body: dict = Body()) -> dict:
|
|
448
|
+
return engine().edit_backlog_meta(repo, body)
|
|
449
|
+
|
|
450
|
+
@app.post("/api/repos/{repo}/backlog")
|
|
451
|
+
def edit_backlog(repo: str, body: dict = Body()) -> dict:
|
|
452
|
+
return engine().edit_backlog(repo, body)
|
|
453
|
+
|
|
454
|
+
@app.post("/api/service/stop")
|
|
455
|
+
def shutdown(body: dict = Body(default={})) -> dict:
|
|
456
|
+
with engine().lock:
|
|
457
|
+
engine().stopping = True
|
|
458
|
+
if body.get("stop_running", False):
|
|
459
|
+
for repo_id, (_, writer) in list(engine().scheduler.repos.items()):
|
|
460
|
+
ids = writer.call(lambda connection: [
|
|
461
|
+
row[0] for row in connection.execute(
|
|
462
|
+
"SELECT id FROM jobs WHERE state IN ('running','paused')")
|
|
463
|
+
])
|
|
464
|
+
for job_id in ids:
|
|
465
|
+
engine().supervisor.stop(repo_id, job_id)
|
|
466
|
+
# Keep polling until the graceful Stop interval and tree cleanup finish.
|
|
467
|
+
import time
|
|
468
|
+
deadline = time.monotonic() + 20
|
|
469
|
+
while engine().scheduler.recount():
|
|
470
|
+
if time.monotonic() > deadline:
|
|
471
|
+
raise ValueError("Timed out waiting for running attempts to stop")
|
|
472
|
+
engine().supervisor.poll()
|
|
473
|
+
time.sleep(0.05)
|
|
474
|
+
app.state.shutdown()
|
|
475
|
+
return {"stopping": True}
|
|
476
|
+
|
|
477
|
+
def load_job(repo: str, job_id: str) -> tuple[Path, dict]:
|
|
478
|
+
with engine().lock:
|
|
479
|
+
root, writer = engine().repo(repo)
|
|
480
|
+
try:
|
|
481
|
+
job = writer.call(lambda connection: pipeline.load(connection, job_id))[0]
|
|
482
|
+
except pipeline.PipelineError as error:
|
|
483
|
+
raise HTTPException(404, "Unknown job") from error
|
|
484
|
+
return root, job
|
|
485
|
+
|
|
486
|
+
@app.get("/api/repos/{repo}/jobs/{job_id}/changes")
|
|
487
|
+
def changes_list(repo: str, job_id: str) -> dict:
|
|
488
|
+
root, job = load_job(repo, job_id)
|
|
489
|
+
return changes.list_changes(root, job)
|
|
490
|
+
|
|
491
|
+
@app.get("/api/repos/{repo}/jobs/{job_id}/changes/diff")
|
|
492
|
+
def change_diff(repo: str, job_id: str, path: Annotated[str, Query(min_length=1)]) -> dict:
|
|
493
|
+
root, job = load_job(repo, job_id)
|
|
494
|
+
try:
|
|
495
|
+
return changes.file_diff(root, job, path)
|
|
496
|
+
except changes.UnknownFile as error:
|
|
497
|
+
raise HTTPException(404, "The job did not change this file") from error
|
|
498
|
+
|
|
499
|
+
@app.get("/{asset_path:path}")
|
|
500
|
+
def dashboard_asset(asset_path: str):
|
|
501
|
+
target = (DASHBOARD_DIR / asset_path).resolve()
|
|
502
|
+
if not target.is_relative_to(DASHBOARD_DIR.resolve()) or not target.is_file():
|
|
503
|
+
raise HTTPException(404, "Dashboard asset not found")
|
|
504
|
+
return FileResponse(target)
|
|
505
|
+
|
|
506
|
+
return app
|
shiptrail/api/streams.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Bounded SSE delivery. No database transactions or runtime locks span a yield."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncIterator, Callable
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
|
|
11
|
+
from shiptrail.engine import pipeline
|
|
12
|
+
from shiptrail.engine.runtime import Runtime
|
|
13
|
+
|
|
14
|
+
REPLAY_LIMIT = 5000
|
|
15
|
+
LOG_BATCH = 64 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def frame(kind: str, data: dict, identifier: int | None = None) -> str:
|
|
19
|
+
prefix = f"id: {identifier}\n" if identifier is not None else ""
|
|
20
|
+
return prefix + f"event: {kind}\ndata: {json.dumps(data, ensure_ascii=True)}\n\n"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def replay(runtime: Runtime, repo: str, after: int) -> tuple[bool, dict, list[dict]]:
|
|
24
|
+
with runtime.lock:
|
|
25
|
+
root, _ = runtime.repo(repo)
|
|
26
|
+
connection = pipeline.open_repo(root)
|
|
27
|
+
try:
|
|
28
|
+
last = connection.execute("SELECT coalesce(max(id),0) FROM events").fetchone()[0]
|
|
29
|
+
present = after == 0 or connection.execute("SELECT 1 FROM events WHERE id=?",
|
|
30
|
+
(after, )).fetchone() is not None
|
|
31
|
+
rows = [
|
|
32
|
+
dict(row)
|
|
33
|
+
for row in connection.execute("SELECT * FROM events WHERE id>? ORDER BY id LIMIT ?",
|
|
34
|
+
(after, REPLAY_LIMIT + 1))
|
|
35
|
+
]
|
|
36
|
+
finally:
|
|
37
|
+
connection.close()
|
|
38
|
+
reset = after > last or not present or len(rows) > REPLAY_LIMIT
|
|
39
|
+
state = runtime.snapshot(repo)
|
|
40
|
+
if reset:
|
|
41
|
+
return True, state, []
|
|
42
|
+
# Durable events are deliberately small partial updates. Attach an authoritative
|
|
43
|
+
# snapshot and affected job detail, so creations, deletions and rewinds reduce
|
|
44
|
+
# correctly without a second request racing the stream.
|
|
45
|
+
details = {}
|
|
46
|
+
for row in rows:
|
|
47
|
+
job_id = row["job_id"]
|
|
48
|
+
if job_id and job_id not in details:
|
|
49
|
+
try:
|
|
50
|
+
details[job_id] = runtime.snapshot(repo, job_id)
|
|
51
|
+
except (KeyError, pipeline.PipelineError):
|
|
52
|
+
details[job_id] = None
|
|
53
|
+
row["payload"] = json.loads(row["payload"])
|
|
54
|
+
row["state"] = state
|
|
55
|
+
row["detail"] = details.get(job_id)
|
|
56
|
+
return False, state, rows
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def repo_stream(request: Request, runtime: Runtime, repo: str,
|
|
60
|
+
after: int) -> AsyncIterator[str]:
|
|
61
|
+
heartbeat = time.monotonic()
|
|
62
|
+
sent_activity: dict[int, tuple[str, float]] = {}
|
|
63
|
+
while not runtime.stopping and not await request.is_disconnected():
|
|
64
|
+
reset, state, rows = await asyncio.to_thread(replay, runtime, repo, after)
|
|
65
|
+
if reset:
|
|
66
|
+
after = state["last_event_id"]
|
|
67
|
+
yield frame("reset", {"last_event_id": after}, after)
|
|
68
|
+
else:
|
|
69
|
+
for row in rows:
|
|
70
|
+
after = row["id"]
|
|
71
|
+
yield frame(row["type"], row, after)
|
|
72
|
+
now = time.monotonic()
|
|
73
|
+
running = {item["attempt_id"] for item in state["activity"]}
|
|
74
|
+
sent_activity = {key: value for key, value in sent_activity.items() if key in running}
|
|
75
|
+
for item in state["activity"]:
|
|
76
|
+
previous, sent = sent_activity.get(item["attempt_id"], ("", -float("inf")))
|
|
77
|
+
if item["at"] and item["at"] != previous and now - sent >= 1:
|
|
78
|
+
sent_activity[item["attempt_id"]] = item["at"], now
|
|
79
|
+
yield frame("activity", item)
|
|
80
|
+
if now - heartbeat >= 15:
|
|
81
|
+
heartbeat = now
|
|
82
|
+
yield ": keepalive\n\n"
|
|
83
|
+
await asyncio.sleep(0.25)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def overview_stream(request: Request, runtime: Runtime) -> AsyncIterator[str]:
|
|
87
|
+
previous = None
|
|
88
|
+
heartbeat = time.monotonic()
|
|
89
|
+
while not runtime.stopping and not await request.is_disconnected():
|
|
90
|
+
current = await asyncio.to_thread(runtime.overview)
|
|
91
|
+
if current != previous:
|
|
92
|
+
previous = current
|
|
93
|
+
yield frame("overview", current)
|
|
94
|
+
if time.monotonic() - heartbeat >= 15:
|
|
95
|
+
heartbeat = time.monotonic()
|
|
96
|
+
yield ": keepalive\n\n"
|
|
97
|
+
await asyncio.sleep(0.5)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def log_batch(path: Path, offset: int, *, final: bool = False) -> tuple[bytes, int]:
|
|
101
|
+
try:
|
|
102
|
+
with path.open("rb") as stream:
|
|
103
|
+
stream.seek(offset)
|
|
104
|
+
chunk = stream.read(LOG_BATCH)
|
|
105
|
+
except FileNotFoundError:
|
|
106
|
+
return b"", offset
|
|
107
|
+
# Split oversized lines at the hard cap, but never split a UTF-8 character.
|
|
108
|
+
end = len(chunk) if final or len(chunk) == LOG_BATCH else chunk.rfind(b"\n") + 1
|
|
109
|
+
data = chunk[:end]
|
|
110
|
+
if data and (not final or len(chunk) == LOG_BATCH):
|
|
111
|
+
try:
|
|
112
|
+
data.decode("utf-8")
|
|
113
|
+
except UnicodeDecodeError as error:
|
|
114
|
+
if error.reason == "unexpected end of data":
|
|
115
|
+
data = data[:error.start]
|
|
116
|
+
return data, offset + len(data)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def log_stream(request: Request, path: Path, offset: int,
|
|
120
|
+
finished: Callable[[], bool]) -> AsyncIterator[str]:
|
|
121
|
+
heartbeat = time.monotonic()
|
|
122
|
+
while not await request.is_disconnected():
|
|
123
|
+
final = await asyncio.to_thread(finished)
|
|
124
|
+
chunk, offset = await asyncio.to_thread(log_batch, path, offset, final=final)
|
|
125
|
+
if chunk:
|
|
126
|
+
yield frame("log", {
|
|
127
|
+
"text": chunk.decode("utf-8", errors="replace"),
|
|
128
|
+
"offset": offset
|
|
129
|
+
}, offset)
|
|
130
|
+
if len(chunk) >= LOG_BATCH - 3:
|
|
131
|
+
continue
|
|
132
|
+
elif final:
|
|
133
|
+
yield frame("end", {"offset": offset}, offset)
|
|
134
|
+
return
|
|
135
|
+
if time.monotonic() - heartbeat >= 15:
|
|
136
|
+
heartbeat = time.monotonic()
|
|
137
|
+
yield ": keepalive\n\n"
|
|
138
|
+
await asyncio.sleep(0.5)
|