loki-mode 11.10.1 → 11.15.0
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/lib/backlog.py +18 -3
- package/autonomy/lib/chat.sh +6 -0
- package/autonomy/lib/engine10-push.sh +26 -2
- package/autonomy/lib/proof-generator.py +16 -0
- package/autonomy/lib/trust_trajectory.py +130 -0
- package/autonomy/lib/workspace.py +2 -3
- package/autonomy/loki +263 -58
- package/autonomy/quickstart.sh +40 -0
- package/autonomy/run.sh +39 -15
- package/autonomy/trigger-server.py +452 -29
- package/bin/loki +4 -1
- package/completions/_loki +1 -1
- package/completions/loki.bash +16 -1
- package/dashboard/__init__.py +1 -1
- package/docs/CLI-REFERENCE.md +7 -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 +140 -85
- package/docs/v10/CLI-MODERN.md +5 -4
- package/docs/v10/DECISIONS.md +1 -0
- package/docs/v10/ENGINE.md +2 -0
- package/docs/v10/FAILURE-CLASSES.md +3 -3
- package/docs/v10/MODERNIZE.md +34 -2
- package/docs/v10/PROGRESS.md +15 -0
- package/loki-ts/dist/loki.js +1225 -1139
- package/mcp/__init__.py +1 -1
- package/package.json +2 -2
- package/packages/control-plane/dist/server.js +1889 -1577
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/schemas/loki-yaml.schema.json +5 -0
- package/web-app/dist/assets/{AdminPage-D3PgG7SH.js → AdminPage-bZlvH4TL.js} +1 -1
- package/web-app/dist/assets/{Avatar-Ct77hvlG.js → Avatar-BZrBstQe.js} +1 -1
- package/web-app/dist/assets/{Badge-Cg_gZaJF.js → Badge-Q8l8r4Vq.js} +1 -1
- package/web-app/dist/assets/{Button-i9r2G84n.js → Button-B8Bt0A3S.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-C3UyLXyp.js → CockpitPage-CIYExrXa.js} +1 -1
- package/web-app/dist/assets/{ComparePage-B1dXHga_.js → ComparePage-CBOmHdam.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-CQ_xREw0.js → ErrorBoundary-BKwDPSnK.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-DwUxd_Xi.js → EvidenceReceiptPanel-KqzjphWl.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-C0jAnuAd.js → GitHubIssuesPanel-lF3GkHRv.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-B4pNXJMz.js → GitHubPRsPanel-BCzipPWD.js} +1 -1
- package/web-app/dist/assets/{HomePage-BC9UTS1l.js → HomePage-CIdnkzaN.js} +2 -2
- package/web-app/dist/assets/{LoginPage-CnpdwK8F.js → LoginPage-D-178nTT.js} +1 -1
- package/web-app/dist/assets/{MagicPage-Bj0lZuNp.js → MagicPage-BmHuzMT8.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-yyw3VnfX.js → MetricsPage-CPCiyDFn.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-LMxkJP15.js → NotFoundPage-B2T6n1gx.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-2x0MRkNu.js → ProjectPage-DQmpFhb0.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-A8J8o1gj.js → ProjectsPage-BmugzFEd.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-fwvOVcRb.js → SettingsPage-ZB-RrmgM.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-DqI4sUwk.js → ShowcasePage-BAhwHt4_.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-CyytfNo_.js → SystemSettingsPage-Bt7sf2Vu.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-BTTe76W1.js → TeamsPage-B-v5j_8q.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-CIp3OTlL.js → TemplatesPage-bdqUL-LG.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-dX5L4ky-.js → TerminalOutput-CRHEXBwS.js} +1 -1
- package/web-app/dist/assets/{activity-uHXr2AaF.js → activity-C-ROkmXt.js} +1 -1
- package/web-app/dist/assets/{bell-D6RbxRMo.js → bell-DbiCBxQA.js} +1 -1
- package/web-app/dist/assets/{bot-D31w2Mu6.js → bot-DsIgE_qr.js} +1 -1
- package/web-app/dist/assets/{check-CoKJksbr.js → check-CkAdmHWP.js} +1 -1
- package/web-app/dist/assets/{chevron-left-Bjx7oX7s.js → chevron-left-CYTLivAo.js} +1 -1
- package/web-app/dist/assets/{circle-alert-DOAh8Jmi.js → circle-alert-BVIcAsFJ.js} +1 -1
- package/web-app/dist/assets/{clock-DgEXPNWH.js → clock-Dg3Rq0go.js} +1 -1
- package/web-app/dist/assets/{cloud-0dXJFzBv.js → cloud-CenrqHFl.js} +1 -1
- package/web-app/dist/assets/{code-xml-DytQ_bq7.js → code-xml-C2mvfoug.js} +1 -1
- package/web-app/dist/assets/{database-Qyjryb72.js → database-BKa0_eCb.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-BzhTVy1e.js → dollar-sign-DfelUK1S.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-DTdSOALw.js → file-code-corner-CZKWoHVg.js} +1 -1
- package/web-app/dist/assets/{file-plus-VdjOG1RK.js → file-plus-CZ-m1zMX.js} +1 -1
- package/web-app/dist/assets/{globe-BhxNOfyM.js → globe-S0kv_GwC.js} +1 -1
- package/web-app/dist/assets/{hammer-MQzrw8ee.js → hammer-BW1MDdu1.js} +1 -1
- package/web-app/dist/assets/{index-DaxrMbHf.js → index-ACEYsTc2.js} +3 -3
- package/web-app/dist/assets/{layers-C7nusTP-.js → layers-BrXHYZ-L.js} +1 -1
- package/web-app/dist/assets/{loader-circle-B1qBkgOY.js → loader-circle-DOdhKFx0.js} +1 -1
- package/web-app/dist/assets/{lock-BdkOxK2O.js → lock-BcFTyg8J.js} +1 -1
- package/web-app/dist/assets/{package-RVyK96Zn.js → package-D34cMVQG.js} +1 -1
- package/web-app/dist/assets/{plus-ntmwFRrp.js → plus-CEaMeBxL.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-BJ39b_PP.js → refresh-cw-D2Rzxr5U.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-uPx_-3vz.js → rotate-ccw-C_-EjRLh.js} +1 -1
- package/web-app/dist/assets/{scroll-text-D9qvXkj-.js → scroll-text-BSSCMI_c.js} +1 -1
- package/web-app/dist/assets/{server-gs4xmD8Y.js → server-BKvLonWt.js} +1 -1
- package/web-app/dist/assets/{shield-alert-O2_YffGI.js → shield-alert-DbbQbPdW.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-B3RVBhQx.js → thumbs-up-NoBD1fUR.js} +1 -1
- package/web-app/dist/assets/{trash-2-DEUt7qhq.js → trash-2-BXz8rAyd.js} +1 -1
- package/web-app/dist/assets/{trending-down-oyTns4UZ.js → trending-down-D8u535Re.js} +1 -1
- package/web-app/dist/assets/{trending-up-CdpZK0Oc.js → trending-up-q8k3kgiy.js} +1 -1
- package/web-app/dist/assets/{upload-D9sbjjC8.js → upload-Cp7_zeBI.js} +1 -1
- package/web-app/dist/assets/{usePolling-6ognrnyV.js → usePolling-CTHVdcuX.js} +1 -1
- package/web-app/dist/assets/{user-CBKc8QoI.js → user-DF7bfSf_.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,6 +489,7 @@ 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
|
|
|
@@ -428,11 +498,12 @@ def _scoped_child_env():
|
|
|
428
498
|
return {k: v for k, v in os.environ.items() if k not in CHILD_ENV_DENYLIST}
|
|
429
499
|
|
|
430
500
|
|
|
431
|
-
def run_loki_outcome(args, dry_run=False):
|
|
501
|
+
def run_loki_outcome(args, dry_run=False, stdout_sink=None):
|
|
432
502
|
"""Run a loki command and report WHICH of the three outcomes occurred.
|
|
433
503
|
|
|
434
504
|
Returns one of OUTCOME_PASSED / OUTCOME_FAILED / OUTCOME_UNKNOWN. This is
|
|
435
505
|
the honest version of run_loki_command, which answers only "did it launch".
|
|
506
|
+
When stdout_sink is a list, the decoded stdout is appended to it on completion.
|
|
436
507
|
"""
|
|
437
508
|
cmd = ["loki"] + args
|
|
438
509
|
if dry_run:
|
|
@@ -451,7 +522,9 @@ def run_loki_outcome(args, dry_run=False):
|
|
|
451
522
|
return OUTCOME_FAILED
|
|
452
523
|
|
|
453
524
|
try:
|
|
454
|
-
|
|
525
|
+
stdout, stderr = proc.communicate(timeout=DISPATCH_WAIT_SECONDS)
|
|
526
|
+
if stdout_sink is not None:
|
|
527
|
+
stdout_sink.append((stdout or b"").decode("utf-8", errors="replace"))
|
|
455
528
|
except subprocess.TimeoutExpired:
|
|
456
529
|
# Detached past the wait window. The reaper collects the child, but we
|
|
457
530
|
# never see its exit code, so the outcome is genuinely UNKNOWN -- NOT a
|
|
@@ -485,7 +558,7 @@ def run_loki_outcome(args, dry_run=False):
|
|
|
485
558
|
return OUTCOME_FAILED
|
|
486
559
|
|
|
487
560
|
|
|
488
|
-
def run_loki_command(args, dry_run=False):
|
|
561
|
+
def run_loki_command(args, dry_run=False, stdout_sink=None):
|
|
489
562
|
"""Run a loki command synchronously and reap it; or print it if dry_run.
|
|
490
563
|
|
|
491
564
|
Returns True if the command was launched and exited 0 (or backgrounded
|
|
@@ -501,17 +574,161 @@ def run_loki_command(args, dry_run=False):
|
|
|
501
574
|
passed wants run_loki_outcome instead. Behaviour is unchanged: a detached
|
|
502
575
|
dispatch (UNKNOWN) still reads as True here, exactly as before.
|
|
503
576
|
"""
|
|
504
|
-
return run_loki_outcome(args, dry_run=dry_run) in (
|
|
577
|
+
return run_loki_outcome(args, dry_run=dry_run, stdout_sink=stdout_sink) in (
|
|
505
578
|
OUTCOME_PASSED, OUTCOME_UNKNOWN,
|
|
506
579
|
)
|
|
507
580
|
|
|
508
581
|
|
|
582
|
+
TRUSTED_ASSOCIATIONS = ("OWNER", "MEMBER", "COLLABORATOR") # same set as verify_pr.ts
|
|
583
|
+
STEER_NOTE_MAX = 1000
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _process_start_time(pid):
|
|
587
|
+
"""`ps -o lstart=` for pid, whitespace-normalized like run_pid.ts processStartTime; None if gone."""
|
|
588
|
+
if not isinstance(pid, int) or pid <= 1:
|
|
589
|
+
return None
|
|
590
|
+
try:
|
|
591
|
+
out = subprocess.run(["ps", "-o", "lstart=", "-p", str(pid)], capture_output=True,
|
|
592
|
+
text=True, timeout=5, env={**os.environ, "LC_ALL": "C"}).stdout
|
|
593
|
+
except (OSError, subprocess.SubprocessError):
|
|
594
|
+
return None
|
|
595
|
+
return " ".join(out.split()) or None
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def find_live_run(ref):
|
|
599
|
+
"""Return the id of a live run for issue ref "owner/repo#N", else None.
|
|
600
|
+
|
|
601
|
+
Scans $LOKI_DIR (default .loki)/runs/*/events.jsonl for a run.started whose issue_ref matches and
|
|
602
|
+
whose run.pid names a live process. Mirrors runOwnerAlive: the recorded
|
|
603
|
+
start time and run_id must match, so a recycled pid does not read as live.
|
|
604
|
+
"""
|
|
605
|
+
runs = Path(os.environ.get("LOKI_DIR") or ".loki") / "runs"
|
|
606
|
+
try:
|
|
607
|
+
dirs = sorted(runs.iterdir())
|
|
608
|
+
except OSError:
|
|
609
|
+
return None
|
|
610
|
+
for d in dirs:
|
|
611
|
+
try:
|
|
612
|
+
with open(d / "events.jsonl", "r", encoding="utf-8", errors="replace") as fh:
|
|
613
|
+
first = json.loads(fh.readline() or "{}")
|
|
614
|
+
if first.get("type") != "run.started" or (first.get("data") or {}).get("issue_ref") != ref:
|
|
615
|
+
continue
|
|
616
|
+
rec = json.loads((d / "run.pid").read_text())
|
|
617
|
+
pid = int(rec.get("pid", 0))
|
|
618
|
+
start = rec.get("start")
|
|
619
|
+
if pid > 1 and isinstance(start, str) and rec.get("run_id") == d.name \
|
|
620
|
+
and _process_start_time(pid) == " ".join(start.split()):
|
|
621
|
+
return d.name
|
|
622
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
623
|
+
continue
|
|
624
|
+
return None
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def steer_ack_text(rev):
|
|
628
|
+
"""Ack line for a steer; byte-identical to the line `loki steer` prints (cmd_steer)."""
|
|
629
|
+
if rev is None:
|
|
630
|
+
return "Steer received: applies at next plan/implement stage."
|
|
631
|
+
return "Steer rev %s received: applies at next plan/implement stage." % rev
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
STEER_ACK_MARKER = "<!-- loki-steer-ack -->"
|
|
635
|
+
LOKI_COMMENT_MARKER = "<!-- loki-" # any Loki-authored comment (status projector, steer ack) never steers
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def parse_steer_rev(stdout):
|
|
639
|
+
"""Revision number from `loki steer` stdout (its ack line), else None. Never guesses."""
|
|
640
|
+
m = re.search(r"Steer rev (\d+) received", stdout or "") \
|
|
641
|
+
or re.search(r"Steering note \(rev (\d+)\)", stdout or "")
|
|
642
|
+
return int(m.group(1)) if m else None
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def post_steer_ack(ref, repo_full_name, rev, dry_run=False):
|
|
646
|
+
"""Post one ack comment on the issue through engine10-push.sh issue-comment. Best effort."""
|
|
647
|
+
text = steer_ack_text(rev)
|
|
648
|
+
if dry_run:
|
|
649
|
+
logging.info("[DRY-RUN] Would ack %s: %s", ref, text)
|
|
650
|
+
return True
|
|
651
|
+
script = Path(__file__).resolve().parent / "lib" / "engine10-push.sh"
|
|
652
|
+
body = None
|
|
653
|
+
try:
|
|
654
|
+
fd, body = tempfile.mkstemp(prefix="loki-steer-ack.", suffix=".md")
|
|
655
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
656
|
+
fh.write(STEER_ACK_MARKER + "\n" + text + "\n")
|
|
657
|
+
env = _scoped_child_env()
|
|
658
|
+
env["_LOKI_ORIGIN_PINNED"] = "1"
|
|
659
|
+
env["_LOKI_PINNED_ORIGIN"] = "https://github.com/%s.git" % repo_full_name
|
|
660
|
+
proc = subprocess.run(["bash", str(script), "issue-comment", ref, body],
|
|
661
|
+
capture_output=True, text=True, timeout=60, env=env)
|
|
662
|
+
if proc.returncode != 0:
|
|
663
|
+
logging.error("steer ack failed (%d): %s", proc.returncode, proc.stderr.strip())
|
|
664
|
+
return proc.returncode == 0
|
|
665
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
666
|
+
logging.error("steer ack failed: %s", e)
|
|
667
|
+
return False
|
|
668
|
+
finally:
|
|
669
|
+
if body:
|
|
670
|
+
try:
|
|
671
|
+
os.unlink(body)
|
|
672
|
+
except OSError:
|
|
673
|
+
pass
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def handle_issue_comment_event(payload, dry_run=False):
|
|
677
|
+
"""Handle issue_comment: an authorized new comment steers the issue's live run."""
|
|
678
|
+
action = payload.get("action", "")
|
|
679
|
+
if action != "created":
|
|
680
|
+
return None, "skipped (action=%s)" % action
|
|
681
|
+
comment = payload.get("comment") or {}
|
|
682
|
+
if comment.get("author_association") not in TRUSTED_ASSOCIATIONS:
|
|
683
|
+
return None, "skipped (comment author not authorized)"
|
|
684
|
+
user = comment.get("user") or {}
|
|
685
|
+
login = str(user.get("login", ""))
|
|
686
|
+
if user.get("type") == "Bot" or login.endswith("[bot]"):
|
|
687
|
+
return None, "skipped (bot comment)"
|
|
688
|
+
if LOKI_COMMENT_MARKER in str(comment.get("body", "")):
|
|
689
|
+
return None, "skipped (loki-authored comment)"
|
|
690
|
+
number = (payload.get("issue") or {}).get("number")
|
|
691
|
+
repo_full_name = (payload.get("repository") or {}).get("full_name", "")
|
|
692
|
+
if number is None or not valid_issue_number(number):
|
|
693
|
+
return None, "rejected (invalid issue number)"
|
|
694
|
+
if not valid_repo_full_name(repo_full_name):
|
|
695
|
+
return None, "rejected (invalid repository full_name)"
|
|
696
|
+
ref = "%s#%s" % (repo_full_name, number)
|
|
697
|
+
if find_live_run(ref) is None:
|
|
698
|
+
return None, "skipped (no live run for %s)" % ref
|
|
699
|
+
body = re.sub(r"[\x00-\x1f\x7f\s]+", " ", str(comment.get("body", ""))).strip()[:STEER_NOTE_MAX]
|
|
700
|
+
if not body:
|
|
701
|
+
return None, "skipped (empty comment)"
|
|
702
|
+
login = re.sub(r"[^A-Za-z0-9-]", "", login)[:39]
|
|
703
|
+
note = "GitHub comment from %s: %s" % (login or "unknown", body)
|
|
704
|
+
summary = "steer %s from comment" % ref
|
|
705
|
+
sink = []
|
|
706
|
+
outcome = run_loki_outcome(["steer", note], dry_run=dry_run, stdout_sink=sink)
|
|
707
|
+
# Only a confirmed exit 0 proves the steer was written. A detached child
|
|
708
|
+
# (UNKNOWN) never reports its exit code, so acking would claim an unverified
|
|
709
|
+
# steer; report it as launched but post nothing.
|
|
710
|
+
if outcome == OUTCOME_UNKNOWN:
|
|
711
|
+
send_notification("Trigger fired: %s" % summary)
|
|
712
|
+
return summary, OUTCOME_UNKNOWN
|
|
713
|
+
success = outcome == OUTCOME_PASSED
|
|
714
|
+
if success:
|
|
715
|
+
send_notification("Trigger fired: %s" % summary)
|
|
716
|
+
rev = parse_steer_rev("".join(sink))
|
|
717
|
+
injection_off = os.environ.get("LOKI_PROMPT_INJECTION", "").lower() in ("0", "false")
|
|
718
|
+
if not injection_off:
|
|
719
|
+
post_steer_ack(ref, repo_full_name, rev, dry_run=dry_run)
|
|
720
|
+
return summary, "fired" if success else "error"
|
|
721
|
+
|
|
722
|
+
|
|
509
723
|
def handle_issues_event(payload, dry_run=False):
|
|
510
724
|
"""Handle issues event: opened -> loki start <issue-ref> --pr --detach."""
|
|
511
725
|
action = payload.get("action", "")
|
|
512
726
|
if action != "opened":
|
|
513
727
|
return None, "skipped (action=%s)" % action
|
|
514
728
|
issue = payload.get("issue", {})
|
|
729
|
+
if (issue.get("author_association") not in TRUSTED_ASSOCIATIONS
|
|
730
|
+
and os.environ.get("LOKI_TRIGGER_ALLOW_ANY_ISSUE_AUTHOR", "") not in ("1", "true")):
|
|
731
|
+
return None, "skipped (issue author not authorized)"
|
|
515
732
|
issue_number = issue.get("number")
|
|
516
733
|
repo = payload.get("repository", {})
|
|
517
734
|
repo_full_name = repo.get("full_name", "")
|
|
@@ -605,8 +822,15 @@ def handle_job_event(payload, dry_run=False):
|
|
|
605
822
|
spec = payload.get("spec")
|
|
606
823
|
if not valid_spec(spec):
|
|
607
824
|
return None, "rejected (invalid spec)"
|
|
825
|
+
unit_job = payload.get("unit_job")
|
|
826
|
+
if unit_job is not None:
|
|
827
|
+
# Re-validated here so the handler is safe no matter who calls it.
|
|
828
|
+
clean, _err = validate_unit_job(unit_job)
|
|
829
|
+
if clean is None:
|
|
830
|
+
return None, "rejected (invalid unit job)"
|
|
831
|
+
args = unit_start_args(clean)
|
|
608
832
|
# Jira/Teams delegation sets pr=True: receipt-gated PR run, never a deploy.
|
|
609
|
-
|
|
833
|
+
elif payload.get("pr") is True:
|
|
610
834
|
args = brief_start_args(spec.strip())
|
|
611
835
|
else:
|
|
612
836
|
args = ["start", spec.strip(), "--detach"]
|
|
@@ -878,32 +1102,61 @@ def _post_json(url, data, headers):
|
|
|
878
1102
|
resp.read()
|
|
879
1103
|
|
|
880
1104
|
|
|
1105
|
+
def _notify_slack(target, text):
|
|
1106
|
+
url = target.get("response_url", "")
|
|
1107
|
+
if not (isinstance(url, str) and url.startswith(SLACK_REPLY_PREFIX)):
|
|
1108
|
+
return
|
|
1109
|
+
_post_json(url, {"response_type": "in_channel", "text": text}, {})
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _notify_linear(target, text):
|
|
1113
|
+
key = os.environ.get("LINEAR_API_KEY", "")
|
|
1114
|
+
issue_id = target.get("issue_id", "")
|
|
1115
|
+
if not key or not isinstance(issue_id, str) or not issue_id:
|
|
1116
|
+
return
|
|
1117
|
+
_post_json(LINEAR_API_URL, {
|
|
1118
|
+
"query": "mutation($i:String!,$b:String!){commentCreate(input:"
|
|
1119
|
+
"{issueId:$i,body:$b}){success}}",
|
|
1120
|
+
"variables": {"i": issue_id, "b": text},
|
|
1121
|
+
}, {"Authorization": key})
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _notify_jira(target, text):
|
|
1125
|
+
"""ADF comment on the issue. Site and credentials come from the operator's
|
|
1126
|
+
env only (never the payload); a non-https base URL is never contacted."""
|
|
1127
|
+
base = os.environ.get("JIRA_BASE_URL", "").rstrip("/")
|
|
1128
|
+
email = os.environ.get("JIRA_EMAIL", "")
|
|
1129
|
+
token = os.environ.get("JIRA_API_TOKEN", "")
|
|
1130
|
+
key = target.get("key", "")
|
|
1131
|
+
if not (base.startswith("https://") and email and token
|
|
1132
|
+
and isinstance(key, str) and JIRA_KEY_RE.match(key)):
|
|
1133
|
+
return
|
|
1134
|
+
auth = base64.b64encode(("%s:%s" % (email, token)).encode("utf-8")).decode("ascii")
|
|
1135
|
+
_post_json("%s/rest/api/3/issue/%s/comment" % (base, key), {
|
|
1136
|
+
"body": {"type": "doc", "version": 1, "content": [
|
|
1137
|
+
{"type": "paragraph", "content": [{"type": "text", "text": text}]}]},
|
|
1138
|
+
}, {"Authorization": "Basic " + auth})
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
# Reply-target kind -> notifier. Sentry has no entry on purpose: it never replies.
|
|
1142
|
+
NOTIFIERS = {"slack": _notify_slack, "linear": _notify_linear, "jira": _notify_jira}
|
|
1143
|
+
|
|
1144
|
+
|
|
881
1145
|
def notify_job_complete(target, job_id, status, summary):
|
|
882
1146
|
"""Send a completion summary to a reply target captured after verification.
|
|
883
1147
|
|
|
884
|
-
Never raises: a notifier failure must not fail the job.
|
|
885
|
-
re-
|
|
1148
|
+
Never raises: a notifier failure must not fail the job. Each notifier
|
|
1149
|
+
re-checks its own destination as defense in depth.
|
|
886
1150
|
"""
|
|
887
1151
|
if not isinstance(target, dict):
|
|
888
1152
|
return
|
|
1153
|
+
notifier = NOTIFIERS.get(target.get("kind"))
|
|
1154
|
+
if notifier is None:
|
|
1155
|
+
return
|
|
889
1156
|
text = ("loki job %s finished: %s. %s" % (job_id, status, summary or "")).strip()
|
|
890
1157
|
text = CONTROL_CHARS_RE.sub(" ", text)[:NOTIFY_TEXT_MAX]
|
|
891
1158
|
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})
|
|
1159
|
+
notifier(target, text)
|
|
907
1160
|
except Exception as e: # notifier errors never fail a job
|
|
908
1161
|
logging.warning("Completion notify for job %s failed: %s", job_id, e)
|
|
909
1162
|
|
|
@@ -941,7 +1194,64 @@ def normalize_sentry_payload(payload):
|
|
|
941
1194
|
return out if isinstance(out, dict) else None
|
|
942
1195
|
|
|
943
1196
|
|
|
944
|
-
def
|
|
1197
|
+
def _clip(value, limit=80):
|
|
1198
|
+
"""Single-line, control-char-free, length-capped untrusted text."""
|
|
1199
|
+
text = " ".join(CONTROL_CHARS_RE.sub(" ", str(value or "")).split())
|
|
1200
|
+
return text.encode("utf-8")[:limit].decode("utf-8", errors="ignore")
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _sentry_release_env(payload):
|
|
1204
|
+
ev = ((payload or {}).get("data") or {}).get("event")
|
|
1205
|
+
ev = ev if isinstance(ev, dict) else {}
|
|
1206
|
+
release = ev.get("release")
|
|
1207
|
+
if isinstance(release, dict):
|
|
1208
|
+
release = release.get("version")
|
|
1209
|
+
return _clip(release), _clip(ev.get("environment"), 40)
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def _last_deploy_context():
|
|
1213
|
+
"""'last deploy <sha7> <finished_at> health=<status>' from the newest
|
|
1214
|
+
.loki/deploys record in the server's project dir, or ''."""
|
|
1215
|
+
try:
|
|
1216
|
+
ddir = os.path.join(os.getcwd(), ".loki", "deploys")
|
|
1217
|
+
names = sorted(n for n in os.listdir(ddir) if n.endswith(".json"))
|
|
1218
|
+
if not names:
|
|
1219
|
+
return ""
|
|
1220
|
+
with open(os.path.join(ddir, names[-1]), "r", encoding="utf-8") as fh:
|
|
1221
|
+
rec = json.load(fh)
|
|
1222
|
+
except (OSError, ValueError):
|
|
1223
|
+
return ""
|
|
1224
|
+
if not isinstance(rec, dict) or not rec.get("head_sha"):
|
|
1225
|
+
return ""
|
|
1226
|
+
return "last deploy %s %s health=%s" % (
|
|
1227
|
+
_clip(rec.get("head_sha"), 7), _clip(rec.get("finished_at"), 30),
|
|
1228
|
+
_clip(rec.get("health_status") or "unchecked", 20))
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def _culprit_owner(culprit):
|
|
1232
|
+
"""'last change to <file> by <author> <sha7>' via read-only git log -1, or ''."""
|
|
1233
|
+
token = culprit.split(" ", 1)[0] if culprit else ""
|
|
1234
|
+
if not token or token.startswith("-") or ".." in token:
|
|
1235
|
+
return ""
|
|
1236
|
+
root = os.path.realpath(os.getcwd())
|
|
1237
|
+
full = os.path.realpath(os.path.join(root, token))
|
|
1238
|
+
if not full.startswith(root + os.sep) or not os.path.isfile(full):
|
|
1239
|
+
return ""
|
|
1240
|
+
try:
|
|
1241
|
+
proc = subprocess.run(
|
|
1242
|
+
["git", "-C", root, "log", "-1", "--format=%an|%h", "--", full],
|
|
1243
|
+
capture_output=True, timeout=5,
|
|
1244
|
+
env={"PATH": os.environ.get("PATH", "/usr/bin:/bin")})
|
|
1245
|
+
except (OSError, subprocess.SubprocessError):
|
|
1246
|
+
return ""
|
|
1247
|
+
out = proc.stdout.decode("utf-8", errors="replace").strip()
|
|
1248
|
+
if proc.returncode != 0 or "|" not in out:
|
|
1249
|
+
return ""
|
|
1250
|
+
author, sha = out.rsplit("|", 1)
|
|
1251
|
+
return "last change to %s by %s %s" % (_clip(token, 60), _clip(author, 40), _clip(sha, 12))
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def build_sentry_brief(norm, payload=None):
|
|
945
1255
|
"""One-line, control-char-free brief that can never parse as a CLI flag."""
|
|
946
1256
|
culprit = ""
|
|
947
1257
|
for line in str(norm.get("body", "")).splitlines():
|
|
@@ -951,6 +1261,17 @@ def build_sentry_brief(norm):
|
|
|
951
1261
|
parts = ["Fix Sentry issue %s: %s" % (norm.get("number", "?"), norm.get("title", ""))]
|
|
952
1262
|
if culprit:
|
|
953
1263
|
parts.append("(culprit %s)" % culprit)
|
|
1264
|
+
release, env_name = _sentry_release_env(payload)
|
|
1265
|
+
ctx = []
|
|
1266
|
+
if release:
|
|
1267
|
+
ctx.append("release %s" % release)
|
|
1268
|
+
if env_name:
|
|
1269
|
+
ctx.append("env %s" % env_name)
|
|
1270
|
+
for extra in (_last_deploy_context(), _culprit_owner(culprit)):
|
|
1271
|
+
if extra:
|
|
1272
|
+
ctx.append(extra)
|
|
1273
|
+
if ctx:
|
|
1274
|
+
parts.append("[%s]" % "; ".join(ctx))
|
|
954
1275
|
if norm.get("url"):
|
|
955
1276
|
parts.append("See %s." % norm["url"])
|
|
956
1277
|
parts.append("Reproduce with a regression test, then fix the root cause.")
|
|
@@ -971,7 +1292,7 @@ def handle_sentry_event(payload, dry_run=False):
|
|
|
971
1292
|
norm = normalize_sentry_payload(payload)
|
|
972
1293
|
if norm is None:
|
|
973
1294
|
return None, "rejected (unparseable sentry payload)"
|
|
974
|
-
brief = build_sentry_brief(norm)
|
|
1295
|
+
brief = build_sentry_brief(norm, payload)
|
|
975
1296
|
if not valid_spec(brief):
|
|
976
1297
|
return None, "rejected (invalid brief)"
|
|
977
1298
|
args = brief_start_args(brief)
|
|
@@ -988,6 +1309,7 @@ def handle_sentry_event(payload, dry_run=False):
|
|
|
988
1309
|
# explicitly refused on the /webhook path.
|
|
989
1310
|
EVENT_HANDLERS = {
|
|
990
1311
|
"issues": handle_issues_event,
|
|
1312
|
+
"issue_comment": handle_issue_comment_event,
|
|
991
1313
|
"pull_request": handle_pull_request_event,
|
|
992
1314
|
"workflow_run": handle_workflow_run_event,
|
|
993
1315
|
JOB_EVENT: handle_job_event,
|
|
@@ -1053,6 +1375,8 @@ class Dispatcher:
|
|
|
1053
1375
|
self._dedup_max = max(1, dedup_size)
|
|
1054
1376
|
self._seen_deliveries = collections.OrderedDict()
|
|
1055
1377
|
self._dedup_lock = threading.Lock()
|
|
1378
|
+
self._seen_path = get_loki_dir() / "seen-deliveries.jsonl"
|
|
1379
|
+
self._load_seen()
|
|
1056
1380
|
self._threads = []
|
|
1057
1381
|
self._stop = threading.Event()
|
|
1058
1382
|
for i in range(max(1, workers)):
|
|
@@ -1064,6 +1388,64 @@ class Dispatcher:
|
|
|
1064
1388
|
t.start()
|
|
1065
1389
|
self._threads.append(t)
|
|
1066
1390
|
|
|
1391
|
+
def _load_seen(self):
|
|
1392
|
+
"""Reload the last dedup_max accepted keys so a restart keeps dedup."""
|
|
1393
|
+
try:
|
|
1394
|
+
lines = self._seen_path.read_text(encoding="utf-8").splitlines()
|
|
1395
|
+
except OSError:
|
|
1396
|
+
return
|
|
1397
|
+
keys = []
|
|
1398
|
+
for line in lines:
|
|
1399
|
+
try:
|
|
1400
|
+
k = json.loads(line).get("k")
|
|
1401
|
+
except (ValueError, AttributeError):
|
|
1402
|
+
continue
|
|
1403
|
+
if isinstance(k, str) and k:
|
|
1404
|
+
keys.append(k)
|
|
1405
|
+
keys = keys[-self._dedup_max:]
|
|
1406
|
+
for k in keys:
|
|
1407
|
+
self._seen_deliveries[k] = True
|
|
1408
|
+
if len(lines) > 2 * self._dedup_max:
|
|
1409
|
+
try:
|
|
1410
|
+
self._seen_path.write_text(
|
|
1411
|
+
"".join(json.dumps({"k": k}) + "\n" for k in keys), encoding="utf-8")
|
|
1412
|
+
except OSError:
|
|
1413
|
+
pass
|
|
1414
|
+
|
|
1415
|
+
def _remember(self, key):
|
|
1416
|
+
"""Record key (caller holds _dedup_lock); append to the JSONL, best effort."""
|
|
1417
|
+
self._seen_deliveries[key] = True
|
|
1418
|
+
while len(self._seen_deliveries) > self._dedup_max:
|
|
1419
|
+
self._seen_deliveries.popitem(last=False)
|
|
1420
|
+
try:
|
|
1421
|
+
with open(self._seen_path, "a", encoding="utf-8") as fh:
|
|
1422
|
+
fh.write(json.dumps({"k": key}) + "\n")
|
|
1423
|
+
except OSError:
|
|
1424
|
+
pass
|
|
1425
|
+
|
|
1426
|
+
def seen_intake(self, event_type, payload):
|
|
1427
|
+
"""True if the same repo#N + action + head sha was accepted and its run is live.
|
|
1428
|
+
|
|
1429
|
+
Catches a redelivery under a new delivery id or after a restart. Once the
|
|
1430
|
+
run is no longer live the same key may dispatch again. Only issues and
|
|
1431
|
+
pull_request events carry an intake key.
|
|
1432
|
+
"""
|
|
1433
|
+
if event_type not in ("issues", "pull_request"):
|
|
1434
|
+
return False
|
|
1435
|
+
number = (payload.get("issue") or payload.get("pull_request") or {}).get("number")
|
|
1436
|
+
repo = (payload.get("repository") or {}).get("full_name", "")
|
|
1437
|
+
if number is None or not repo:
|
|
1438
|
+
return False
|
|
1439
|
+
sha = ((payload.get("pull_request") or {}).get("head") or {}).get("sha", "")
|
|
1440
|
+
key = "intake:%s:%s#%s:%s:%s" % (event_type, repo, number, payload.get("action", ""), sha)
|
|
1441
|
+
ref = "%s#%s" % (repo, number)
|
|
1442
|
+
with self._dedup_lock:
|
|
1443
|
+
if key in self._seen_deliveries and find_live_run(ref) is not None:
|
|
1444
|
+
return True
|
|
1445
|
+
if key not in self._seen_deliveries:
|
|
1446
|
+
self._remember(key)
|
|
1447
|
+
return False
|
|
1448
|
+
|
|
1067
1449
|
def seen_delivery(self, delivery_id):
|
|
1068
1450
|
"""Return True if this delivery_id was already accepted (idempotency).
|
|
1069
1451
|
|
|
@@ -1082,9 +1464,7 @@ class Dispatcher:
|
|
|
1082
1464
|
# Insertion order is the eviction policy; duplicates leave it
|
|
1083
1465
|
# unchanged.
|
|
1084
1466
|
return True
|
|
1085
|
-
self.
|
|
1086
|
-
while len(self._seen_deliveries) > self._dedup_max:
|
|
1087
|
-
self._seen_deliveries.popitem(last=False)
|
|
1467
|
+
self._remember(delivery_id)
|
|
1088
1468
|
return False
|
|
1089
1469
|
|
|
1090
1470
|
def _begin_proof_window(self, job_id=None):
|
|
@@ -1585,6 +1965,10 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1585
1965
|
self._send_json(400, {"error": "payload must be a JSON object"})
|
|
1586
1966
|
return
|
|
1587
1967
|
|
|
1968
|
+
if payload.get("kind") == UNIT_JOB_KIND:
|
|
1969
|
+
self._handle_unit_submit(payload)
|
|
1970
|
+
return
|
|
1971
|
+
|
|
1588
1972
|
spec = payload.get("spec")
|
|
1589
1973
|
if not valid_spec(spec):
|
|
1590
1974
|
log_event(JOB_EVENT, "", "", "rejected (invalid spec)")
|
|
@@ -1596,7 +1980,24 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1596
1980
|
return
|
|
1597
1981
|
self._send_json(202, {"id": job_id, "status": "queued"})
|
|
1598
1982
|
|
|
1599
|
-
def
|
|
1983
|
+
def _handle_unit_submit(self, payload):
|
|
1984
|
+
"""POST /jobs {"kind": "unit", ...}; caller already authenticated."""
|
|
1985
|
+
clean, err = validate_unit_job(payload)
|
|
1986
|
+
if clean is None:
|
|
1987
|
+
log_event(JOB_EVENT, "", "", "rejected (invalid unit job)")
|
|
1988
|
+
self._send_json(400, {"error": err})
|
|
1989
|
+
return
|
|
1990
|
+
summary = "unit:%s@%s:%s" % (clean["repo"], clean["ref"], clean["unit"])
|
|
1991
|
+
if not valid_spec(summary):
|
|
1992
|
+
log_event(JOB_EVENT, "", "", "rejected (invalid unit job)")
|
|
1993
|
+
self._send_json(400, {"error": "invalid unit job"})
|
|
1994
|
+
return
|
|
1995
|
+
job_id = self._enqueue_job(summary, unit_job=clean)
|
|
1996
|
+
if job_id is None:
|
|
1997
|
+
return
|
|
1998
|
+
self._send_json(202, {"id": job_id, "status": "queued"})
|
|
1999
|
+
|
|
2000
|
+
def _enqueue_job(self, spec, reply_target=None, pr=False, unit_job=None):
|
|
1600
2001
|
"""Record and enqueue one validated job; shared by /jobs, /slack, /linear.
|
|
1601
2002
|
|
|
1602
2003
|
Returns the job id, or None after sending 429 when the queue is full.
|
|
@@ -1606,6 +2007,8 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1606
2007
|
job = {"spec": spec, "job_id": job_id}
|
|
1607
2008
|
if pr:
|
|
1608
2009
|
job["pr"] = True
|
|
2010
|
+
if unit_job is not None:
|
|
2011
|
+
job["unit_job"] = unit_job
|
|
1609
2012
|
|
|
1610
2013
|
# Record BEFORE enqueueing. A worker can pick the job up the instant
|
|
1611
2014
|
# submit() returns and write "running"/"fired"; record_job is
|
|
@@ -1662,6 +2065,13 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1662
2065
|
self._send_json(400, {"error": "unsupported slack payload"})
|
|
1663
2066
|
return
|
|
1664
2067
|
form = urllib.parse.parse_qs(body.decode("utf-8", errors="replace"))
|
|
2068
|
+
allowed = {u.strip() for u in os.environ.get("LOKI_SLACK_ALLOWED_USERS", "").split(",") if u.strip()}
|
|
2069
|
+
user_id = (form.get("user_id") or [""])[0]
|
|
2070
|
+
if "*" not in allowed and user_id not in allowed:
|
|
2071
|
+
log_event(JOB_EVENT, "", "", "rejected (slack user not allowed)")
|
|
2072
|
+
self._send_json(200, {"response_type": "ephemeral",
|
|
2073
|
+
"text": "You are not allowed to start runs from Slack."})
|
|
2074
|
+
return
|
|
1665
2075
|
text = (form.get("text") or [""])[0]
|
|
1666
2076
|
if not valid_spec(text):
|
|
1667
2077
|
log_event(JOB_EVENT, "", "", "rejected (invalid slack spec)")
|
|
@@ -1777,7 +2187,7 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1777
2187
|
if dup_id or dup_body:
|
|
1778
2188
|
self._send_json(200, {"status": "duplicate"})
|
|
1779
2189
|
return
|
|
1780
|
-
job_id = self._enqueue_job(brief, pr=True)
|
|
2190
|
+
job_id = self._enqueue_job(brief, {"kind": "jira", "key": key}, pr=True)
|
|
1781
2191
|
if job_id is None:
|
|
1782
2192
|
return
|
|
1783
2193
|
self._send_json(202, {"id": job_id, "status": "queued"})
|
|
@@ -1962,6 +2372,14 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1962
2372
|
)
|
|
1963
2373
|
return
|
|
1964
2374
|
|
|
2375
|
+
if self.dispatcher.seen_intake(event_type, payload):
|
|
2376
|
+
log_event(event_type, action, "", "duplicate (live run for same intake key)")
|
|
2377
|
+
self._send_json(
|
|
2378
|
+
200,
|
|
2379
|
+
{"event": event_type, "action": action, "status": "duplicate"},
|
|
2380
|
+
)
|
|
2381
|
+
return
|
|
2382
|
+
|
|
1965
2383
|
# Hand off to the bounded worker queue so the listener never blocks.
|
|
1966
2384
|
accepted = self.dispatcher.submit(event_type, payload)
|
|
1967
2385
|
if not accepted:
|
|
@@ -2139,6 +2557,11 @@ def main():
|
|
|
2139
2557
|
"No Slack signing secret configured: POST /slack is rejected with "
|
|
2140
2558
|
"503. Set SLACK_SIGNING_SECRET."
|
|
2141
2559
|
)
|
|
2560
|
+
elif not os.environ.get("LOKI_SLACK_ALLOWED_USERS", "").strip():
|
|
2561
|
+
logging.warning(
|
|
2562
|
+
"LOKI_SLACK_ALLOWED_USERS is not set: every /slack command is "
|
|
2563
|
+
"rejected. Set Slack user IDs, or * to allow anyone."
|
|
2564
|
+
)
|
|
2142
2565
|
if not linear_secret:
|
|
2143
2566
|
logging.warning(
|
|
2144
2567
|
"No Linear webhook secret configured: POST /linear is rejected with "
|
package/bin/loki
CHANGED
|
@@ -391,9 +391,12 @@ _loki_bun_why() {
|
|
|
391
391
|
# them to the legacy CLI today. (`--help`'s own content -- whether it should
|
|
392
392
|
# also mention heal/migrate -- is modernize/cli.ts's USAGE text, outside this
|
|
393
393
|
# arm's file set; left unchanged.)
|
|
394
|
+
# M-23: `loki modernize unit ...` (the remote unit worker) is the second routing
|
|
395
|
+
# signal. It takes no --to, and the legacy cmd_modernize has no `unit` subcommand,
|
|
396
|
+
# so routing it to Bun changes nothing for heal/migrate.
|
|
394
397
|
if [ "${1:-}" = "modernize" ]; then
|
|
395
398
|
for _loki_mod_a in "$@"; do
|
|
396
|
-
if [ "$_loki_mod_a" = "--to" ]; then
|
|
399
|
+
if [ "$_loki_mod_a" = "--to" ] || [ "${2:-}" = "unit" ]; then
|
|
397
400
|
_lb=$(_loki_bun)
|
|
398
401
|
[ -n "$_lb" ] || { echo "Error: loki modernize needs bun." >&2; exit 1; }
|
|
399
402
|
exec "$_lb" "$BUN_CLI" engine10 "$@"
|