loki-mode 10.6.7 → 10.6.9
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 +44 -9
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/backlog.py +167 -36
- package/autonomy/lib/workspace.py +384 -0
- package/autonomy/lib/workspace_comment.py +91 -0
- package/autonomy/lib/worktree_prep.py +143 -0
- package/autonomy/loki +96 -3
- package/bin/loki +3 -3
- package/completions/_loki +2 -0
- package/completions/loki.bash +1 -2
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_start.py +26 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/WANG-PRINCIPLES-PLAN.md +1 -1
- package/docs/WORKSPACES.md +36 -0
- package/docs/alternative-installations.md +3 -1
- package/docs/control-plane-container.md +50 -0
- package/docs/dashboard-guide.md +4 -0
- package/docs/dev/architecture-reference.md +4 -0
- package/docs/dev/project-structure.md +1 -1
- package/docs/exit-codes.md +1 -0
- package/docs/slack.md +34 -0
- package/docs/trackers.md +27 -0
- package/docs/v10/BOARD.md +74 -14
- package/docs/v10/CONTROL-PLANE.md +17 -6
- package/docs/v10/D51-PHASE-B.md +3 -1
- package/docs/v10/D61-SPEED.md +11 -2
- package/docs/v10/D63-CARDS.md +354 -0
- package/docs/v10/DECISIONS.md +63 -0
- package/docs/v10/DEPS.md +6 -2
- package/docs/v10/E2E-D65.md +87 -0
- package/docs/v10/HANDOFF-NEXT-WEEK.md +10 -4
- package/docs/v10/PROGRESS.md +63 -0
- package/docs/v10/ROADMAP-D63.md +35 -0
- package/loki-ts/dist/loki.js +612 -556
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +76 -0
- package/mcp/tests/test_v10_tools.py +193 -0
- package/mcp/v10_tools.py +182 -0
- package/package.json +1 -1
- package/packages/control-plane/dist/server.js +131 -9
- package/packages/control-plane/drizzle/0001_group_id.sql +3 -0
- package/packages/control-plane/drizzle/meta/_journal.json +8 -1
- package/packages/control-plane/src/db/schema.ts +3 -0
- package/packages/control-plane/src/server/answer.ts +33 -0
- package/packages/control-plane/src/server/app.ts +22 -2
- package/packages/control-plane/src/server/auth.ts +51 -0
- package/packages/control-plane/src/server/runs.ts +6 -3
- package/packages/control-plane/src/server/serve.ts +13 -3
- package/packages/control-plane/src/shipper/discover.ts +29 -0
- package/packages/control-plane/ui/dist/assets/{index-BH3LtKcG.css → index-CFfrqCx2.css} +1 -1
- package/packages/control-plane/ui/dist/assets/index-Jt31nrK-.js +114 -0
- package/packages/control-plane/ui/dist/index.html +2 -2
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/web-app/dist/assets/{AdminPage-DyjnUcaP.js → AdminPage-CZKZLuWU.js} +1 -1
- package/web-app/dist/assets/{Avatar-CY0sQNao.js → Avatar-Dcy9kO2E.js} +1 -1
- package/web-app/dist/assets/{Badge-BmwTRu72.js → Badge-DUotUSyx.js} +1 -1
- package/web-app/dist/assets/{Button-BRf5aTlb.js → Button-CFz_zfMZ.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-RR94HuxS.js → CockpitPage-BhRw_oiM.js} +1 -1
- package/web-app/dist/assets/{ComparePage-CFWAuPRO.js → ComparePage-DfnsRTTC.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-4cDADrG-.js → ErrorBoundary-Dn0gUu3D.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-ChPFx2eM.js → EvidenceReceiptPanel-Co-xi282.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-B0TAjyeW.js → GitHubIssuesPanel-CoCpfexq.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-BbszEwW9.js → GitHubPRsPanel-Dg0Eg6F_.js} +1 -1
- package/web-app/dist/assets/{HomePage-DrBgzs1d.js → HomePage-BysxL4mV.js} +2 -2
- package/web-app/dist/assets/{LoginPage-Bu7b2DGz.js → LoginPage-CQ366p_d.js} +1 -1
- package/web-app/dist/assets/{MagicPage-BFEYp631.js → MagicPage-DD7YcbNq.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-eIe2naVR.js → MetricsPage-BYz49xQj.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-BVWYkNt9.js → NotFoundPage-CKs09MeO.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-zmxyu732.js → ProjectPage-Ci2BIA_D.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-Dq5NdCak.js → ProjectsPage-ClwMH0eu.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-CCrJOMe0.js → SettingsPage-Cm0rCX-y.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-n-Ipzp01.js → ShowcasePage-CxvESt8X.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-CP_rjYXK.js → SystemSettingsPage-W_CGYPOY.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-B_P9DepW.js → TeamsPage-feVvRvys.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-h-3A6PQT.js → TemplatesPage-C-pUWMmF.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-pD0nJ1BU.js → TerminalOutput-j5ibjKY3.js} +1 -1
- package/web-app/dist/assets/{activity-ChFwzT-d.js → activity-TTdVYMa_.js} +1 -1
- package/web-app/dist/assets/{bell-BX-eK8Gu.js → bell-DeoTFwpv.js} +1 -1
- package/web-app/dist/assets/{bot-fj5PkPtR.js → bot-B5SghxqU.js} +1 -1
- package/web-app/dist/assets/{check-BGAbHlvD.js → check-k8SzsdbE.js} +1 -1
- package/web-app/dist/assets/{chevron-left-BzFUxHgb.js → chevron-left-DHnEllFz.js} +1 -1
- package/web-app/dist/assets/{circle-alert-BQrg5DWl.js → circle-alert-DW_SLdcB.js} +1 -1
- package/web-app/dist/assets/{clock-CTu9lkVo.js → clock-Bmoaxy09.js} +1 -1
- package/web-app/dist/assets/{cloud-DrIENRiE.js → cloud-eg3tzXTh.js} +1 -1
- package/web-app/dist/assets/{code-xml-PaoVpfur.js → code-xml-VAfXihhD.js} +1 -1
- package/web-app/dist/assets/{database-DMmkAotn.js → database-CY3_-5aK.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-B2LQdBoX.js → dollar-sign-DYC0OLlg.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-Dfv1XXeR.js → file-code-corner-0X3SveKz.js} +1 -1
- package/web-app/dist/assets/{file-plus-D2Wzf2wd.js → file-plus-B_oh_ukl.js} +1 -1
- package/web-app/dist/assets/{globe-DxotVWt8.js → globe-F0tLA8WJ.js} +1 -1
- package/web-app/dist/assets/{hammer-Brfy5ZrJ.js → hammer-DrlvBmfW.js} +1 -1
- package/web-app/dist/assets/{index-BfhHMwi8.js → index-YihVaZAj.js} +3 -3
- package/web-app/dist/assets/{layers-DfxSWDvp.js → layers-CbJfn5UC.js} +1 -1
- package/web-app/dist/assets/{loader-circle-CxIrzNwx.js → loader-circle-CpmOggou.js} +1 -1
- package/web-app/dist/assets/{lock-m6nzhtpU.js → lock-BGJ6QWrI.js} +1 -1
- package/web-app/dist/assets/{package-ri9oE9Jc.js → package-BTmH4Xto.js} +1 -1
- package/web-app/dist/assets/{plus-DWeTAMVV.js → plus-mKQ7hz-8.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-BGMSpaMh.js → refresh-cw-vPetpLsm.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-DD1xBD_b.js → rotate-ccw-DiJ8GFhj.js} +1 -1
- package/web-app/dist/assets/{scroll-text-BCCR_AN9.js → scroll-text-DpPQIlQR.js} +1 -1
- package/web-app/dist/assets/{server-CmMWPIjS.js → server-CiaV4drx.js} +1 -1
- package/web-app/dist/assets/{shield-alert-BZZmBVEJ.js → shield-alert-BaaQqyco.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-OaxbNRXu.js → thumbs-up-2zNCKp0Q.js} +1 -1
- package/web-app/dist/assets/{trash-2-DmcSzTfr.js → trash-2-CiVDx5Co.js} +1 -1
- package/web-app/dist/assets/{trending-down-BmfXLykz.js → trending-down-tcxvw5BH.js} +1 -1
- package/web-app/dist/assets/{trending-up-Djec3jIq.js → trending-up-C2bv2_v_.js} +1 -1
- package/web-app/dist/assets/{upload-BN8dXIlF.js → upload-DS4VPUAp.js} +1 -1
- package/web-app/dist/assets/{usePolling-Cu4yi04I.js → usePolling-JggBcVln.js} +1 -1
- package/web-app/dist/assets/{user-IBI-k10o.js → user-CvavvOxM.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/packages/control-plane/ui/dist/assets/index-B6EiyukW.js +0 -114
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""loki workspace: run one issue across several repos (D51 Phase B, D65).
|
|
3
|
+
|
|
4
|
+
loki workspace list
|
|
5
|
+
loki workspace show <name>
|
|
6
|
+
loki workspace run <name> <issue-ref>
|
|
7
|
+
loki workspace status [<run-id>]
|
|
8
|
+
|
|
9
|
+
On by default (LOKI_WORKSPACES=0 disables). Workspaces are defined in loki.yaml:
|
|
10
|
+
|
|
11
|
+
workspaces:
|
|
12
|
+
shop:
|
|
13
|
+
repos:
|
|
14
|
+
- {repo: acme/api, path: ~/src/api, setup: "npm ci"}
|
|
15
|
+
- {repo: acme/web, path: ~/src/web, after: [acme/api]}
|
|
16
|
+
integration: {command: "make e2e", timeout_s: 900}
|
|
17
|
+
|
|
18
|
+
Each repo gets its own worktree and engine run (continue-and-report: a failing
|
|
19
|
+
repo does not stop the others; dependents of a failed repo are SKIPPED). After
|
|
20
|
+
every repo has finished, the integration command runs once with each run branch
|
|
21
|
+
checked out, and evidence lands in
|
|
22
|
+
.loki/workspaces/<name>/<run-id>/integration.json. Integration evidence is not
|
|
23
|
+
part of the Seal.
|
|
24
|
+
|
|
25
|
+
Repos with no pending `after` run in parallel, at most `concurrency` at once
|
|
26
|
+
(workspace key or LOKI_WORKSPACE_CONCURRENCY, default 2). SIGINT and SIGTERM
|
|
27
|
+
kill each recorded child process group.
|
|
28
|
+
|
|
29
|
+
Exit: 0 all repos ok and integration not failed; 3 when the only non-ok
|
|
30
|
+
outcomes are budget stops (child exit 3); 1 otherwise; 2 usage/config.
|
|
31
|
+
|
|
32
|
+
Test seam: LOKI_WORKSPACE_LAUNCHER replaces the engine launcher (called with
|
|
33
|
+
the issue ref or task text, cwd = the repo worktree).
|
|
34
|
+
"""
|
|
35
|
+
import datetime
|
|
36
|
+
import hashlib
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import signal
|
|
41
|
+
import subprocess
|
|
42
|
+
import sys
|
|
43
|
+
import threading
|
|
44
|
+
import time
|
|
45
|
+
|
|
46
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
47
|
+
sys.path.insert(0, HERE)
|
|
48
|
+
|
|
49
|
+
REPO_ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
|
50
|
+
REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
51
|
+
REF_RE = re.compile(r"^([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)#(\d+)$")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def say(msg):
|
|
55
|
+
print(msg, flush=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
BUDGET_EXIT = 3
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def enabled(env=None):
|
|
62
|
+
"""On by default; LOKI_WORKSPACES=0 disables."""
|
|
63
|
+
return (os.environ if env is None else env).get("LOKI_WORKSPACES") != "0"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def git(*args, cwd=None):
|
|
67
|
+
return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_workspaces(cfg=None):
|
|
71
|
+
"""Return (workspaces, errors) from loki.yaml (or the given cfg mapping)."""
|
|
72
|
+
if cfg is None:
|
|
73
|
+
import loki_yaml
|
|
74
|
+
cfg, _path, errors = loki_yaml.load()
|
|
75
|
+
if errors:
|
|
76
|
+
return {}, errors
|
|
77
|
+
return cfg.get("workspaces") or {}, []
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def order_repos(ws):
|
|
81
|
+
"""Return repo entries ordered so every `after` predecessor comes first.
|
|
82
|
+
|
|
83
|
+
Raises ValueError on an unknown `after` entry, a duplicate repo or a cycle.
|
|
84
|
+
"""
|
|
85
|
+
entries = list(ws.get("repos") or [])
|
|
86
|
+
names = [e.get("repo") for e in entries]
|
|
87
|
+
if len(set(names)) != len(names):
|
|
88
|
+
raise ValueError("duplicate repo in workspace")
|
|
89
|
+
for e in entries:
|
|
90
|
+
if not isinstance(e.get("repo"), str) or not REPO_RE.match(e["repo"]):
|
|
91
|
+
raise ValueError("repo must be owner/name, got %r" % (e.get("repo"),))
|
|
92
|
+
for dep in e.get("after") or []:
|
|
93
|
+
if dep not in names:
|
|
94
|
+
raise ValueError("%s: unknown after repo %s" % (e["repo"], dep))
|
|
95
|
+
done, ordered, remaining = set(), [], entries
|
|
96
|
+
while remaining:
|
|
97
|
+
ready = [e for e in remaining if all(d in done for d in e.get("after") or [])]
|
|
98
|
+
if not ready:
|
|
99
|
+
raise ValueError("cycle in `after`: " + ", ".join(e["repo"] for e in remaining))
|
|
100
|
+
for e in ready:
|
|
101
|
+
ordered.append(e)
|
|
102
|
+
done.add(e["repo"])
|
|
103
|
+
remaining = [e for e in remaining if e not in ready]
|
|
104
|
+
return ordered
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _slug(repo):
|
|
108
|
+
return repo.replace("/", "__")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def source_checkout(entry):
|
|
112
|
+
"""Local checkout for a repo entry: its `path`, else clone into ~/.loki/repos/<slug>."""
|
|
113
|
+
if entry.get("path"):
|
|
114
|
+
return os.path.abspath(os.path.expanduser(entry["path"]))
|
|
115
|
+
dest = os.path.join(os.path.expanduser("~"), ".loki", "repos", _slug(entry["repo"]))
|
|
116
|
+
if not os.path.isdir(os.path.join(dest, ".git")):
|
|
117
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
118
|
+
r = subprocess.run(["git", "clone", "https://github.com/%s.git" % entry["repo"], dest],
|
|
119
|
+
capture_output=True, text=True)
|
|
120
|
+
if r.returncode != 0:
|
|
121
|
+
raise RuntimeError("clone failed: " + (r.stderr.strip().splitlines() or ["git error"])[-1])
|
|
122
|
+
return dest
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def prepare(source, dest, branch, setup):
|
|
126
|
+
"""Make a worktree via worktree_prep when available, else a minimal `git worktree add`."""
|
|
127
|
+
try:
|
|
128
|
+
import worktree_prep
|
|
129
|
+
res = worktree_prep.prepare_worktree(source, dest, branch, setup=setup)
|
|
130
|
+
return res["path"]
|
|
131
|
+
except ImportError:
|
|
132
|
+
pass
|
|
133
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
134
|
+
r = git("worktree", "add", "-B", branch, dest, "HEAD", cwd=source)
|
|
135
|
+
if r.returncode != 0:
|
|
136
|
+
raise RuntimeError("worktree: " + (r.stderr.strip().splitlines() or ["git error"])[-1])
|
|
137
|
+
if setup:
|
|
138
|
+
s = subprocess.run(setup, shell=True, cwd=dest, capture_output=True, text=True)
|
|
139
|
+
if s.returncode != 0:
|
|
140
|
+
raise RuntimeError("setup failed: " + (s.stderr.strip().splitlines() or ["no output"])[-1])
|
|
141
|
+
return dest
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def task_for(entry, ref, siblings):
|
|
145
|
+
"""(launcher argument, extra env) for a repo: the issue ref when it lives here, else task text."""
|
|
146
|
+
m = REF_RE.match(ref)
|
|
147
|
+
if m and m.group(1) == entry["repo"]:
|
|
148
|
+
return ref, {}
|
|
149
|
+
text = ("Implement the change described by %s for the repo %s. Sibling worktrees (read-only "
|
|
150
|
+
"context, do not edit): %s" % (ref, entry["repo"], ", ".join(siblings) or "none"))
|
|
151
|
+
return text, {"LOKI_E10_TASK_TEXT": text}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def run_integration(ws, run_dir, worktrees, heads):
|
|
155
|
+
"""Run the integration command once; return the evidence dict."""
|
|
156
|
+
integ = ws.get("integration") or {}
|
|
157
|
+
cmd = integ.get("command")
|
|
158
|
+
ev = {"heads": heads, "exit_code": None, "log_sha256": None, "status": "not_configured",
|
|
159
|
+
"seal": False, "note": "integration evidence is not part of the Seal"}
|
|
160
|
+
if not cmd:
|
|
161
|
+
return ev
|
|
162
|
+
timeout = integ.get("timeout_s", 900)
|
|
163
|
+
env = dict(os.environ)
|
|
164
|
+
for repo, wt in worktrees.items():
|
|
165
|
+
env["LOKI_WS_DIR_" + re.sub(r"[^A-Za-z0-9]", "_", repo).upper()] = wt
|
|
166
|
+
log_path = os.path.join(run_dir, "integration.log")
|
|
167
|
+
with open(log_path, "wb") as lf:
|
|
168
|
+
p = subprocess.Popen(cmd, shell=True, cwd=run_dir, env=env, stdout=lf, stderr=subprocess.STDOUT,
|
|
169
|
+
stdin=subprocess.DEVNULL, start_new_session=True)
|
|
170
|
+
try:
|
|
171
|
+
rc = p.wait(timeout=timeout)
|
|
172
|
+
ev["status"] = "passed" if rc == 0 else "failed"
|
|
173
|
+
except subprocess.TimeoutExpired:
|
|
174
|
+
try:
|
|
175
|
+
os.killpg(p.pid, signal.SIGKILL)
|
|
176
|
+
except OSError:
|
|
177
|
+
pass
|
|
178
|
+
p.wait()
|
|
179
|
+
rc = 124
|
|
180
|
+
ev["status"] = "failed"
|
|
181
|
+
ev["note"] += "; timeout after %ss" % timeout
|
|
182
|
+
ev["exit_code"] = rc
|
|
183
|
+
with open(log_path, "rb") as f:
|
|
184
|
+
ev["log_sha256"] = hashlib.sha256(f.read()).hexdigest()
|
|
185
|
+
return ev
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run_workspace(name, ref, workspaces, base_dir=None, launcher=None):
|
|
189
|
+
"""Run the workspace; return (exit_code, evidence_path_or_None)."""
|
|
190
|
+
ws = workspaces.get(name)
|
|
191
|
+
if ws is None:
|
|
192
|
+
say("workspace: unknown workspace %r (known: %s)" % (name, ", ".join(sorted(workspaces)) or "none"))
|
|
193
|
+
return 2, None
|
|
194
|
+
try:
|
|
195
|
+
ordered = order_repos(ws)
|
|
196
|
+
except ValueError as e:
|
|
197
|
+
say("workspace: %s: %s" % (name, e))
|
|
198
|
+
return 2, None
|
|
199
|
+
if not ordered:
|
|
200
|
+
say("workspace: %s has no repos" % name)
|
|
201
|
+
return 2, None
|
|
202
|
+
|
|
203
|
+
base_dir = base_dir or os.getcwd()
|
|
204
|
+
run_id = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
205
|
+
run_dir = os.path.join(base_dir, ".loki", "workspaces", name, run_id)
|
|
206
|
+
os.makedirs(run_dir, exist_ok=True)
|
|
207
|
+
launcher = launcher or os.environ.get("LOKI_WORKSPACE_LAUNCHER") or os.path.join(REPO_ROOT, "bin", "loki")
|
|
208
|
+
child_env_base = dict(os.environ, LOKI_ENGINE="v10", LOKI_NO_BROWSER="1")
|
|
209
|
+
|
|
210
|
+
outcome, worktrees, heads, kind = {}, {}, {}, {}
|
|
211
|
+
running = {} # repo -> (Popen, log file handle)
|
|
212
|
+
try:
|
|
213
|
+
conc = max(1, int(os.environ.get("LOKI_WORKSPACE_CONCURRENCY") or ws.get("concurrency") or 2))
|
|
214
|
+
except (TypeError, ValueError):
|
|
215
|
+
conc = 2
|
|
216
|
+
|
|
217
|
+
def stop(*_):
|
|
218
|
+
for p, _lf in running.values():
|
|
219
|
+
try:
|
|
220
|
+
os.killpg(p.pid, signal.SIGTERM)
|
|
221
|
+
except OSError:
|
|
222
|
+
pass
|
|
223
|
+
sys.exit(130)
|
|
224
|
+
if threading.current_thread() is threading.main_thread():
|
|
225
|
+
signal.signal(signal.SIGTERM, stop)
|
|
226
|
+
signal.signal(signal.SIGINT, stop)
|
|
227
|
+
|
|
228
|
+
index = {e["repo"]: i for i, e in enumerate(ordered)}
|
|
229
|
+
pending = list(ordered)
|
|
230
|
+
while pending or running:
|
|
231
|
+
for entry in list(pending):
|
|
232
|
+
repo = entry["repo"]
|
|
233
|
+
deps = entry.get("after") or []
|
|
234
|
+
failed_dep = [d for d in deps if d in kind and kind[d] != "ok"]
|
|
235
|
+
if failed_dep:
|
|
236
|
+
pending.remove(entry)
|
|
237
|
+
outcome[repo] = "SKIPPED (predecessor failed: %s)" % ", ".join(failed_dep)
|
|
238
|
+
kind[repo] = "budget" if all(kind[d] == "budget" for d in failed_dep) else "fail"
|
|
239
|
+
say("workspace: %s %s" % (repo, outcome[repo]))
|
|
240
|
+
for entry in list(pending):
|
|
241
|
+
repo = entry["repo"]
|
|
242
|
+
if len(running) >= conc:
|
|
243
|
+
break
|
|
244
|
+
if not all(kind.get(d) == "ok" for d in entry.get("after") or []):
|
|
245
|
+
continue
|
|
246
|
+
pending.remove(entry)
|
|
247
|
+
try:
|
|
248
|
+
src = source_checkout(entry)
|
|
249
|
+
dest = os.path.join(run_dir, "worktrees", _slug(repo))
|
|
250
|
+
wt = prepare(src, dest, "loki/ws-%s-%d" % (run_id, index[repo]), entry.get("setup"))
|
|
251
|
+
except (RuntimeError, OSError) as e:
|
|
252
|
+
outcome[repo], kind[repo] = "FAILED: %s" % e, "fail"
|
|
253
|
+
say("workspace: %s %s" % (repo, outcome[repo]))
|
|
254
|
+
continue
|
|
255
|
+
worktrees[repo] = wt
|
|
256
|
+
siblings = [os.path.join(run_dir, "worktrees", _slug(o["repo"])) for o in ordered if o["repo"] != repo]
|
|
257
|
+
arg, extra = task_for(entry, ref, siblings)
|
|
258
|
+
lf = open(os.path.join(run_dir, "%s.log" % _slug(repo)), "w")
|
|
259
|
+
say("workspace: %s running" % repo)
|
|
260
|
+
p = subprocess.Popen([launcher, arg], cwd=wt, env=dict(child_env_base, **extra), stdout=lf,
|
|
261
|
+
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
|
|
262
|
+
running[repo] = (p, lf)
|
|
263
|
+
for repo, (p, lf) in list(running.items()):
|
|
264
|
+
rc = p.poll()
|
|
265
|
+
if rc is None:
|
|
266
|
+
continue
|
|
267
|
+
lf.close()
|
|
268
|
+
running.pop(repo)
|
|
269
|
+
if rc == 0:
|
|
270
|
+
outcome[repo], kind[repo] = "ok", "ok"
|
|
271
|
+
else:
|
|
272
|
+
outcome[repo] = "FAILED: exit %d" % rc
|
|
273
|
+
kind[repo] = "budget" if rc == BUDGET_EXIT else "fail"
|
|
274
|
+
say("workspace: %s %s" % (repo, outcome[repo]))
|
|
275
|
+
if running:
|
|
276
|
+
time.sleep(0.05)
|
|
277
|
+
|
|
278
|
+
for repo, wt in worktrees.items():
|
|
279
|
+
r = git("rev-parse", "HEAD", cwd=wt)
|
|
280
|
+
heads[repo] = r.stdout.strip() if r.returncode == 0 else None
|
|
281
|
+
|
|
282
|
+
ev = run_integration(ws, run_dir, worktrees, heads)
|
|
283
|
+
ev["outcomes"] = outcome
|
|
284
|
+
ev_path = os.path.join(run_dir, "integration.json")
|
|
285
|
+
tmp = ev_path + ".tmp"
|
|
286
|
+
with open(tmp, "w") as f:
|
|
287
|
+
json.dump(ev, f, indent=2, sort_keys=True)
|
|
288
|
+
os.replace(tmp, ev_path)
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
import workspace_comment
|
|
292
|
+
workspace_comment.post_comments(name, run_id, run_dir, worktrees, ev, say)
|
|
293
|
+
except Exception as e: # commenting must never fail the run
|
|
294
|
+
say("workspace: commenting skipped (%s)" % type(e).__name__)
|
|
295
|
+
|
|
296
|
+
say("")
|
|
297
|
+
say("Summary: workspace %s (%s)" % (name, run_id))
|
|
298
|
+
for repo, res in outcome.items():
|
|
299
|
+
say("%-30s %s" % (repo, res))
|
|
300
|
+
say("%-30s %s" % ("integration", ev["status"]))
|
|
301
|
+
if ev["status"] == "failed" or any(k == "fail" for k in kind.values()):
|
|
302
|
+
return 1, ev_path
|
|
303
|
+
return (3 if any(k == "budget" for k in kind.values()) else 0), ev_path
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _runs(base_dir):
|
|
307
|
+
"""Yield (workspace, run_id, evidence dict) for every recorded run, oldest first."""
|
|
308
|
+
root = os.path.join(base_dir, ".loki", "workspaces")
|
|
309
|
+
if not os.path.isdir(root):
|
|
310
|
+
return
|
|
311
|
+
for name in sorted(os.listdir(root)):
|
|
312
|
+
for run_id in sorted(os.listdir(os.path.join(root, name))):
|
|
313
|
+
path = os.path.join(root, name, run_id, "integration.json")
|
|
314
|
+
try:
|
|
315
|
+
with open(path) as f:
|
|
316
|
+
yield name, run_id, json.load(f)
|
|
317
|
+
except (OSError, ValueError):
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def status(base_dir, run_id=None, say=say):
|
|
322
|
+
"""Print per-repo rows from integration.json for one run, or for every run."""
|
|
323
|
+
found = [r for r in _runs(base_dir) if run_id in (None, r[1])]
|
|
324
|
+
if not found:
|
|
325
|
+
say("workspace: no such run %s" % run_id if run_id else "no workspace runs recorded")
|
|
326
|
+
return 0 if run_id is None else 1
|
|
327
|
+
for name, rid, ev in found:
|
|
328
|
+
say("%s %s" % (name, rid))
|
|
329
|
+
for repo, res in sorted((ev.get("outcomes") or {}).items()):
|
|
330
|
+
say("%-30s %s" % (repo, res))
|
|
331
|
+
say("%-30s %s" % ("integration", ev.get("status")))
|
|
332
|
+
return 0
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def show(name, workspaces, say=say):
|
|
336
|
+
ws = workspaces.get(name)
|
|
337
|
+
if ws is None:
|
|
338
|
+
say("workspace: unknown workspace %r (known: %s)" % (name, ", ".join(sorted(workspaces)) or "none"))
|
|
339
|
+
return 2
|
|
340
|
+
say("%s (concurrency %s)" % (name, ws.get("concurrency") or 2))
|
|
341
|
+
for e in ws.get("repos") or []:
|
|
342
|
+
extra = "; after: " + ", ".join(e["after"]) if e.get("after") else ""
|
|
343
|
+
say("%s path: %s%s" % (e.get("repo", "?"), e.get("path") or "(clone)", extra))
|
|
344
|
+
integ = ws.get("integration") or {}
|
|
345
|
+
say("integration: %s" % (integ.get("command") or "not configured"))
|
|
346
|
+
return 0
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def main(argv):
|
|
350
|
+
if not enabled():
|
|
351
|
+
say("loki workspace is disabled (LOKI_WORKSPACES=0). Unset it to use workspaces.")
|
|
352
|
+
return 2
|
|
353
|
+
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
354
|
+
say("Usage: loki workspace list | show <name> | run <name> <owner/repo#N | task text> | status [<run-id>]")
|
|
355
|
+
return 0 if argv else 2
|
|
356
|
+
try:
|
|
357
|
+
workspaces, errors = load_workspaces()
|
|
358
|
+
except Exception as e: # CannotCheck or parse failure
|
|
359
|
+
say("workspace: cannot read loki.yaml: %s" % e)
|
|
360
|
+
return 2
|
|
361
|
+
if errors:
|
|
362
|
+
for e in errors:
|
|
363
|
+
say("workspace: config error: %s" % e)
|
|
364
|
+
return 2
|
|
365
|
+
cmd = argv[0]
|
|
366
|
+
if cmd == "list":
|
|
367
|
+
if not workspaces:
|
|
368
|
+
say("no workspaces defined in loki.yaml")
|
|
369
|
+
for n, ws in sorted(workspaces.items()):
|
|
370
|
+
say("%s: %s" % (n, ", ".join(e.get("repo", "?") for e in ws.get("repos") or [])))
|
|
371
|
+
return 0
|
|
372
|
+
if cmd == "show" and len(argv) == 2:
|
|
373
|
+
return show(argv[1], workspaces)
|
|
374
|
+
if cmd == "status" and len(argv) <= 2:
|
|
375
|
+
return status(os.getcwd(), argv[1] if len(argv) == 2 else None)
|
|
376
|
+
if cmd == "run" and len(argv) >= 3:
|
|
377
|
+
rc, _ = run_workspace(argv[1], " ".join(argv[2:]), workspaces)
|
|
378
|
+
return rc
|
|
379
|
+
say("Usage: loki workspace list | show <name> | run <name> <issue-ref> | status [<run-id>]")
|
|
380
|
+
return 2
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
if __name__ == "__main__":
|
|
384
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Post the workspace integration result as one PR comment per repo PR (D51-B11).
|
|
2
|
+
|
|
3
|
+
No LLM. Uses the `gh` CLI with the caller's own environment, so GH_TOKEN or
|
|
4
|
+
GITHUB_TOKEN is read by gh itself and never placed in argv, the comment body
|
|
5
|
+
or any log line. Commenting is best effort: it never raises and never changes
|
|
6
|
+
the run's exit code. Opt out with LOKI_WORKSPACE_COMMENT=0.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
GH_TIMEOUT_S = 60
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verdict(ev):
|
|
17
|
+
"""Map integration evidence to a label. Only a clean exit 0 reads as PASSED."""
|
|
18
|
+
status = ev.get("status")
|
|
19
|
+
if "timeout" in str(ev.get("note") or "").lower():
|
|
20
|
+
return "TIMEOUT"
|
|
21
|
+
if status == "passed" and ev.get("exit_code") == 0:
|
|
22
|
+
return "PASSED"
|
|
23
|
+
if status == "not_configured":
|
|
24
|
+
return "NOT CONFIGURED"
|
|
25
|
+
return "FAILED"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render(ws_name, run_id, repo, ev):
|
|
29
|
+
heads = ev.get("heads") or {}
|
|
30
|
+
lines = ["Loki workspace `%s` run `%s`" % (ws_name, run_id), "",
|
|
31
|
+
"Integration: **%s** (exit code %s)" % (verdict(ev), ev.get("exit_code")), "",
|
|
32
|
+
"| Repo | Outcome | Head SHA |", "| --- | --- | --- |"]
|
|
33
|
+
outcomes = ev.get("outcomes") or {}
|
|
34
|
+
for r in sorted(heads.keys() | outcomes.keys()):
|
|
35
|
+
out = outcomes.get(r, "unknown")
|
|
36
|
+
out = "ok" if out == "ok" else str(out).replace("|", "/")
|
|
37
|
+
mark = " (this PR)" if r == repo else ""
|
|
38
|
+
lines.append("| %s%s | %s | %s |" % (r, mark, out, heads.get(r) or "none"))
|
|
39
|
+
if ev.get("log_sha256"):
|
|
40
|
+
lines += ["", "Integration log sha256: `%s`" % ev["log_sha256"]]
|
|
41
|
+
lines += ["", "Integration evidence is not part of the Seal."]
|
|
42
|
+
return "\n".join(lines) + "\n"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _gh(args, cwd):
|
|
46
|
+
return subprocess.run(["gh"] + args, cwd=cwd, stdin=subprocess.DEVNULL, capture_output=True,
|
|
47
|
+
text=True, timeout=GH_TIMEOUT_S)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _branch(wt):
|
|
51
|
+
r = subprocess.run(["git", "-C", wt, "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True,
|
|
52
|
+
timeout=GH_TIMEOUT_S)
|
|
53
|
+
b = r.stdout.strip()
|
|
54
|
+
return b if r.returncode == 0 and b and b != "HEAD" else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def post_comments(ws_name, run_id, run_dir, worktrees, ev, say):
|
|
58
|
+
"""One comment per repo that has an open PR on its run branch. Never raises."""
|
|
59
|
+
if os.environ.get("LOKI_WORKSPACE_COMMENT") == "0":
|
|
60
|
+
return 0
|
|
61
|
+
posted = 0
|
|
62
|
+
try:
|
|
63
|
+
for repo, wt in sorted(worktrees.items()):
|
|
64
|
+
try:
|
|
65
|
+
branch = _branch(wt)
|
|
66
|
+
if not branch:
|
|
67
|
+
continue
|
|
68
|
+
r = _gh(["pr", "list", "--repo", repo, "--head", branch, "--state", "open",
|
|
69
|
+
"--json", "number", "--jq", ".[0].number"], wt)
|
|
70
|
+
num = r.stdout.strip()
|
|
71
|
+
if r.returncode != 0 or not re.fullmatch(r"\d+", num):
|
|
72
|
+
continue
|
|
73
|
+
fd, body = tempfile.mkstemp(prefix="comment-", suffix=".md", dir=run_dir) # mode 0600
|
|
74
|
+
try:
|
|
75
|
+
with os.fdopen(fd, "w") as f:
|
|
76
|
+
f.write(render(ws_name, run_id, repo, ev))
|
|
77
|
+
c = _gh(["pr", "comment", num, "--repo", repo, "--body-file", body], wt)
|
|
78
|
+
finally:
|
|
79
|
+
try:
|
|
80
|
+
os.unlink(body)
|
|
81
|
+
except OSError:
|
|
82
|
+
pass
|
|
83
|
+
if c.returncode == 0:
|
|
84
|
+
posted += 1
|
|
85
|
+
else:
|
|
86
|
+
say("workspace: comment on %s#%s failed (gh exit %d)" % (repo, num, c.returncode))
|
|
87
|
+
except (OSError, subprocess.SubprocessError, ValueError) as e:
|
|
88
|
+
say("workspace: comment for %s skipped (%s)" % (repo, type(e).__name__))
|
|
89
|
+
except Exception as e: # never fail the run because commenting failed
|
|
90
|
+
say("workspace: commenting skipped (%s)" % type(e).__name__)
|
|
91
|
+
return posted
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared worktree substrate (D51 Phase B, D61 slice 7).
|
|
3
|
+
|
|
4
|
+
prepare_worktree() creates a git worktree for a run from a clean start point,
|
|
5
|
+
never carrying the source checkout's uncommitted edits, and prepares
|
|
6
|
+
dependencies (setup command, else copy-on-write copy of ignored dep dirs).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import fcntl
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
DEP_DIRS = ("node_modules", ".venv", "venv", "vendor")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _git(repo, *args, check=True):
|
|
20
|
+
return subprocess.run(
|
|
21
|
+
["git", "-C", repo, *args], capture_output=True, text=True, check=check
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _slug(repo):
|
|
26
|
+
real = os.path.realpath(repo)
|
|
27
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "-", real).strip("-")[-120:] or "repo"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _lock_path(repo):
|
|
31
|
+
d = os.path.join(os.path.expanduser("~"), ".loki", "repos")
|
|
32
|
+
os.makedirs(d, exist_ok=True)
|
|
33
|
+
return os.path.join(d, _slug(repo) + ".lock")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _start_point(repo, base):
|
|
37
|
+
if base:
|
|
38
|
+
return base
|
|
39
|
+
if _git(repo, "remote", check=False).stdout.strip():
|
|
40
|
+
_git(repo, "fetch", "--quiet", check=False)
|
|
41
|
+
for ref in ("origin/HEAD", "HEAD"):
|
|
42
|
+
probe = _git(repo, "rev-parse", "--verify", "--quiet", ref + "^{commit}", check=False)
|
|
43
|
+
if probe.returncode == 0:
|
|
44
|
+
return ref
|
|
45
|
+
raise RuntimeError("no start point: repository has no commits")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _left_out_changes(repo):
|
|
49
|
+
out = _git(repo, "status", "--porcelain", check=False).stdout
|
|
50
|
+
return len([line for line in out.splitlines() if line.strip()])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cow_copy(src, dst):
|
|
54
|
+
if sys.platform == "darwin":
|
|
55
|
+
cmds = [["cp", "-c", "-R", src, dst]]
|
|
56
|
+
else:
|
|
57
|
+
cmds = [["cp", "-R", "--reflink=auto", src, dst]]
|
|
58
|
+
cmds.append(["cp", "-R", src, dst])
|
|
59
|
+
for cmd in cmds:
|
|
60
|
+
if os.path.lexists(dst):
|
|
61
|
+
subprocess.run(["rm", "-rf", dst], check=False)
|
|
62
|
+
if subprocess.run(cmd, capture_output=True).returncode == 0:
|
|
63
|
+
return True
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _escapes_source(path):
|
|
68
|
+
"""True if a dep dir has an absolute symlink or an editable finder."""
|
|
69
|
+
for root, dirs, files in os.walk(path):
|
|
70
|
+
for name in dirs + files:
|
|
71
|
+
p = os.path.join(root, name)
|
|
72
|
+
if os.path.islink(p):
|
|
73
|
+
if os.path.isabs(os.readlink(p)):
|
|
74
|
+
return True
|
|
75
|
+
elif name.endswith(".pth") or name.startswith("__editable__"):
|
|
76
|
+
return True
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _copy_deps(source, dest, start):
|
|
81
|
+
copied = 0
|
|
82
|
+
for name in DEP_DIRS:
|
|
83
|
+
s = os.path.join(source, name)
|
|
84
|
+
if not os.path.isdir(s) or os.path.islink(s):
|
|
85
|
+
continue
|
|
86
|
+
if _git(source, "check-ignore", "-q", name + "/", check=False).returncode != 0:
|
|
87
|
+
continue
|
|
88
|
+
if _git(source, "ls-tree", "-d", start, name, check=False).stdout.strip():
|
|
89
|
+
continue
|
|
90
|
+
if _escapes_source(s):
|
|
91
|
+
raise RuntimeError("deps: copy unsafe, declare setup")
|
|
92
|
+
if not _cow_copy(s, os.path.join(dest, name)):
|
|
93
|
+
raise RuntimeError("deps: copy failed, declare setup")
|
|
94
|
+
copied += 1
|
|
95
|
+
return copied
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def prepare_worktree(source_repo, dest, branch, setup=None, base=None):
|
|
99
|
+
source_repo = os.path.abspath(source_repo)
|
|
100
|
+
dest = os.path.abspath(dest)
|
|
101
|
+
lock = open(_lock_path(source_repo), "w")
|
|
102
|
+
try:
|
|
103
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
104
|
+
left_out = _left_out_changes(source_repo)
|
|
105
|
+
start = _start_point(source_repo, base)
|
|
106
|
+
base_sha = _git(source_repo, "rev-parse", start + "^{commit}").stdout.strip()
|
|
107
|
+
_git(source_repo, "worktree", "add", "-b", branch, dest, base_sha)
|
|
108
|
+
finally:
|
|
109
|
+
fcntl.flock(lock, fcntl.LOCK_UN)
|
|
110
|
+
lock.close()
|
|
111
|
+
|
|
112
|
+
deps = "none"
|
|
113
|
+
if setup:
|
|
114
|
+
r = subprocess.run(setup, shell=True, cwd=dest)
|
|
115
|
+
if r.returncode != 0:
|
|
116
|
+
raise RuntimeError("deps: setup command failed (exit %d)" % r.returncode)
|
|
117
|
+
deps = "setup"
|
|
118
|
+
elif _copy_deps(source_repo, dest, base_sha):
|
|
119
|
+
deps = "cow" if sys.platform in ("darwin", "linux") else "copy"
|
|
120
|
+
return {"path": dest, "base_sha": base_sha, "deps": deps, "left_out_changes": left_out}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main(argv=None):
|
|
124
|
+
import argparse
|
|
125
|
+
|
|
126
|
+
p = argparse.ArgumentParser(description="Prepare an isolated git worktree")
|
|
127
|
+
p.add_argument("source")
|
|
128
|
+
p.add_argument("dest")
|
|
129
|
+
p.add_argument("branch")
|
|
130
|
+
p.add_argument("--setup", default=None)
|
|
131
|
+
p.add_argument("--base", default=None)
|
|
132
|
+
a = p.parse_args(argv)
|
|
133
|
+
try:
|
|
134
|
+
res = prepare_worktree(a.source, a.dest, a.branch, a.setup, a.base)
|
|
135
|
+
except (RuntimeError, subprocess.CalledProcessError) as e:
|
|
136
|
+
print(json.dumps({"error": str(e)}))
|
|
137
|
+
return 1
|
|
138
|
+
print(json.dumps(res))
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
sys.exit(main())
|