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.
Files changed (118) hide show
  1. shiptrail/__init__.py +8 -0
  2. shiptrail/api/__init__.py +1 -0
  3. shiptrail/api/app.py +506 -0
  4. shiptrail/api/streams.py +138 -0
  5. shiptrail/cli.py +425 -0
  6. shiptrail/dashboard_dist/THIRD_PARTY_NOTICES.txt +6511 -0
  7. shiptrail/dashboard_dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  8. shiptrail/dashboard_dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  9. shiptrail/dashboard_dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  10. shiptrail/dashboard_dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  11. shiptrail/dashboard_dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  12. shiptrail/dashboard_dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  13. shiptrail/dashboard_dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  14. shiptrail/dashboard_dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  15. shiptrail/dashboard_dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  16. shiptrail/dashboard_dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  17. shiptrail/dashboard_dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  18. shiptrail/dashboard_dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  19. shiptrail/dashboard_dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  20. shiptrail/dashboard_dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  21. shiptrail/dashboard_dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  22. shiptrail/dashboard_dist/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  23. shiptrail/dashboard_dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  24. shiptrail/dashboard_dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  25. shiptrail/dashboard_dist/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  26. shiptrail/dashboard_dist/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  27. shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
  28. shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
  29. shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff +0 -0
  30. shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2 +0 -0
  31. shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
  32. shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
  33. shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
  34. shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
  35. shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2 +0 -0
  36. shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff +0 -0
  37. shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
  38. shiptrail/dashboard_dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
  39. shiptrail/dashboard_dist/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
  40. shiptrail/dashboard_dist/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
  41. shiptrail/dashboard_dist/assets/ibm-plex-sans-greek-500-normal-CuWXN6rf.woff +0 -0
  42. shiptrail/dashboard_dist/assets/ibm-plex-sans-greek-500-normal-JMMifIXV.woff2 +0 -0
  43. shiptrail/dashboard_dist/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
  44. shiptrail/dashboard_dist/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
  45. shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  46. shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  47. shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  48. shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  49. shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  50. shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  51. shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
  52. shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
  53. shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff +0 -0
  54. shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 +0 -0
  55. shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
  56. shiptrail/dashboard_dist/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
  57. shiptrail/dashboard_dist/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
  58. shiptrail/dashboard_dist/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
  59. shiptrail/dashboard_dist/assets/ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff +0 -0
  60. shiptrail/dashboard_dist/assets/ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2 +0 -0
  61. shiptrail/dashboard_dist/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
  62. shiptrail/dashboard_dist/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
  63. shiptrail/dashboard_dist/assets/index-CFxUIJrK.css +1 -0
  64. shiptrail/dashboard_dist/assets/index-jGh4BLlJ.js +202 -0
  65. shiptrail/dashboard_dist/index.html +13 -0
  66. shiptrail/engine/__init__.py +1 -0
  67. shiptrail/engine/activity.py +36 -0
  68. shiptrail/engine/adapters/__init__.py +15 -0
  69. shiptrail/engine/adapters/base.py +110 -0
  70. shiptrail/engine/adapters/codex.py +295 -0
  71. shiptrail/engine/adapters/opencode.py +322 -0
  72. shiptrail/engine/backlog.py +182 -0
  73. shiptrail/engine/catalog.py +145 -0
  74. shiptrail/engine/changes.py +139 -0
  75. shiptrail/engine/config.py +388 -0
  76. shiptrail/engine/crash.py +11 -0
  77. shiptrail/engine/db.py +97 -0
  78. shiptrail/engine/gitops.py +312 -0
  79. shiptrail/engine/library.py +83 -0
  80. shiptrail/engine/locking.py +50 -0
  81. shiptrail/engine/migrations/machine/0001_init.sql +15 -0
  82. shiptrail/engine/migrations/repo/0001_init.sql +89 -0
  83. shiptrail/engine/packet.py +307 -0
  84. shiptrail/engine/pipeline.py +516 -0
  85. shiptrail/engine/ports.py +154 -0
  86. shiptrail/engine/registry.py +87 -0
  87. shiptrail/engine/reports.py +98 -0
  88. shiptrail/engine/roles.py +210 -0
  89. shiptrail/engine/runtime.py +600 -0
  90. shiptrail/engine/scaffold.py +449 -0
  91. shiptrail/engine/scheduler.py +228 -0
  92. shiptrail/engine/supervisor.py +837 -0
  93. shiptrail/engine/usage.py +52 -0
  94. shiptrail/engine/versioning.py +106 -0
  95. shiptrail/engine/winjob.py +197 -0
  96. shiptrail/paths.py +30 -0
  97. shiptrail/roles/README.md +22 -0
  98. shiptrail/roles/approver.md +56 -0
  99. shiptrail/roles/builder.md +85 -0
  100. shiptrail/roles/fast-approver.md +64 -0
  101. shiptrail/roles/reviewer-code.md +53 -0
  102. shiptrail/roles/reviewer-security.md +52 -0
  103. shiptrail/roles/reviewer-ui.md +59 -0
  104. shiptrail/roles/reviewer-ux.md +60 -0
  105. shiptrail/roles/spec-writer.md +65 -0
  106. shiptrail/run_cli.py +355 -0
  107. shiptrail/service.py +262 -0
  108. shiptrail/templates/AGENTS.starter.md +26 -0
  109. shiptrail/templates/SKILL.claude.md +23 -0
  110. shiptrail/templates/SKILL.codex.md +23 -0
  111. shiptrail/templates/SKILL.opencode.md +23 -0
  112. shiptrail/templates/backlog.md +3 -0
  113. shiptrail/templates/config.json +209 -0
  114. shiptrail-0.1.0.dist-info/METADATA +201 -0
  115. shiptrail-0.1.0.dist-info/RECORD +118 -0
  116. shiptrail-0.1.0.dist-info/WHEEL +4 -0
  117. shiptrail-0.1.0.dist-info/entry_points.txt +2 -0
  118. shiptrail-0.1.0.dist-info/licenses/LICENSE +21 -0
shiptrail/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """The shiptrail engine package."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("shiptrail")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0"
@@ -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
@@ -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)