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.
Files changed (99) hide show
  1. package/README.md +2 -2
  2. package/SKILL.md +3 -3
  3. package/VERSION +1 -1
  4. package/autonomy/lib/backlog.py +18 -3
  5. package/autonomy/lib/chat.sh +6 -0
  6. package/autonomy/lib/engine10-push.sh +26 -2
  7. package/autonomy/lib/proof-generator.py +16 -0
  8. package/autonomy/lib/trust_trajectory.py +130 -0
  9. package/autonomy/lib/workspace.py +2 -3
  10. package/autonomy/loki +263 -58
  11. package/autonomy/quickstart.sh +40 -0
  12. package/autonomy/run.sh +39 -15
  13. package/autonomy/trigger-server.py +452 -29
  14. package/bin/loki +4 -1
  15. package/completions/_loki +1 -1
  16. package/completions/loki.bash +16 -1
  17. package/dashboard/__init__.py +1 -1
  18. package/docs/CLI-REFERENCE.md +7 -4
  19. package/docs/DEPLOYMENT.md +12 -5
  20. package/docs/EVALUATING.md +13 -0
  21. package/docs/INSTALLATION.md +2 -2
  22. package/docs/SIGNED-RECEIPTS.md +7 -0
  23. package/docs/WORKSPACES.md +2 -2
  24. package/docs/cost-controls.md +26 -0
  25. package/docs/merge-queue.md +19 -0
  26. package/docs/sentry-intake.md +4 -0
  27. package/docs/slack.md +2 -1
  28. package/docs/trackers.md +12 -1
  29. package/docs/v10/BOARD.md +140 -85
  30. package/docs/v10/CLI-MODERN.md +5 -4
  31. package/docs/v10/DECISIONS.md +1 -0
  32. package/docs/v10/ENGINE.md +2 -0
  33. package/docs/v10/FAILURE-CLASSES.md +3 -3
  34. package/docs/v10/MODERNIZE.md +34 -2
  35. package/docs/v10/PROGRESS.md +15 -0
  36. package/loki-ts/dist/loki.js +1225 -1139
  37. package/mcp/__init__.py +1 -1
  38. package/package.json +2 -2
  39. package/packages/control-plane/dist/server.js +1889 -1577
  40. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  41. package/schemas/loki-yaml.schema.json +5 -0
  42. package/web-app/dist/assets/{AdminPage-D3PgG7SH.js → AdminPage-bZlvH4TL.js} +1 -1
  43. package/web-app/dist/assets/{Avatar-Ct77hvlG.js → Avatar-BZrBstQe.js} +1 -1
  44. package/web-app/dist/assets/{Badge-Cg_gZaJF.js → Badge-Q8l8r4Vq.js} +1 -1
  45. package/web-app/dist/assets/{Button-i9r2G84n.js → Button-B8Bt0A3S.js} +1 -1
  46. package/web-app/dist/assets/{CockpitPage-C3UyLXyp.js → CockpitPage-CIYExrXa.js} +1 -1
  47. package/web-app/dist/assets/{ComparePage-B1dXHga_.js → ComparePage-CBOmHdam.js} +1 -1
  48. package/web-app/dist/assets/{ErrorBoundary-CQ_xREw0.js → ErrorBoundary-BKwDPSnK.js} +1 -1
  49. package/web-app/dist/assets/{EvidenceReceiptPanel-DwUxd_Xi.js → EvidenceReceiptPanel-KqzjphWl.js} +1 -1
  50. package/web-app/dist/assets/{GitHubIssuesPanel-C0jAnuAd.js → GitHubIssuesPanel-lF3GkHRv.js} +1 -1
  51. package/web-app/dist/assets/{GitHubPRsPanel-B4pNXJMz.js → GitHubPRsPanel-BCzipPWD.js} +1 -1
  52. package/web-app/dist/assets/{HomePage-BC9UTS1l.js → HomePage-CIdnkzaN.js} +2 -2
  53. package/web-app/dist/assets/{LoginPage-CnpdwK8F.js → LoginPage-D-178nTT.js} +1 -1
  54. package/web-app/dist/assets/{MagicPage-Bj0lZuNp.js → MagicPage-BmHuzMT8.js} +1 -1
  55. package/web-app/dist/assets/{MetricsPage-yyw3VnfX.js → MetricsPage-CPCiyDFn.js} +1 -1
  56. package/web-app/dist/assets/{NotFoundPage-LMxkJP15.js → NotFoundPage-B2T6n1gx.js} +1 -1
  57. package/web-app/dist/assets/{ProjectPage-2x0MRkNu.js → ProjectPage-DQmpFhb0.js} +3 -3
  58. package/web-app/dist/assets/{ProjectsPage-A8J8o1gj.js → ProjectsPage-BmugzFEd.js} +1 -1
  59. package/web-app/dist/assets/{SettingsPage-fwvOVcRb.js → SettingsPage-ZB-RrmgM.js} +1 -1
  60. package/web-app/dist/assets/{ShowcasePage-DqI4sUwk.js → ShowcasePage-BAhwHt4_.js} +1 -1
  61. package/web-app/dist/assets/{SystemSettingsPage-CyytfNo_.js → SystemSettingsPage-Bt7sf2Vu.js} +1 -1
  62. package/web-app/dist/assets/{TeamsPage-BTTe76W1.js → TeamsPage-B-v5j_8q.js} +1 -1
  63. package/web-app/dist/assets/{TemplatesPage-CIp3OTlL.js → TemplatesPage-bdqUL-LG.js} +1 -1
  64. package/web-app/dist/assets/{TerminalOutput-dX5L4ky-.js → TerminalOutput-CRHEXBwS.js} +1 -1
  65. package/web-app/dist/assets/{activity-uHXr2AaF.js → activity-C-ROkmXt.js} +1 -1
  66. package/web-app/dist/assets/{bell-D6RbxRMo.js → bell-DbiCBxQA.js} +1 -1
  67. package/web-app/dist/assets/{bot-D31w2Mu6.js → bot-DsIgE_qr.js} +1 -1
  68. package/web-app/dist/assets/{check-CoKJksbr.js → check-CkAdmHWP.js} +1 -1
  69. package/web-app/dist/assets/{chevron-left-Bjx7oX7s.js → chevron-left-CYTLivAo.js} +1 -1
  70. package/web-app/dist/assets/{circle-alert-DOAh8Jmi.js → circle-alert-BVIcAsFJ.js} +1 -1
  71. package/web-app/dist/assets/{clock-DgEXPNWH.js → clock-Dg3Rq0go.js} +1 -1
  72. package/web-app/dist/assets/{cloud-0dXJFzBv.js → cloud-CenrqHFl.js} +1 -1
  73. package/web-app/dist/assets/{code-xml-DytQ_bq7.js → code-xml-C2mvfoug.js} +1 -1
  74. package/web-app/dist/assets/{database-Qyjryb72.js → database-BKa0_eCb.js} +1 -1
  75. package/web-app/dist/assets/{dollar-sign-BzhTVy1e.js → dollar-sign-DfelUK1S.js} +1 -1
  76. package/web-app/dist/assets/{file-code-corner-DTdSOALw.js → file-code-corner-CZKWoHVg.js} +1 -1
  77. package/web-app/dist/assets/{file-plus-VdjOG1RK.js → file-plus-CZ-m1zMX.js} +1 -1
  78. package/web-app/dist/assets/{globe-BhxNOfyM.js → globe-S0kv_GwC.js} +1 -1
  79. package/web-app/dist/assets/{hammer-MQzrw8ee.js → hammer-BW1MDdu1.js} +1 -1
  80. package/web-app/dist/assets/{index-DaxrMbHf.js → index-ACEYsTc2.js} +3 -3
  81. package/web-app/dist/assets/{layers-C7nusTP-.js → layers-BrXHYZ-L.js} +1 -1
  82. package/web-app/dist/assets/{loader-circle-B1qBkgOY.js → loader-circle-DOdhKFx0.js} +1 -1
  83. package/web-app/dist/assets/{lock-BdkOxK2O.js → lock-BcFTyg8J.js} +1 -1
  84. package/web-app/dist/assets/{package-RVyK96Zn.js → package-D34cMVQG.js} +1 -1
  85. package/web-app/dist/assets/{plus-ntmwFRrp.js → plus-CEaMeBxL.js} +1 -1
  86. package/web-app/dist/assets/{refresh-cw-BJ39b_PP.js → refresh-cw-D2Rzxr5U.js} +1 -1
  87. package/web-app/dist/assets/{rotate-ccw-uPx_-3vz.js → rotate-ccw-C_-EjRLh.js} +1 -1
  88. package/web-app/dist/assets/{scroll-text-D9qvXkj-.js → scroll-text-BSSCMI_c.js} +1 -1
  89. package/web-app/dist/assets/{server-gs4xmD8Y.js → server-BKvLonWt.js} +1 -1
  90. package/web-app/dist/assets/{shield-alert-O2_YffGI.js → shield-alert-DbbQbPdW.js} +1 -1
  91. package/web-app/dist/assets/{thumbs-up-B3RVBhQx.js → thumbs-up-NoBD1fUR.js} +1 -1
  92. package/web-app/dist/assets/{trash-2-DEUt7qhq.js → trash-2-BXz8rAyd.js} +1 -1
  93. package/web-app/dist/assets/{trending-down-oyTns4UZ.js → trending-down-D8u535Re.js} +1 -1
  94. package/web-app/dist/assets/{trending-up-CdpZK0Oc.js → trending-up-q8k3kgiy.js} +1 -1
  95. package/web-app/dist/assets/{upload-D9sbjjC8.js → upload-Cp7_zeBI.js} +1 -1
  96. package/web-app/dist/assets/{usePolling-6ognrnyV.js → usePolling-CTHVdcuX.js} +1 -1
  97. package/web-app/dist/assets/{user-CBKc8QoI.js → user-DF7bfSf_.js} +1 -1
  98. package/web-app/dist/index.html +1 -1
  99. 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
- _, stderr = proc.communicate(timeout=DISPATCH_WAIT_SECONDS)
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
- if payload.get("pr") is True:
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. The Slack URL is
885
- re-checked here as defense in depth.
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
- if target.get("kind") == "slack":
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 build_sentry_brief(norm):
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._seen_deliveries[delivery_id] = True
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 _enqueue_job(self, spec, reply_target=None, pr=False):
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 "$@"