loki-mode 11.12.1 → 11.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/SKILL.md +3 -3
- package/VERSION +1 -1
- package/autonomy/grill.sh +3 -1
- package/autonomy/hooks/validate-bash.sh +51 -13
- package/autonomy/lib/backlog.py +18 -3
- package/autonomy/lib/chat.sh +117 -7
- package/autonomy/lib/claude-flags.sh +20 -0
- package/autonomy/lib/claude_permissions.py +40 -0
- package/autonomy/lib/done-recognition.sh +8 -4
- package/autonomy/lib/engine10-push.sh +56 -7
- package/autonomy/lib/prd-enrich.sh +4 -2
- package/autonomy/lib/proof-generator.py +21 -0
- package/autonomy/lib/proof-verify.py +109 -1
- package/autonomy/lib/trust_trajectory.py +139 -0
- package/autonomy/lib/voter-agents.sh +3 -1
- package/autonomy/lib/workspace.py +2 -3
- package/autonomy/loki +427 -74
- package/autonomy/quickstart.sh +21 -2
- package/autonomy/run.sh +55 -21
- package/autonomy/trigger-server.py +461 -32
- package/bin/loki +4 -1
- package/completions/_loki +1 -1
- package/completions/loki.bash +10 -2
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_start.py +2 -2
- package/dashboard/build_supervisor.py +2 -0
- package/docs/CLI-REFERENCE.md +6 -4
- package/docs/DEPLOYMENT.md +12 -5
- package/docs/EVALUATING.md +13 -0
- package/docs/INSTALLATION.md +2 -2
- package/docs/SIGNED-RECEIPTS.md +7 -0
- package/docs/WORKSPACES.md +2 -2
- package/docs/cost-controls.md +26 -0
- package/docs/merge-queue.md +19 -0
- package/docs/sentry-intake.md +4 -0
- package/docs/slack.md +2 -1
- package/docs/trackers.md +12 -1
- package/docs/v10/BOARD.md +148 -123
- package/docs/v10/CLI-MODERN.md +5 -5
- package/docs/v10/DECISIONS.md +2 -0
- package/docs/v10/ENGINE.md +2 -0
- package/docs/v10/FAILURE-CLASSES.md +18 -0
- package/docs/v10/MODERNIZE.md +32 -1
- package/docs/v10/PROGRESS.md +7 -0
- package/loki-ts/data/finding-schema.json +2 -1
- package/loki-ts/dist/loki.js +1222 -1128
- package/magic/core/debate.py +27 -1
- package/mcp/__init__.py +1 -1
- package/package.json +2 -2
- package/packages/control-plane/dist/server.js +5895 -5292
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/claude.sh +6 -3
- package/providers/model_catalog.json +6 -0
- package/schemas/loki-yaml.schema.json +5 -0
- package/src/policies/claude_permissions.js +114 -0
- package/web-app/dist/assets/{AdminPage-DuT74pf5.js → AdminPage-OVzN6eLm.js} +1 -1
- package/web-app/dist/assets/{Avatar-BtgidR5a.js → Avatar-hCCrxrCZ.js} +1 -1
- package/web-app/dist/assets/{Badge-D2nt8G_I.js → Badge-CDcpkhbJ.js} +1 -1
- package/web-app/dist/assets/{Button-BKl2jOyb.js → Button-8mwWlhEu.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-DaWr0EUX.js → CockpitPage-D8cf3u22.js} +1 -1
- package/web-app/dist/assets/{ComparePage-aIiNhxLr.js → ComparePage-CIgkbDRY.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-C1Q7rV23.js → ErrorBoundary-99kJH9g2.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-C-C0-tOx.js → EvidenceReceiptPanel-C9fj32eS.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-l3IaXDag.js → GitHubIssuesPanel-De43YQ_C.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-DweLBgr5.js → GitHubPRsPanel-7g7ZTnkx.js} +1 -1
- package/web-app/dist/assets/{HomePage-B_KK7AvY.js → HomePage-CcNA24ny.js} +2 -2
- package/web-app/dist/assets/{LoginPage-CKS-KLdH.js → LoginPage-BYjlb-IK.js} +1 -1
- package/web-app/dist/assets/{MagicPage-Cl6CEp8J.js → MagicPage-C0Dwt9Na.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-n9CPUaX0.js → MetricsPage-jDGmdcgg.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-D85eWn70.js → NotFoundPage-C_ZO8tLE.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-CDN7LpSo.js → ProjectPage-B5wnurfQ.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-D4SDmU4-.js → ProjectsPage--EyKt5-D.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-Dby6GnKm.js → SettingsPage-cB0lnfti.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-CgHxeqoU.js → ShowcasePage-Dl16FDkK.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-BaewGHlc.js → SystemSettingsPage-BmAXd4V2.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-905MJyke.js → TeamsPage-BFkWVOZe.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-DMrUHcvF.js → TemplatesPage-Bf2kmEUM.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-BsbEbBYX.js → TerminalOutput-BItv9Wa6.js} +1 -1
- package/web-app/dist/assets/{activity-BuaXhBsC.js → activity-Df2Awg6I.js} +1 -1
- package/web-app/dist/assets/{bell-BfEQ71OG.js → bell-D-1ztTsJ.js} +1 -1
- package/web-app/dist/assets/{bot-DOGeYewg.js → bot-A4qgZsKi.js} +1 -1
- package/web-app/dist/assets/{check-zbjPpb3E.js → check-BPfFpF0c.js} +1 -1
- package/web-app/dist/assets/{chevron-left-DWTOgfFt.js → chevron-left-CfSJvmDi.js} +1 -1
- package/web-app/dist/assets/{circle-alert-BEPKfRzR.js → circle-alert-wx2d1dzb.js} +1 -1
- package/web-app/dist/assets/{clock-C_fX3OHo.js → clock-pQMai6l-.js} +1 -1
- package/web-app/dist/assets/{cloud-DUXHygXQ.js → cloud-DWL470r6.js} +1 -1
- package/web-app/dist/assets/{code-xml-BEVl_9Hw.js → code-xml-BaEQpQLZ.js} +1 -1
- package/web-app/dist/assets/{database-_a22uh7l.js → database-h-N7WkQN.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-l2en7xfg.js → dollar-sign-B5oYiD27.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-DItKl6H4.js → file-code-corner-fbTtTy0o.js} +1 -1
- package/web-app/dist/assets/{file-plus-BSk7qFMA.js → file-plus-PaF6HMo8.js} +1 -1
- package/web-app/dist/assets/{globe-2TveFE5l.js → globe-Ct-GlWID.js} +1 -1
- package/web-app/dist/assets/{hammer-BU6OJlQj.js → hammer-C1oZFX51.js} +1 -1
- package/web-app/dist/assets/{index-DGnZXUYk.js → index-C2egrRhz.js} +3 -3
- package/web-app/dist/assets/{layers-DKw7MNuq.js → layers-Dt5ZK4v2.js} +1 -1
- package/web-app/dist/assets/{loader-circle-CoYfykzD.js → loader-circle-DrpMziic.js} +1 -1
- package/web-app/dist/assets/{lock-D9JzrXDL.js → lock-DOez8MmK.js} +1 -1
- package/web-app/dist/assets/{package-BZtqTIqH.js → package-ClGIkerr.js} +1 -1
- package/web-app/dist/assets/{plus-BGJfo8bV.js → plus-C004i9ua.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-F6lowBgR.js → refresh-cw-Ddfk-7Sy.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-DHOK8dM4.js → rotate-ccw-Bu1KgDjS.js} +1 -1
- package/web-app/dist/assets/{scroll-text-CKcROoMV.js → scroll-text-3q1lW5pm.js} +1 -1
- package/web-app/dist/assets/{server-DbG8MDv6.js → server-pd6x4HW1.js} +1 -1
- package/web-app/dist/assets/{shield-alert-RNKNXdJT.js → shield-alert-B9jGJepT.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-Yxp-UBxS.js → thumbs-up-Cdcc9nQf.js} +1 -1
- package/web-app/dist/assets/{trash-2-DY-eba1n.js → trash-2-foSojXHK.js} +1 -1
- package/web-app/dist/assets/{trending-down-CARiDB3t.js → trending-down-CTTiqlJ1.js} +1 -1
- package/web-app/dist/assets/{trending-up-brQqWbA3.js → trending-up-B_MgmRAx.js} +1 -1
- package/web-app/dist/assets/{upload-D6wRjKhz.js → upload-g6BfALzG.js} +1 -1
- package/web-app/dist/assets/{usePolling-BMrqNbAI.js → usePolling-DrHwvzuh.js} +1 -1
- package/web-app/dist/assets/{user-CK5-EXIj.js → user-COp75m7l.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/autonomy/lib/doctor-fix.py +0 -279
|
@@ -53,6 +53,7 @@ import signal
|
|
|
53
53
|
import socket
|
|
54
54
|
import socketserver
|
|
55
55
|
import subprocess
|
|
56
|
+
import tempfile
|
|
56
57
|
import sys
|
|
57
58
|
import threading
|
|
58
59
|
import time
|
|
@@ -187,6 +188,74 @@ def valid_spec(spec):
|
|
|
187
188
|
return True
|
|
188
189
|
|
|
189
190
|
|
|
191
|
+
# M-23: POST /jobs kind "unit" (remote modernize unit). The schema is closed:
|
|
192
|
+
# any field outside UNIT_JOB_FIELDS is rejected, so no credential, env var or
|
|
193
|
+
# command can ride the payload. Credentials come from the worker's own secret
|
|
194
|
+
# mount, never from the job. Every value is bounded and dash-leading values are
|
|
195
|
+
# refused so none can be parsed as a CLI flag.
|
|
196
|
+
UNIT_JOB_KIND = "unit"
|
|
197
|
+
UNIT_JOB_FIELDS = frozenset(("kind", "repo", "ref", "unit", "mid", "target"))
|
|
198
|
+
UNIT_JOB_REQUIRED = ("repo", "ref", "unit")
|
|
199
|
+
UNIT_JOB_TARGETS = ("python3", "java21")
|
|
200
|
+
MAX_UNIT_REPO_LEN = 140
|
|
201
|
+
MAX_UNIT_REF_LEN = 200
|
|
202
|
+
# fullmatch + explicit ASCII classes: "$" would accept a trailing newline and
|
|
203
|
+
# "\d" would accept Unicode digits. Mirrors the TS REPO_RE/REF_RE/UNIT_RE/MID_RE.
|
|
204
|
+
UNIT_REPO_RE = re.compile(r"[A-Za-z0-9._][A-Za-z0-9._-]*/[A-Za-z0-9._-]+", re.ASCII)
|
|
205
|
+
UNIT_REF_RE = re.compile(r"[A-Za-z0-9._][A-Za-z0-9._/-]*", re.ASCII)
|
|
206
|
+
UNIT_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", re.ASCII)
|
|
207
|
+
UNIT_MID_RE = re.compile(r"mod-[0-9]{8}T[0-9]{6}Z-[0-9a-f]{6}", re.ASCII)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def validate_unit_job(payload):
|
|
211
|
+
"""Strictly validate a kind "unit" payload.
|
|
212
|
+
|
|
213
|
+
Returns (clean_dict, None) or (None, error). The clean dict holds only
|
|
214
|
+
known string fields, so downstream code never sees anything else.
|
|
215
|
+
"""
|
|
216
|
+
if not isinstance(payload, dict):
|
|
217
|
+
return None, "payload must be a JSON object"
|
|
218
|
+
unknown = sorted(str(k) for k in payload if k not in UNIT_JOB_FIELDS)
|
|
219
|
+
if unknown:
|
|
220
|
+
return None, "unknown field(s): %s" % ", ".join(k[:40] for k in unknown[:5])
|
|
221
|
+
for key in UNIT_JOB_REQUIRED:
|
|
222
|
+
if key not in payload:
|
|
223
|
+
return None, "missing field: %s" % key
|
|
224
|
+
for key, value in payload.items():
|
|
225
|
+
if not isinstance(value, str):
|
|
226
|
+
return None, "field %s must be a string" % key
|
|
227
|
+
repo, ref, unit = payload["repo"], payload["ref"], payload["unit"]
|
|
228
|
+
if (len(repo) > MAX_UNIT_REPO_LEN or not UNIT_REPO_RE.fullmatch(repo)
|
|
229
|
+
or any(seg in (".", "..") for seg in repo.split("/"))):
|
|
230
|
+
return None, "invalid repo"
|
|
231
|
+
if (not ref or len(ref) > MAX_UNIT_REF_LEN or not UNIT_REF_RE.fullmatch(ref)
|
|
232
|
+
or ".." in ref or ref.endswith((".lock", "/", "."))):
|
|
233
|
+
return None, "invalid ref"
|
|
234
|
+
if not UNIT_ID_RE.fullmatch(unit):
|
|
235
|
+
return None, "invalid unit"
|
|
236
|
+
clean = {"kind": UNIT_JOB_KIND, "repo": repo, "ref": ref, "unit": unit}
|
|
237
|
+
if "mid" in payload:
|
|
238
|
+
if not UNIT_MID_RE.fullmatch(payload["mid"]):
|
|
239
|
+
return None, "invalid mid"
|
|
240
|
+
clean["mid"] = payload["mid"]
|
|
241
|
+
if "target" in payload:
|
|
242
|
+
if payload["target"] not in UNIT_JOB_TARGETS:
|
|
243
|
+
return None, "invalid target"
|
|
244
|
+
clean["target"] = payload["target"]
|
|
245
|
+
return clean, None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def unit_start_args(unit_job):
|
|
249
|
+
"""argv for the modernize unit worker. One list, never a shell string."""
|
|
250
|
+
args = ["modernize", "unit", "--repo", unit_job["repo"],
|
|
251
|
+
"--ref", unit_job["ref"], "--unit", unit_job["unit"]]
|
|
252
|
+
if "mid" in unit_job:
|
|
253
|
+
args += ["--mid", unit_job["mid"]]
|
|
254
|
+
if "target" in unit_job:
|
|
255
|
+
args += ["--target", unit_job["target"]]
|
|
256
|
+
return args
|
|
257
|
+
|
|
258
|
+
|
|
190
259
|
def constant_time_equals(a, b):
|
|
191
260
|
"""Constant-time string compare that never raises on odd input.
|
|
192
261
|
|
|
@@ -420,19 +489,25 @@ CHILD_ENV_DENYLIST = (
|
|
|
420
489
|
"LINEAR_WEBHOOK_SECRET",
|
|
421
490
|
"LINEAR_API_KEY",
|
|
422
491
|
"JIRA_WEBHOOK_SECRET",
|
|
492
|
+
"JIRA_API_TOKEN",
|
|
423
493
|
"TEAMS_SECURITY_TOKEN",
|
|
424
494
|
)
|
|
425
495
|
|
|
426
496
|
|
|
427
|
-
def _scoped_child_env():
|
|
428
|
-
|
|
497
|
+
def _scoped_child_env(extra_env=None):
|
|
498
|
+
env = {k: v for k, v in os.environ.items() if k not in CHILD_ENV_DENYLIST}
|
|
499
|
+
# Caller values override inherited ones but can never restore a denied secret.
|
|
500
|
+
env.update({k: v for k, v in (extra_env or {}).items() if k not in CHILD_ENV_DENYLIST})
|
|
501
|
+
return env
|
|
429
502
|
|
|
430
503
|
|
|
431
|
-
def run_loki_outcome(args, dry_run=False):
|
|
504
|
+
def run_loki_outcome(args, dry_run=False, stdout_sink=None, extra_env=None):
|
|
432
505
|
"""Run a loki command and report WHICH of the three outcomes occurred.
|
|
433
506
|
|
|
434
507
|
Returns one of OUTCOME_PASSED / OUTCOME_FAILED / OUTCOME_UNKNOWN. This is
|
|
435
508
|
the honest version of run_loki_command, which answers only "did it launch".
|
|
509
|
+
When stdout_sink is a list, the decoded stdout is appended to it on completion.
|
|
510
|
+
extra_env adds child env vars (e.g. LOKI_STEER_SOURCE) on top of the scoped env.
|
|
436
511
|
"""
|
|
437
512
|
cmd = ["loki"] + args
|
|
438
513
|
if dry_run:
|
|
@@ -444,14 +519,16 @@ def run_loki_outcome(args, dry_run=False):
|
|
|
444
519
|
cmd,
|
|
445
520
|
stdout=subprocess.PIPE,
|
|
446
521
|
stderr=subprocess.PIPE,
|
|
447
|
-
env=_scoped_child_env(),
|
|
522
|
+
env=_scoped_child_env(extra_env),
|
|
448
523
|
)
|
|
449
524
|
except (FileNotFoundError, OSError) as e:
|
|
450
525
|
logging.error("Failed to launch %s: %s", " ".join(cmd), e)
|
|
451
526
|
return OUTCOME_FAILED
|
|
452
527
|
|
|
453
528
|
try:
|
|
454
|
-
|
|
529
|
+
stdout, stderr = proc.communicate(timeout=DISPATCH_WAIT_SECONDS)
|
|
530
|
+
if stdout_sink is not None:
|
|
531
|
+
stdout_sink.append((stdout or b"").decode("utf-8", errors="replace"))
|
|
455
532
|
except subprocess.TimeoutExpired:
|
|
456
533
|
# Detached past the wait window. The reaper collects the child, but we
|
|
457
534
|
# never see its exit code, so the outcome is genuinely UNKNOWN -- NOT a
|
|
@@ -485,7 +562,7 @@ def run_loki_outcome(args, dry_run=False):
|
|
|
485
562
|
return OUTCOME_FAILED
|
|
486
563
|
|
|
487
564
|
|
|
488
|
-
def run_loki_command(args, dry_run=False):
|
|
565
|
+
def run_loki_command(args, dry_run=False, stdout_sink=None):
|
|
489
566
|
"""Run a loki command synchronously and reap it; or print it if dry_run.
|
|
490
567
|
|
|
491
568
|
Returns True if the command was launched and exited 0 (or backgrounded
|
|
@@ -501,17 +578,163 @@ def run_loki_command(args, dry_run=False):
|
|
|
501
578
|
passed wants run_loki_outcome instead. Behaviour is unchanged: a detached
|
|
502
579
|
dispatch (UNKNOWN) still reads as True here, exactly as before.
|
|
503
580
|
"""
|
|
504
|
-
return run_loki_outcome(args, dry_run=dry_run) in (
|
|
581
|
+
return run_loki_outcome(args, dry_run=dry_run, stdout_sink=stdout_sink) in (
|
|
505
582
|
OUTCOME_PASSED, OUTCOME_UNKNOWN,
|
|
506
583
|
)
|
|
507
584
|
|
|
508
585
|
|
|
586
|
+
TRUSTED_ASSOCIATIONS = ("OWNER", "MEMBER", "COLLABORATOR") # same set as verify_pr.ts
|
|
587
|
+
STEER_NOTE_MAX = 1000
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _process_start_time(pid):
|
|
591
|
+
"""`ps -o lstart=` for pid, whitespace-normalized like run_pid.ts processStartTime; None if gone."""
|
|
592
|
+
if not isinstance(pid, int) or pid <= 1:
|
|
593
|
+
return None
|
|
594
|
+
try:
|
|
595
|
+
out = subprocess.run(["ps", "-o", "lstart=", "-p", str(pid)], capture_output=True,
|
|
596
|
+
text=True, timeout=5, env={**os.environ, "LC_ALL": "C"}).stdout
|
|
597
|
+
except (OSError, subprocess.SubprocessError):
|
|
598
|
+
return None
|
|
599
|
+
return " ".join(out.split()) or None
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def find_live_run(ref):
|
|
603
|
+
"""Return the id of a live run for issue ref "owner/repo#N", else None.
|
|
604
|
+
|
|
605
|
+
Scans $LOKI_DIR (default .loki)/runs/*/events.jsonl for a run.started whose issue_ref matches and
|
|
606
|
+
whose run.pid names a live process. Mirrors runOwnerAlive: the recorded
|
|
607
|
+
start time and run_id must match, so a recycled pid does not read as live.
|
|
608
|
+
"""
|
|
609
|
+
runs = Path(os.environ.get("LOKI_DIR") or ".loki") / "runs"
|
|
610
|
+
try:
|
|
611
|
+
dirs = sorted(runs.iterdir())
|
|
612
|
+
except OSError:
|
|
613
|
+
return None
|
|
614
|
+
for d in dirs:
|
|
615
|
+
try:
|
|
616
|
+
with open(d / "events.jsonl", "r", encoding="utf-8", errors="replace") as fh:
|
|
617
|
+
first = json.loads(fh.readline() or "{}")
|
|
618
|
+
if first.get("type") != "run.started" or (first.get("data") or {}).get("issue_ref") != ref:
|
|
619
|
+
continue
|
|
620
|
+
rec = json.loads((d / "run.pid").read_text())
|
|
621
|
+
pid = int(rec.get("pid", 0))
|
|
622
|
+
start = rec.get("start")
|
|
623
|
+
if pid > 1 and isinstance(start, str) and rec.get("run_id") == d.name \
|
|
624
|
+
and _process_start_time(pid) == " ".join(start.split()):
|
|
625
|
+
return d.name
|
|
626
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
627
|
+
continue
|
|
628
|
+
return None
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def steer_ack_text(rev):
|
|
632
|
+
"""Ack line for a steer; byte-identical to the line `loki steer` prints (cmd_steer)."""
|
|
633
|
+
if rev is None:
|
|
634
|
+
return "Steer received: applies at next plan/implement stage."
|
|
635
|
+
return "Steer rev %s received: applies at next plan/implement stage." % rev
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
STEER_ACK_MARKER = "<!-- loki-steer-ack -->"
|
|
639
|
+
LOKI_COMMENT_MARKER = "<!-- loki-" # any Loki-authored comment (status projector, steer ack) never steers
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def parse_steer_rev(stdout):
|
|
643
|
+
"""Revision number from `loki steer` stdout (its ack line), else None. Never guesses."""
|
|
644
|
+
m = re.search(r"Steer rev (\d+) received", stdout or "") \
|
|
645
|
+
or re.search(r"Steering note \(rev (\d+)\)", stdout or "")
|
|
646
|
+
return int(m.group(1)) if m else None
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def post_steer_ack(ref, repo_full_name, rev, dry_run=False):
|
|
650
|
+
"""Post one ack comment on the issue through engine10-push.sh issue-comment. Best effort."""
|
|
651
|
+
text = steer_ack_text(rev)
|
|
652
|
+
if dry_run:
|
|
653
|
+
logging.info("[DRY-RUN] Would ack %s: %s", ref, text)
|
|
654
|
+
return True
|
|
655
|
+
script = Path(__file__).resolve().parent / "lib" / "engine10-push.sh"
|
|
656
|
+
body = None
|
|
657
|
+
try:
|
|
658
|
+
fd, body = tempfile.mkstemp(prefix="loki-steer-ack.", suffix=".md")
|
|
659
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
660
|
+
fh.write(STEER_ACK_MARKER + "\n" + text + "\n")
|
|
661
|
+
env = _scoped_child_env()
|
|
662
|
+
env["_LOKI_ORIGIN_PINNED"] = "1"
|
|
663
|
+
env["_LOKI_PINNED_ORIGIN"] = "https://github.com/%s.git" % repo_full_name
|
|
664
|
+
proc = subprocess.run(["bash", str(script), "issue-comment", ref, body],
|
|
665
|
+
capture_output=True, text=True, timeout=60, env=env)
|
|
666
|
+
if proc.returncode != 0:
|
|
667
|
+
logging.error("steer ack failed (%d): %s", proc.returncode, proc.stderr.strip())
|
|
668
|
+
return proc.returncode == 0
|
|
669
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
670
|
+
logging.error("steer ack failed: %s", e)
|
|
671
|
+
return False
|
|
672
|
+
finally:
|
|
673
|
+
if body:
|
|
674
|
+
try:
|
|
675
|
+
os.unlink(body)
|
|
676
|
+
except OSError:
|
|
677
|
+
pass
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def handle_issue_comment_event(payload, dry_run=False):
|
|
681
|
+
"""Handle issue_comment: an authorized new comment steers the issue's live run."""
|
|
682
|
+
action = payload.get("action", "")
|
|
683
|
+
if action != "created":
|
|
684
|
+
return None, "skipped (action=%s)" % action
|
|
685
|
+
comment = payload.get("comment") or {}
|
|
686
|
+
if comment.get("author_association") not in TRUSTED_ASSOCIATIONS:
|
|
687
|
+
return None, "skipped (comment author not authorized)"
|
|
688
|
+
user = comment.get("user") or {}
|
|
689
|
+
login = str(user.get("login", ""))
|
|
690
|
+
if user.get("type") == "Bot" or login.endswith("[bot]"):
|
|
691
|
+
return None, "skipped (bot comment)"
|
|
692
|
+
if LOKI_COMMENT_MARKER in str(comment.get("body", "")):
|
|
693
|
+
return None, "skipped (loki-authored comment)"
|
|
694
|
+
number = (payload.get("issue") or {}).get("number")
|
|
695
|
+
repo_full_name = (payload.get("repository") or {}).get("full_name", "")
|
|
696
|
+
if number is None or not valid_issue_number(number):
|
|
697
|
+
return None, "rejected (invalid issue number)"
|
|
698
|
+
if not valid_repo_full_name(repo_full_name):
|
|
699
|
+
return None, "rejected (invalid repository full_name)"
|
|
700
|
+
ref = "%s#%s" % (repo_full_name, number)
|
|
701
|
+
if find_live_run(ref) is None:
|
|
702
|
+
return None, "skipped (no live run for %s)" % ref
|
|
703
|
+
body = re.sub(r"[\x00-\x1f\x7f\s]+", " ", str(comment.get("body", ""))).strip()[:STEER_NOTE_MAX]
|
|
704
|
+
if not body:
|
|
705
|
+
return None, "skipped (empty comment)"
|
|
706
|
+
login = re.sub(r"[^A-Za-z0-9-]", "", login)[:39]
|
|
707
|
+
note = "GitHub comment from %s: %s" % (login or "unknown", body)
|
|
708
|
+
summary = "steer %s from comment" % ref
|
|
709
|
+
sink = []
|
|
710
|
+
# Provenance for the revision log: loki steer records actor= and source= per revision.
|
|
711
|
+
steer_env = {"LOKI_STEER_SOURCE": "github", "LOKI_STEER_ACTOR": login or "unknown"}
|
|
712
|
+
outcome = run_loki_outcome(["steer", note], dry_run=dry_run, stdout_sink=sink, extra_env=steer_env)
|
|
713
|
+
# Only a confirmed exit 0 proves the steer was written. A detached child
|
|
714
|
+
# (UNKNOWN) never reports its exit code, so acking would claim an unverified
|
|
715
|
+
# steer; report it as launched but post nothing.
|
|
716
|
+
if outcome == OUTCOME_UNKNOWN:
|
|
717
|
+
send_notification("Trigger fired: %s" % summary)
|
|
718
|
+
return summary, OUTCOME_UNKNOWN
|
|
719
|
+
success = outcome == OUTCOME_PASSED
|
|
720
|
+
if success:
|
|
721
|
+
send_notification("Trigger fired: %s" % summary)
|
|
722
|
+
rev = parse_steer_rev("".join(sink))
|
|
723
|
+
injection_off = os.environ.get("LOKI_PROMPT_INJECTION", "").lower() in ("0", "false")
|
|
724
|
+
if not injection_off:
|
|
725
|
+
post_steer_ack(ref, repo_full_name, rev, dry_run=dry_run)
|
|
726
|
+
return summary, "fired" if success else "error"
|
|
727
|
+
|
|
728
|
+
|
|
509
729
|
def handle_issues_event(payload, dry_run=False):
|
|
510
730
|
"""Handle issues event: opened -> loki start <issue-ref> --pr --detach."""
|
|
511
731
|
action = payload.get("action", "")
|
|
512
732
|
if action != "opened":
|
|
513
733
|
return None, "skipped (action=%s)" % action
|
|
514
734
|
issue = payload.get("issue", {})
|
|
735
|
+
if (issue.get("author_association") not in TRUSTED_ASSOCIATIONS
|
|
736
|
+
and os.environ.get("LOKI_TRIGGER_ALLOW_ANY_ISSUE_AUTHOR", "") not in ("1", "true")):
|
|
737
|
+
return None, "skipped (issue author not authorized)"
|
|
515
738
|
issue_number = issue.get("number")
|
|
516
739
|
repo = payload.get("repository", {})
|
|
517
740
|
repo_full_name = repo.get("full_name", "")
|
|
@@ -605,8 +828,15 @@ def handle_job_event(payload, dry_run=False):
|
|
|
605
828
|
spec = payload.get("spec")
|
|
606
829
|
if not valid_spec(spec):
|
|
607
830
|
return None, "rejected (invalid spec)"
|
|
831
|
+
unit_job = payload.get("unit_job")
|
|
832
|
+
if unit_job is not None:
|
|
833
|
+
# Re-validated here so the handler is safe no matter who calls it.
|
|
834
|
+
clean, _err = validate_unit_job(unit_job)
|
|
835
|
+
if clean is None:
|
|
836
|
+
return None, "rejected (invalid unit job)"
|
|
837
|
+
args = unit_start_args(clean)
|
|
608
838
|
# Jira/Teams delegation sets pr=True: receipt-gated PR run, never a deploy.
|
|
609
|
-
|
|
839
|
+
elif payload.get("pr") is True:
|
|
610
840
|
args = brief_start_args(spec.strip())
|
|
611
841
|
else:
|
|
612
842
|
args = ["start", spec.strip(), "--detach"]
|
|
@@ -878,32 +1108,61 @@ def _post_json(url, data, headers):
|
|
|
878
1108
|
resp.read()
|
|
879
1109
|
|
|
880
1110
|
|
|
1111
|
+
def _notify_slack(target, text):
|
|
1112
|
+
url = target.get("response_url", "")
|
|
1113
|
+
if not (isinstance(url, str) and url.startswith(SLACK_REPLY_PREFIX)):
|
|
1114
|
+
return
|
|
1115
|
+
_post_json(url, {"response_type": "in_channel", "text": text}, {})
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def _notify_linear(target, text):
|
|
1119
|
+
key = os.environ.get("LINEAR_API_KEY", "")
|
|
1120
|
+
issue_id = target.get("issue_id", "")
|
|
1121
|
+
if not key or not isinstance(issue_id, str) or not issue_id:
|
|
1122
|
+
return
|
|
1123
|
+
_post_json(LINEAR_API_URL, {
|
|
1124
|
+
"query": "mutation($i:String!,$b:String!){commentCreate(input:"
|
|
1125
|
+
"{issueId:$i,body:$b}){success}}",
|
|
1126
|
+
"variables": {"i": issue_id, "b": text},
|
|
1127
|
+
}, {"Authorization": key})
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def _notify_jira(target, text):
|
|
1131
|
+
"""ADF comment on the issue. Site and credentials come from the operator's
|
|
1132
|
+
env only (never the payload); a non-https base URL is never contacted."""
|
|
1133
|
+
base = os.environ.get("JIRA_BASE_URL", "").rstrip("/")
|
|
1134
|
+
email = os.environ.get("JIRA_EMAIL", "")
|
|
1135
|
+
token = os.environ.get("JIRA_API_TOKEN", "")
|
|
1136
|
+
key = target.get("key", "")
|
|
1137
|
+
if not (base.startswith("https://") and email and token
|
|
1138
|
+
and isinstance(key, str) and JIRA_KEY_RE.match(key)):
|
|
1139
|
+
return
|
|
1140
|
+
auth = base64.b64encode(("%s:%s" % (email, token)).encode("utf-8")).decode("ascii")
|
|
1141
|
+
_post_json("%s/rest/api/3/issue/%s/comment" % (base, key), {
|
|
1142
|
+
"body": {"type": "doc", "version": 1, "content": [
|
|
1143
|
+
{"type": "paragraph", "content": [{"type": "text", "text": text}]}]},
|
|
1144
|
+
}, {"Authorization": "Basic " + auth})
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
# Reply-target kind -> notifier. Sentry has no entry on purpose: it never replies.
|
|
1148
|
+
NOTIFIERS = {"slack": _notify_slack, "linear": _notify_linear, "jira": _notify_jira}
|
|
1149
|
+
|
|
1150
|
+
|
|
881
1151
|
def notify_job_complete(target, job_id, status, summary):
|
|
882
1152
|
"""Send a completion summary to a reply target captured after verification.
|
|
883
1153
|
|
|
884
|
-
Never raises: a notifier failure must not fail the job.
|
|
885
|
-
re-
|
|
1154
|
+
Never raises: a notifier failure must not fail the job. Each notifier
|
|
1155
|
+
re-checks its own destination as defense in depth.
|
|
886
1156
|
"""
|
|
887
1157
|
if not isinstance(target, dict):
|
|
888
1158
|
return
|
|
1159
|
+
notifier = NOTIFIERS.get(target.get("kind"))
|
|
1160
|
+
if notifier is None:
|
|
1161
|
+
return
|
|
889
1162
|
text = ("loki job %s finished: %s. %s" % (job_id, status, summary or "")).strip()
|
|
890
1163
|
text = CONTROL_CHARS_RE.sub(" ", text)[:NOTIFY_TEXT_MAX]
|
|
891
1164
|
try:
|
|
892
|
-
|
|
893
|
-
url = target.get("response_url", "")
|
|
894
|
-
if not (isinstance(url, str) and url.startswith(SLACK_REPLY_PREFIX)):
|
|
895
|
-
return
|
|
896
|
-
_post_json(url, {"response_type": "in_channel", "text": text}, {})
|
|
897
|
-
elif target.get("kind") == "linear":
|
|
898
|
-
key = os.environ.get("LINEAR_API_KEY", "")
|
|
899
|
-
issue_id = target.get("issue_id", "")
|
|
900
|
-
if not key or not isinstance(issue_id, str) or not issue_id:
|
|
901
|
-
return
|
|
902
|
-
_post_json(LINEAR_API_URL, {
|
|
903
|
-
"query": "mutation($i:String!,$b:String!){commentCreate(input:"
|
|
904
|
-
"{issueId:$i,body:$b}){success}}",
|
|
905
|
-
"variables": {"i": issue_id, "b": text},
|
|
906
|
-
}, {"Authorization": key})
|
|
1165
|
+
notifier(target, text)
|
|
907
1166
|
except Exception as e: # notifier errors never fail a job
|
|
908
1167
|
logging.warning("Completion notify for job %s failed: %s", job_id, e)
|
|
909
1168
|
|
|
@@ -941,7 +1200,64 @@ def normalize_sentry_payload(payload):
|
|
|
941
1200
|
return out if isinstance(out, dict) else None
|
|
942
1201
|
|
|
943
1202
|
|
|
944
|
-
def
|
|
1203
|
+
def _clip(value, limit=80):
|
|
1204
|
+
"""Single-line, control-char-free, length-capped untrusted text."""
|
|
1205
|
+
text = " ".join(CONTROL_CHARS_RE.sub(" ", str(value or "")).split())
|
|
1206
|
+
return text.encode("utf-8")[:limit].decode("utf-8", errors="ignore")
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def _sentry_release_env(payload):
|
|
1210
|
+
ev = ((payload or {}).get("data") or {}).get("event")
|
|
1211
|
+
ev = ev if isinstance(ev, dict) else {}
|
|
1212
|
+
release = ev.get("release")
|
|
1213
|
+
if isinstance(release, dict):
|
|
1214
|
+
release = release.get("version")
|
|
1215
|
+
return _clip(release), _clip(ev.get("environment"), 40)
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def _last_deploy_context():
|
|
1219
|
+
"""'last deploy <sha7> <finished_at> health=<status>' from the newest
|
|
1220
|
+
.loki/deploys record in the server's project dir, or ''."""
|
|
1221
|
+
try:
|
|
1222
|
+
ddir = os.path.join(os.getcwd(), ".loki", "deploys")
|
|
1223
|
+
names = sorted(n for n in os.listdir(ddir) if n.endswith(".json"))
|
|
1224
|
+
if not names:
|
|
1225
|
+
return ""
|
|
1226
|
+
with open(os.path.join(ddir, names[-1]), "r", encoding="utf-8") as fh:
|
|
1227
|
+
rec = json.load(fh)
|
|
1228
|
+
except (OSError, ValueError):
|
|
1229
|
+
return ""
|
|
1230
|
+
if not isinstance(rec, dict) or not rec.get("head_sha"):
|
|
1231
|
+
return ""
|
|
1232
|
+
return "last deploy %s %s health=%s" % (
|
|
1233
|
+
_clip(rec.get("head_sha"), 7), _clip(rec.get("finished_at"), 30),
|
|
1234
|
+
_clip(rec.get("health_status") or "unchecked", 20))
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def _culprit_owner(culprit):
|
|
1238
|
+
"""'last change to <file> by <author> <sha7>' via read-only git log -1, or ''."""
|
|
1239
|
+
token = culprit.split(" ", 1)[0] if culprit else ""
|
|
1240
|
+
if not token or token.startswith("-") or ".." in token:
|
|
1241
|
+
return ""
|
|
1242
|
+
root = os.path.realpath(os.getcwd())
|
|
1243
|
+
full = os.path.realpath(os.path.join(root, token))
|
|
1244
|
+
if not full.startswith(root + os.sep) or not os.path.isfile(full):
|
|
1245
|
+
return ""
|
|
1246
|
+
try:
|
|
1247
|
+
proc = subprocess.run(
|
|
1248
|
+
["git", "-C", root, "log", "-1", "--format=%an|%h", "--", full],
|
|
1249
|
+
capture_output=True, timeout=5,
|
|
1250
|
+
env={"PATH": os.environ.get("PATH", "/usr/bin:/bin")})
|
|
1251
|
+
except (OSError, subprocess.SubprocessError):
|
|
1252
|
+
return ""
|
|
1253
|
+
out = proc.stdout.decode("utf-8", errors="replace").strip()
|
|
1254
|
+
if proc.returncode != 0 or "|" not in out:
|
|
1255
|
+
return ""
|
|
1256
|
+
author, sha = out.rsplit("|", 1)
|
|
1257
|
+
return "last change to %s by %s %s" % (_clip(token, 60), _clip(author, 40), _clip(sha, 12))
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def build_sentry_brief(norm, payload=None):
|
|
945
1261
|
"""One-line, control-char-free brief that can never parse as a CLI flag."""
|
|
946
1262
|
culprit = ""
|
|
947
1263
|
for line in str(norm.get("body", "")).splitlines():
|
|
@@ -951,6 +1267,17 @@ def build_sentry_brief(norm):
|
|
|
951
1267
|
parts = ["Fix Sentry issue %s: %s" % (norm.get("number", "?"), norm.get("title", ""))]
|
|
952
1268
|
if culprit:
|
|
953
1269
|
parts.append("(culprit %s)" % culprit)
|
|
1270
|
+
release, env_name = _sentry_release_env(payload)
|
|
1271
|
+
ctx = []
|
|
1272
|
+
if release:
|
|
1273
|
+
ctx.append("release %s" % release)
|
|
1274
|
+
if env_name:
|
|
1275
|
+
ctx.append("env %s" % env_name)
|
|
1276
|
+
for extra in (_last_deploy_context(), _culprit_owner(culprit)):
|
|
1277
|
+
if extra:
|
|
1278
|
+
ctx.append(extra)
|
|
1279
|
+
if ctx:
|
|
1280
|
+
parts.append("[%s]" % "; ".join(ctx))
|
|
954
1281
|
if norm.get("url"):
|
|
955
1282
|
parts.append("See %s." % norm["url"])
|
|
956
1283
|
parts.append("Reproduce with a regression test, then fix the root cause.")
|
|
@@ -971,7 +1298,7 @@ def handle_sentry_event(payload, dry_run=False):
|
|
|
971
1298
|
norm = normalize_sentry_payload(payload)
|
|
972
1299
|
if norm is None:
|
|
973
1300
|
return None, "rejected (unparseable sentry payload)"
|
|
974
|
-
brief = build_sentry_brief(norm)
|
|
1301
|
+
brief = build_sentry_brief(norm, payload)
|
|
975
1302
|
if not valid_spec(brief):
|
|
976
1303
|
return None, "rejected (invalid brief)"
|
|
977
1304
|
args = brief_start_args(brief)
|
|
@@ -988,6 +1315,7 @@ def handle_sentry_event(payload, dry_run=False):
|
|
|
988
1315
|
# explicitly refused on the /webhook path.
|
|
989
1316
|
EVENT_HANDLERS = {
|
|
990
1317
|
"issues": handle_issues_event,
|
|
1318
|
+
"issue_comment": handle_issue_comment_event,
|
|
991
1319
|
"pull_request": handle_pull_request_event,
|
|
992
1320
|
"workflow_run": handle_workflow_run_event,
|
|
993
1321
|
JOB_EVENT: handle_job_event,
|
|
@@ -1053,6 +1381,8 @@ class Dispatcher:
|
|
|
1053
1381
|
self._dedup_max = max(1, dedup_size)
|
|
1054
1382
|
self._seen_deliveries = collections.OrderedDict()
|
|
1055
1383
|
self._dedup_lock = threading.Lock()
|
|
1384
|
+
self._seen_path = get_loki_dir() / "seen-deliveries.jsonl"
|
|
1385
|
+
self._load_seen()
|
|
1056
1386
|
self._threads = []
|
|
1057
1387
|
self._stop = threading.Event()
|
|
1058
1388
|
for i in range(max(1, workers)):
|
|
@@ -1064,6 +1394,64 @@ class Dispatcher:
|
|
|
1064
1394
|
t.start()
|
|
1065
1395
|
self._threads.append(t)
|
|
1066
1396
|
|
|
1397
|
+
def _load_seen(self):
|
|
1398
|
+
"""Reload the last dedup_max accepted keys so a restart keeps dedup."""
|
|
1399
|
+
try:
|
|
1400
|
+
lines = self._seen_path.read_text(encoding="utf-8").splitlines()
|
|
1401
|
+
except OSError:
|
|
1402
|
+
return
|
|
1403
|
+
keys = []
|
|
1404
|
+
for line in lines:
|
|
1405
|
+
try:
|
|
1406
|
+
k = json.loads(line).get("k")
|
|
1407
|
+
except (ValueError, AttributeError):
|
|
1408
|
+
continue
|
|
1409
|
+
if isinstance(k, str) and k:
|
|
1410
|
+
keys.append(k)
|
|
1411
|
+
keys = keys[-self._dedup_max:]
|
|
1412
|
+
for k in keys:
|
|
1413
|
+
self._seen_deliveries[k] = True
|
|
1414
|
+
if len(lines) > 2 * self._dedup_max:
|
|
1415
|
+
try:
|
|
1416
|
+
self._seen_path.write_text(
|
|
1417
|
+
"".join(json.dumps({"k": k}) + "\n" for k in keys), encoding="utf-8")
|
|
1418
|
+
except OSError:
|
|
1419
|
+
pass
|
|
1420
|
+
|
|
1421
|
+
def _remember(self, key):
|
|
1422
|
+
"""Record key (caller holds _dedup_lock); append to the JSONL, best effort."""
|
|
1423
|
+
self._seen_deliveries[key] = True
|
|
1424
|
+
while len(self._seen_deliveries) > self._dedup_max:
|
|
1425
|
+
self._seen_deliveries.popitem(last=False)
|
|
1426
|
+
try:
|
|
1427
|
+
with open(self._seen_path, "a", encoding="utf-8") as fh:
|
|
1428
|
+
fh.write(json.dumps({"k": key}) + "\n")
|
|
1429
|
+
except OSError:
|
|
1430
|
+
pass
|
|
1431
|
+
|
|
1432
|
+
def seen_intake(self, event_type, payload):
|
|
1433
|
+
"""True if the same repo#N + action + head sha was accepted and its run is live.
|
|
1434
|
+
|
|
1435
|
+
Catches a redelivery under a new delivery id or after a restart. Once the
|
|
1436
|
+
run is no longer live the same key may dispatch again. Only issues and
|
|
1437
|
+
pull_request events carry an intake key.
|
|
1438
|
+
"""
|
|
1439
|
+
if event_type not in ("issues", "pull_request"):
|
|
1440
|
+
return False
|
|
1441
|
+
number = (payload.get("issue") or payload.get("pull_request") or {}).get("number")
|
|
1442
|
+
repo = (payload.get("repository") or {}).get("full_name", "")
|
|
1443
|
+
if number is None or not repo:
|
|
1444
|
+
return False
|
|
1445
|
+
sha = ((payload.get("pull_request") or {}).get("head") or {}).get("sha", "")
|
|
1446
|
+
key = "intake:%s:%s#%s:%s:%s" % (event_type, repo, number, payload.get("action", ""), sha)
|
|
1447
|
+
ref = "%s#%s" % (repo, number)
|
|
1448
|
+
with self._dedup_lock:
|
|
1449
|
+
if key in self._seen_deliveries and find_live_run(ref) is not None:
|
|
1450
|
+
return True
|
|
1451
|
+
if key not in self._seen_deliveries:
|
|
1452
|
+
self._remember(key)
|
|
1453
|
+
return False
|
|
1454
|
+
|
|
1067
1455
|
def seen_delivery(self, delivery_id):
|
|
1068
1456
|
"""Return True if this delivery_id was already accepted (idempotency).
|
|
1069
1457
|
|
|
@@ -1082,9 +1470,7 @@ class Dispatcher:
|
|
|
1082
1470
|
# Insertion order is the eviction policy; duplicates leave it
|
|
1083
1471
|
# unchanged.
|
|
1084
1472
|
return True
|
|
1085
|
-
self.
|
|
1086
|
-
while len(self._seen_deliveries) > self._dedup_max:
|
|
1087
|
-
self._seen_deliveries.popitem(last=False)
|
|
1473
|
+
self._remember(delivery_id)
|
|
1088
1474
|
return False
|
|
1089
1475
|
|
|
1090
1476
|
def _begin_proof_window(self, job_id=None):
|
|
@@ -1585,6 +1971,10 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1585
1971
|
self._send_json(400, {"error": "payload must be a JSON object"})
|
|
1586
1972
|
return
|
|
1587
1973
|
|
|
1974
|
+
if payload.get("kind") == UNIT_JOB_KIND:
|
|
1975
|
+
self._handle_unit_submit(payload)
|
|
1976
|
+
return
|
|
1977
|
+
|
|
1588
1978
|
spec = payload.get("spec")
|
|
1589
1979
|
if not valid_spec(spec):
|
|
1590
1980
|
log_event(JOB_EVENT, "", "", "rejected (invalid spec)")
|
|
@@ -1596,7 +1986,24 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1596
1986
|
return
|
|
1597
1987
|
self._send_json(202, {"id": job_id, "status": "queued"})
|
|
1598
1988
|
|
|
1599
|
-
def
|
|
1989
|
+
def _handle_unit_submit(self, payload):
|
|
1990
|
+
"""POST /jobs {"kind": "unit", ...}; caller already authenticated."""
|
|
1991
|
+
clean, err = validate_unit_job(payload)
|
|
1992
|
+
if clean is None:
|
|
1993
|
+
log_event(JOB_EVENT, "", "", "rejected (invalid unit job)")
|
|
1994
|
+
self._send_json(400, {"error": err})
|
|
1995
|
+
return
|
|
1996
|
+
summary = "unit:%s@%s:%s" % (clean["repo"], clean["ref"], clean["unit"])
|
|
1997
|
+
if not valid_spec(summary):
|
|
1998
|
+
log_event(JOB_EVENT, "", "", "rejected (invalid unit job)")
|
|
1999
|
+
self._send_json(400, {"error": "invalid unit job"})
|
|
2000
|
+
return
|
|
2001
|
+
job_id = self._enqueue_job(summary, unit_job=clean)
|
|
2002
|
+
if job_id is None:
|
|
2003
|
+
return
|
|
2004
|
+
self._send_json(202, {"id": job_id, "status": "queued"})
|
|
2005
|
+
|
|
2006
|
+
def _enqueue_job(self, spec, reply_target=None, pr=False, unit_job=None):
|
|
1600
2007
|
"""Record and enqueue one validated job; shared by /jobs, /slack, /linear.
|
|
1601
2008
|
|
|
1602
2009
|
Returns the job id, or None after sending 429 when the queue is full.
|
|
@@ -1606,6 +2013,8 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1606
2013
|
job = {"spec": spec, "job_id": job_id}
|
|
1607
2014
|
if pr:
|
|
1608
2015
|
job["pr"] = True
|
|
2016
|
+
if unit_job is not None:
|
|
2017
|
+
job["unit_job"] = unit_job
|
|
1609
2018
|
|
|
1610
2019
|
# Record BEFORE enqueueing. A worker can pick the job up the instant
|
|
1611
2020
|
# submit() returns and write "running"/"fired"; record_job is
|
|
@@ -1662,6 +2071,13 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1662
2071
|
self._send_json(400, {"error": "unsupported slack payload"})
|
|
1663
2072
|
return
|
|
1664
2073
|
form = urllib.parse.parse_qs(body.decode("utf-8", errors="replace"))
|
|
2074
|
+
allowed = {u.strip() for u in os.environ.get("LOKI_SLACK_ALLOWED_USERS", "").split(",") if u.strip()}
|
|
2075
|
+
user_id = (form.get("user_id") or [""])[0]
|
|
2076
|
+
if "*" not in allowed and user_id not in allowed:
|
|
2077
|
+
log_event(JOB_EVENT, "", "", "rejected (slack user not allowed)")
|
|
2078
|
+
self._send_json(200, {"response_type": "ephemeral",
|
|
2079
|
+
"text": "You are not allowed to start runs from Slack."})
|
|
2080
|
+
return
|
|
1665
2081
|
text = (form.get("text") or [""])[0]
|
|
1666
2082
|
if not valid_spec(text):
|
|
1667
2083
|
log_event(JOB_EVENT, "", "", "rejected (invalid slack spec)")
|
|
@@ -1777,7 +2193,7 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1777
2193
|
if dup_id or dup_body:
|
|
1778
2194
|
self._send_json(200, {"status": "duplicate"})
|
|
1779
2195
|
return
|
|
1780
|
-
job_id = self._enqueue_job(brief, pr=True)
|
|
2196
|
+
job_id = self._enqueue_job(brief, {"kind": "jira", "key": key}, pr=True)
|
|
1781
2197
|
if job_id is None:
|
|
1782
2198
|
return
|
|
1783
2199
|
self._send_json(202, {"id": job_id, "status": "queued"})
|
|
@@ -1962,6 +2378,14 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1962
2378
|
)
|
|
1963
2379
|
return
|
|
1964
2380
|
|
|
2381
|
+
if self.dispatcher.seen_intake(event_type, payload):
|
|
2382
|
+
log_event(event_type, action, "", "duplicate (live run for same intake key)")
|
|
2383
|
+
self._send_json(
|
|
2384
|
+
200,
|
|
2385
|
+
{"event": event_type, "action": action, "status": "duplicate"},
|
|
2386
|
+
)
|
|
2387
|
+
return
|
|
2388
|
+
|
|
1965
2389
|
# Hand off to the bounded worker queue so the listener never blocks.
|
|
1966
2390
|
accepted = self.dispatcher.submit(event_type, payload)
|
|
1967
2391
|
if not accepted:
|
|
@@ -2139,6 +2563,11 @@ def main():
|
|
|
2139
2563
|
"No Slack signing secret configured: POST /slack is rejected with "
|
|
2140
2564
|
"503. Set SLACK_SIGNING_SECRET."
|
|
2141
2565
|
)
|
|
2566
|
+
elif not os.environ.get("LOKI_SLACK_ALLOWED_USERS", "").strip():
|
|
2567
|
+
logging.warning(
|
|
2568
|
+
"LOKI_SLACK_ALLOWED_USERS is not set: every /slack command is "
|
|
2569
|
+
"rejected. Set Slack user IDs, or * to allow anyone."
|
|
2570
|
+
)
|
|
2142
2571
|
if not linear_secret:
|
|
2143
2572
|
logging.warning(
|
|
2144
2573
|
"No Linear webhook secret configured: POST /linear is rejected with "
|