loki-mode 11.6.0 → 11.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/backlog.py +48 -4
- package/autonomy/lib/chat.sh +60 -3
- package/autonomy/lib/loki_yaml.py +8 -2
- package/autonomy/lib/workspace.py +267 -11
- package/autonomy/loki +315 -21
- package/autonomy/trigger-server.py +454 -4
- package/autonomy/verify.sh +9 -0
- package/completions/_loki +4 -0
- package/completions/loki.bash +20 -2
- package/dashboard/__init__.py +1 -1
- package/docs/CLI-REFERENCE.md +2 -1
- package/docs/DEPLOY-PLAN.md +17 -8
- package/docs/EVALUATING.md +8 -0
- package/docs/INSTALLATION.md +1 -1
- package/docs/WORKSPACES.md +15 -2
- package/docs/dev/architecture-reference.md +8 -0
- package/docs/sentry-intake.md +33 -0
- package/docs/v10/CLI-MODERN.md +1 -1
- package/docs/v10/D61-SPEED.md +12 -0
- package/docs/v10/PROGRESS.md +10 -0
- package/loki-ts/dist/loki.js +1055 -1004
- package/mcp/__init__.py +1 -1
- package/package.json +2 -2
- package/packages/control-plane/dist/server.js +789 -634
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/schemas/loki-yaml.schema.json +5 -2
- package/src/policies/engine.js +41 -2
- package/src/policies/types.js +49 -0
- package/web-app/dist/assets/{AdminPage-DhXdJNQk.js → AdminPage-BnIg9ilA.js} +1 -1
- package/web-app/dist/assets/{Avatar-ixVj76fM.js → Avatar-0BTVsQ2A.js} +1 -1
- package/web-app/dist/assets/{Badge-BGMSp22s.js → Badge-Bbq8CXgn.js} +1 -1
- package/web-app/dist/assets/{Button-pYJc32tG.js → Button-CKD4n0ld.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-C1JBBouA.js → CockpitPage-DJhLNLAz.js} +1 -1
- package/web-app/dist/assets/{ComparePage-qD0NV4RK.js → ComparePage-Di2akuII.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-C-yxO58j.js → ErrorBoundary-9lhsYG-z.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-ByPzuE1S.js → EvidenceReceiptPanel-CQDGXvDr.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-BNv_2BEJ.js → GitHubIssuesPanel-Y_w2k9Ej.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-CuLZQ9CS.js → GitHubPRsPanel-pMSQedCv.js} +1 -1
- package/web-app/dist/assets/{HomePage-CS_FW8Je.js → HomePage-CIAkiGdt.js} +2 -2
- package/web-app/dist/assets/{LoginPage-DwiNf1Wm.js → LoginPage-BES4Pk6k.js} +1 -1
- package/web-app/dist/assets/{MagicPage-CmQybLPZ.js → MagicPage-B154fMhi.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-D9ERdpv8.js → MetricsPage-BcNyaiUO.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-D9ccgdbF.js → NotFoundPage-D28FqYxG.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-B9Jslmgv.js → ProjectPage-C_O3u8Jx.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-DnU99Go2.js → ProjectsPage-uqdahuPA.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-DcTyx6wo.js → SettingsPage-DLvVA9OZ.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-jEY9ghSr.js → ShowcasePage-BAZP8vsm.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-CSGltW-8.js → SystemSettingsPage-DPYZkLWh.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-gUuJXb9l.js → TeamsPage-BgSyb5Pd.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-37Zhe--v.js → TemplatesPage-D_Jh5VU1.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-B09hRwaR.js → TerminalOutput-4v8poDKb.js} +1 -1
- package/web-app/dist/assets/{activity-q_4MY9J0.js → activity-CE2b6kdw.js} +1 -1
- package/web-app/dist/assets/{bell-X8OC_PFP.js → bell-CxJDlLSR.js} +1 -1
- package/web-app/dist/assets/{bot-C-N1PUOD.js → bot-C2WuK4zw.js} +1 -1
- package/web-app/dist/assets/{check-B2c76Jmp.js → check-81epVldw.js} +1 -1
- package/web-app/dist/assets/{chevron-left-BF76YLNw.js → chevron-left-B6EXh_3C.js} +1 -1
- package/web-app/dist/assets/{circle-alert-AeFKBD8h.js → circle-alert-0iqoo-iy.js} +1 -1
- package/web-app/dist/assets/{clock-CiXRf-FH.js → clock-CFrBpNUp.js} +1 -1
- package/web-app/dist/assets/{cloud-DKQ8SRJ5.js → cloud-BBSFhUT_.js} +1 -1
- package/web-app/dist/assets/{code-xml-DEb6jXWP.js → code-xml-BCU2V1rq.js} +1 -1
- package/web-app/dist/assets/{database-6W7e-E9c.js → database-D2hvO52K.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-Ds2GOuyW.js → dollar-sign-CXFA9b6T.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-CZU0jg52.js → file-code-corner-CqTCEEfS.js} +1 -1
- package/web-app/dist/assets/{file-plus-DTVYnXCN.js → file-plus-DYFbkGbs.js} +1 -1
- package/web-app/dist/assets/{globe-0mUzKYDe.js → globe-DpYHLobk.js} +1 -1
- package/web-app/dist/assets/{hammer-BMHZg3YX.js → hammer-CZ97SEYo.js} +1 -1
- package/web-app/dist/assets/{index-BvOTvjax.js → index-DzswgpRo.js} +3 -3
- package/web-app/dist/assets/{layers-Df7TdcWB.js → layers-2Tq0edx4.js} +1 -1
- package/web-app/dist/assets/{loader-circle-kPsVfrSY.js → loader-circle-BFP4kUX-.js} +1 -1
- package/web-app/dist/assets/{lock-CXYcbEc5.js → lock-DkKdZH4d.js} +1 -1
- package/web-app/dist/assets/{package-Cqhua1ID.js → package-ChOGD8yf.js} +1 -1
- package/web-app/dist/assets/{plus-lseXhsn-.js → plus-hggB1o6b.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-OZriI6gD.js → refresh-cw-DYwVLOeF.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-CKCfjPg8.js → rotate-ccw-CUdY506q.js} +1 -1
- package/web-app/dist/assets/{scroll-text-DoVyyfKb.js → scroll-text-Wvidz5uN.js} +1 -1
- package/web-app/dist/assets/{server-DGy7TflW.js → server-O11QmkCN.js} +1 -1
- package/web-app/dist/assets/{shield-alert-BCzRcMXi.js → shield-alert-DXXx27uV.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-C_znJd5g.js → thumbs-up-DF8ivs3A.js} +1 -1
- package/web-app/dist/assets/{trash-2-BosCbeeA.js → trash-2-LA6PW0Nf.js} +1 -1
- package/web-app/dist/assets/{trending-down-CkR5zDkV.js → trending-down-Bw_vLKHT.js} +1 -1
- package/web-app/dist/assets/{trending-up-DpUIfOH4.js → trending-up-tX8motha.js} +1 -1
- package/web-app/dist/assets/{upload-CH9aKc8L.js → upload-CxtTWJd2.js} +1 -1
- package/web-app/dist/assets/{usePolling-pzxrHJeg.js → usePolling-D-P5Iofq.js} +1 -1
- package/web-app/dist/assets/{user-B7OHa-VQ.js → user-dKTcuUFq.js} +1 -1
- package/web-app/dist/index.html +1 -1
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
loki workspace show <name>
|
|
6
6
|
loki workspace run <name> <issue-ref>
|
|
7
7
|
loki workspace status [<run-id>]
|
|
8
|
+
loki workspace infer <name>
|
|
8
9
|
|
|
9
10
|
On by default (LOKI_WORKSPACES=0 disables). Workspaces are defined in loki.yaml:
|
|
10
11
|
|
|
@@ -19,8 +20,15 @@ Each repo gets its own worktree and engine run (continue-and-report: a failing
|
|
|
19
20
|
repo does not stop the others; dependents of a failed repo are SKIPPED). After
|
|
20
21
|
every repo has finished, the integration command runs once with each run branch
|
|
21
22
|
checked out, and evidence lands in
|
|
22
|
-
.loki/workspaces/<name>/<run-id>/integration.json.
|
|
23
|
-
|
|
23
|
+
.loki/workspaces/<name>/<run-id>/integration.json. When every repo finished ok,
|
|
24
|
+
integration did not fail and every child sealed a receipt, the run seals
|
|
25
|
+
composite.json (`loki verify --seal-composite`), which binds integration.json.
|
|
26
|
+
Otherwise the run prints "composite not sealed: <reason>".
|
|
27
|
+
|
|
28
|
+
With `plan: true` (workspace key) or LOKI_WORKSPACE_PLAN=1 one provider call
|
|
29
|
+
through the launcher seam writes <run-dir>/plan.json ({"<repo>": "<task>"});
|
|
30
|
+
a valid plan gives each listed repo its own task, an invalid one falls back to
|
|
31
|
+
the generic text and prints why.
|
|
24
32
|
|
|
25
33
|
Repos with no pending `after` run in parallel, at most `concurrency` at once
|
|
26
34
|
(workspace key or LOKI_WORKSPACE_CONCURRENCY, default 2). SIGINT and SIGTERM
|
|
@@ -29,6 +37,12 @@ kill each recorded child process group.
|
|
|
29
37
|
Exit: 0 all repos ok and integration not failed; 3 when the only non-ok
|
|
30
38
|
outcomes are budget stops (child exit 3); 1 otherwise; 2 usage/config.
|
|
31
39
|
|
|
40
|
+
`infer` prints suggested `after:` edges read from package.json, go.mod and
|
|
41
|
+
pyproject.toml dependencies on other listed repos (print-only, cycle-checked).
|
|
42
|
+
With `integration: {per_stage: true}` the integration command also runs after
|
|
43
|
+
each topological layer; a failing stage SKIPs every later layer, so a broken
|
|
44
|
+
contract stops before downstream repos build on it.
|
|
45
|
+
|
|
32
46
|
Test seam: LOKI_WORKSPACE_LAUNCHER replaces the engine launcher (called with
|
|
33
47
|
the issue ref or task text, cwd = the repo worktree).
|
|
34
48
|
"""
|
|
@@ -104,6 +118,14 @@ def order_repos(ws):
|
|
|
104
118
|
return ordered
|
|
105
119
|
|
|
106
120
|
|
|
121
|
+
def layers_of(ordered):
|
|
122
|
+
"""Map repo -> topological layer (0 = no `after`), for an order_repos result."""
|
|
123
|
+
layer = {}
|
|
124
|
+
for e in ordered:
|
|
125
|
+
layer[e["repo"]] = 1 + max([layer[d] for d in e.get("after") or []], default=-1)
|
|
126
|
+
return layer
|
|
127
|
+
|
|
128
|
+
|
|
107
129
|
def _slug(repo):
|
|
108
130
|
return repo.replace("/", "__")
|
|
109
131
|
|
|
@@ -141,8 +163,53 @@ def prepare(source, dest, branch, setup):
|
|
|
141
163
|
return dest
|
|
142
164
|
|
|
143
165
|
|
|
144
|
-
def
|
|
145
|
-
"""(
|
|
166
|
+
def load_plan(run_dir, repos):
|
|
167
|
+
"""(plan dict, reason): per-repo task text from run_dir/plan.json. Any problem gives ({}, reason)."""
|
|
168
|
+
path = os.path.join(run_dir, "plan.json")
|
|
169
|
+
try:
|
|
170
|
+
with open(path) as f:
|
|
171
|
+
plan = json.load(f)
|
|
172
|
+
except FileNotFoundError:
|
|
173
|
+
return {}, "plan.json was not written"
|
|
174
|
+
except (OSError, ValueError) as e:
|
|
175
|
+
return {}, "plan.json unreadable (%s)" % type(e).__name__
|
|
176
|
+
if not isinstance(plan, dict) or not plan:
|
|
177
|
+
return {}, "plan.json is not a non-empty object"
|
|
178
|
+
unknown = sorted(k for k in plan if k not in repos)
|
|
179
|
+
if unknown:
|
|
180
|
+
return {}, "plan.json names repos outside the workspace: %s" % ", ".join(map(str, unknown))
|
|
181
|
+
empty = sorted(k for k, v in plan.items() if not isinstance(v, str) or not v.strip())
|
|
182
|
+
if empty:
|
|
183
|
+
return {}, "plan.json has empty task text for: %s" % ", ".join(empty)
|
|
184
|
+
return plan, None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def request_plan(ordered, ref, run_dir, launcher, env):
|
|
188
|
+
"""One launcher call asking for plan.json; returns (plan, reason). The launcher may ignore it."""
|
|
189
|
+
repos = [e["repo"] for e in ordered]
|
|
190
|
+
prompt = ("Plan the change described by %s across the repos %s. Write JSON to %s mapping each "
|
|
191
|
+
"repo that needs work to its own task text. Do not edit any repo." %
|
|
192
|
+
(ref, ", ".join(repos), os.path.join(run_dir, "plan.json")))
|
|
193
|
+
try:
|
|
194
|
+
timeout = int(os.environ.get("LOKI_WORKSPACE_PLAN_TIMEOUT") or 600)
|
|
195
|
+
except ValueError:
|
|
196
|
+
timeout = 600
|
|
197
|
+
penv = dict(env, LOKI_E10_TASK_TEXT=prompt, LOKI_WORKSPACE_PLAN_OUT=os.path.join(run_dir, "plan.json"))
|
|
198
|
+
try:
|
|
199
|
+
subprocess.run([launcher, prompt], cwd=run_dir, env=penv, stdout=subprocess.DEVNULL,
|
|
200
|
+
stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, timeout=timeout,
|
|
201
|
+
start_new_session=True)
|
|
202
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
203
|
+
return {}, "plan call failed (%s)" % type(e).__name__
|
|
204
|
+
return load_plan(run_dir, set(repos))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def task_for(entry, ref, siblings, plan=None):
|
|
208
|
+
"""(launcher argument, extra env) for a repo: its planned task, else the issue ref when it lives here, else task text."""
|
|
209
|
+
if plan and plan.get(entry["repo"]):
|
|
210
|
+
text = "%s\n\nPart of: %s. Sibling worktrees (read-only context, do not edit): %s" % (
|
|
211
|
+
plan[entry["repo"]].strip(), ref, ", ".join(siblings) or "none")
|
|
212
|
+
return text, {"LOKI_E10_TASK_TEXT": text}
|
|
146
213
|
m = REF_RE.match(ref)
|
|
147
214
|
if m and m.group(1) == entry["repo"]:
|
|
148
215
|
return ref, {}
|
|
@@ -156,7 +223,7 @@ def run_integration(ws, run_dir, worktrees, heads):
|
|
|
156
223
|
integ = ws.get("integration") or {}
|
|
157
224
|
cmd = integ.get("command")
|
|
158
225
|
ev = {"heads": heads, "exit_code": None, "log_sha256": None, "status": "not_configured",
|
|
159
|
-
"seal": False, "note": "integration evidence is
|
|
226
|
+
"seal": False, "note": "integration evidence is bound by composite.json only when the run seals one"}
|
|
160
227
|
if not cmd:
|
|
161
228
|
return ev
|
|
162
229
|
timeout = integ.get("timeout_s", 900)
|
|
@@ -185,6 +252,14 @@ def run_integration(ws, run_dir, worktrees, heads):
|
|
|
185
252
|
return ev
|
|
186
253
|
|
|
187
254
|
|
|
255
|
+
def _heads(worktrees):
|
|
256
|
+
out = {}
|
|
257
|
+
for repo, wt in worktrees.items():
|
|
258
|
+
r = git("rev-parse", "HEAD", cwd=wt)
|
|
259
|
+
out[repo] = r.stdout.strip() if r.returncode == 0 else None
|
|
260
|
+
return out
|
|
261
|
+
|
|
262
|
+
|
|
188
263
|
def run_workspace(name, ref, workspaces, base_dir=None, launcher=None):
|
|
189
264
|
"""Run the workspace; return (exit_code, evidence_path_or_None)."""
|
|
190
265
|
ws = workspaces.get(name)
|
|
@@ -215,6 +290,14 @@ def run_workspace(name, ref, workspaces, base_dir=None, launcher=None):
|
|
|
215
290
|
except (TypeError, ValueError):
|
|
216
291
|
conc = 2
|
|
217
292
|
|
|
293
|
+
plan = {}
|
|
294
|
+
if ws.get("plan") is True or os.environ.get("LOKI_WORKSPACE_PLAN") == "1":
|
|
295
|
+
plan, why = request_plan(ordered, ref, run_dir, launcher, child_env_base)
|
|
296
|
+
if why:
|
|
297
|
+
say("workspace: plan not used (%s); every repo gets the generic task" % why)
|
|
298
|
+
else:
|
|
299
|
+
say("workspace: plan accepted for %s" % ", ".join(sorted(plan)))
|
|
300
|
+
|
|
218
301
|
def stop(*_):
|
|
219
302
|
for p, _lf in running.values():
|
|
220
303
|
try:
|
|
@@ -244,7 +327,37 @@ def run_workspace(name, ref, workspaces, base_dir=None, launcher=None):
|
|
|
244
327
|
|
|
245
328
|
index = {e["repo"]: i for i, e in enumerate(ordered)}
|
|
246
329
|
pending = list(ordered)
|
|
330
|
+
per_stage = bool((ws.get("integration") or {}).get("per_stage"))
|
|
331
|
+
layer = layers_of(ordered)
|
|
332
|
+
gate, stages = {}, [] # layer -> "passed"/"failed"; stage evidence list
|
|
333
|
+
|
|
334
|
+
def process_gates():
|
|
335
|
+
"""per_stage: once a layer has fully finished, gate it with the integration command."""
|
|
336
|
+
for lv in sorted(set(layer.values())):
|
|
337
|
+
members = [r for r, l in layer.items() if l == lv]
|
|
338
|
+
if lv in gate or not all(r in kind for r in members):
|
|
339
|
+
continue
|
|
340
|
+
if any(l < lv and l not in gate for l in set(layer.values())):
|
|
341
|
+
continue
|
|
342
|
+
if any(kind[r] != "ok" for r in members):
|
|
343
|
+
gate[lv] = "failed"
|
|
344
|
+
else:
|
|
345
|
+
sev = run_integration(ws, run_dir, worktrees, _heads(worktrees))
|
|
346
|
+
sev["layer"] = lv
|
|
347
|
+
stages.append(sev)
|
|
348
|
+
gate[lv] = "failed" if sev["status"] == "failed" else "passed"
|
|
349
|
+
say("workspace: stage %d integration %s" % (lv, sev["status"]))
|
|
350
|
+
if gate[lv] == "failed":
|
|
351
|
+
for e in list(pending):
|
|
352
|
+
if layer[e["repo"]] > lv:
|
|
353
|
+
pending.remove(e)
|
|
354
|
+
outcome[e["repo"]] = "SKIPPED (stage %d integration failed)" % lv
|
|
355
|
+
kind[e["repo"]] = "fail"
|
|
356
|
+
say("workspace: %s %s" % (e["repo"], outcome[e["repo"]]))
|
|
357
|
+
|
|
247
358
|
while pending or running:
|
|
359
|
+
if per_stage:
|
|
360
|
+
process_gates()
|
|
248
361
|
for entry in list(pending):
|
|
249
362
|
repo = entry["repo"]
|
|
250
363
|
deps = entry.get("after") or []
|
|
@@ -260,6 +373,8 @@ def run_workspace(name, ref, workspaces, base_dir=None, launcher=None):
|
|
|
260
373
|
break
|
|
261
374
|
if not all(kind.get(d) == "ok" for d in entry.get("after") or []):
|
|
262
375
|
continue
|
|
376
|
+
if per_stage and any(gate.get(lv) != "passed" for lv in range(layer[repo])):
|
|
377
|
+
continue
|
|
263
378
|
pending.remove(entry)
|
|
264
379
|
try:
|
|
265
380
|
src = source_checkout(entry)
|
|
@@ -271,7 +386,7 @@ def run_workspace(name, ref, workspaces, base_dir=None, launcher=None):
|
|
|
271
386
|
continue
|
|
272
387
|
worktrees[repo] = wt
|
|
273
388
|
siblings = [os.path.join(run_dir, "worktrees", _slug(o["repo"])) for o in ordered if o["repo"] != repo]
|
|
274
|
-
arg, extra = task_for(entry, ref, siblings)
|
|
389
|
+
arg, extra = task_for(entry, ref, siblings, plan)
|
|
275
390
|
lf = open(os.path.join(run_dir, "%s.log" % _slug(repo)), "w")
|
|
276
391
|
say("workspace: %s running" % repo)
|
|
277
392
|
p = subprocess.Popen([launcher, arg], cwd=wt, env=dict(child_env_base, **extra), stdout=lf,
|
|
@@ -294,11 +409,14 @@ def run_workspace(name, ref, workspaces, base_dir=None, launcher=None):
|
|
|
294
409
|
if running:
|
|
295
410
|
time.sleep(0.05)
|
|
296
411
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
412
|
+
if per_stage:
|
|
413
|
+
process_gates()
|
|
414
|
+
heads = _heads(worktrees)
|
|
300
415
|
|
|
301
|
-
|
|
416
|
+
if per_stage and stages:
|
|
417
|
+
ev = dict(stages[-1], heads=heads, stages=[{k: v for k, v in s_.items() if k != "heads"} for s_ in stages])
|
|
418
|
+
else:
|
|
419
|
+
ev = run_integration(ws, run_dir, worktrees, heads)
|
|
302
420
|
ev["outcomes"] = outcome
|
|
303
421
|
ev["timing"] = {r: t for r, t in timing.items() if "finished_at" in t}
|
|
304
422
|
ev_path = os.path.join(run_dir, "integration.json")
|
|
@@ -307,6 +425,8 @@ def run_workspace(name, ref, workspaces, base_dir=None, launcher=None):
|
|
|
307
425
|
json.dump(ev, f, indent=2, sort_keys=True)
|
|
308
426
|
os.replace(tmp, ev_path)
|
|
309
427
|
|
|
428
|
+
seal_composite(ordered, outcome, ev, run_dir)
|
|
429
|
+
|
|
310
430
|
try:
|
|
311
431
|
import workspace_comment
|
|
312
432
|
workspace_comment.post_comments(name, run_id, run_dir, worktrees, ev, say)
|
|
@@ -323,6 +443,46 @@ def run_workspace(name, ref, workspaces, base_dir=None, launcher=None):
|
|
|
323
443
|
return (3 if any(k == "budget" for k in kind.values()) else 0), ev_path
|
|
324
444
|
|
|
325
445
|
|
|
446
|
+
_SEAL_ENV_KEYS = ("PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "SHELL", "USER", "BUN_INSTALL", "LOKI_NO_BROWSER",
|
|
447
|
+
"LOKI_RECEIPT_SIGNING_KEY", "LOKI_RECEIPT_SIGNING_KEY_FILE", "LOKI_TS_ENTRY", "BUN_FROM_SOURCE",
|
|
448
|
+
"LOKI_LEGACY_BASH")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def seal_composite(ordered, outcome, ev, run_dir):
|
|
452
|
+
"""Seal composite.json once when every repo finished ok with a receipt; otherwise say why not."""
|
|
453
|
+
if ev.get("status") == "failed":
|
|
454
|
+
say("composite not sealed: integration failed")
|
|
455
|
+
return False
|
|
456
|
+
for e in ordered:
|
|
457
|
+
repo = e["repo"]
|
|
458
|
+
if outcome.get(repo) != "ok":
|
|
459
|
+
say("composite not sealed: %s did not finish ok (%s)" % (repo, outcome.get(repo, "no outcome")))
|
|
460
|
+
return False
|
|
461
|
+
runs = os.path.join(run_dir, "worktrees", _slug(repo), ".loki", "runs")
|
|
462
|
+
try:
|
|
463
|
+
has = any(os.path.isfile(os.path.join(runs, n, "receipt.json")) for n in os.listdir(runs))
|
|
464
|
+
except OSError:
|
|
465
|
+
has = False
|
|
466
|
+
if not has:
|
|
467
|
+
say("composite not sealed: %s has no receipt" % repo)
|
|
468
|
+
return False
|
|
469
|
+
env = {k: os.environ[k] for k in _SEAL_ENV_KEYS if k in os.environ}
|
|
470
|
+
env["LOKI_NO_BROWSER"] = "1"
|
|
471
|
+
exe = os.environ.get("LOKI_WORKSPACE_VERIFY") or os.path.join(REPO_ROOT, "bin", "loki")
|
|
472
|
+
try:
|
|
473
|
+
r = subprocess.run([exe, "verify", "--seal-composite", run_dir], env=env, capture_output=True,
|
|
474
|
+
text=True, stdin=subprocess.DEVNULL, timeout=120)
|
|
475
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
476
|
+
say("composite not sealed: verify could not run (%s)" % type(e).__name__)
|
|
477
|
+
return False
|
|
478
|
+
if r.returncode != 0:
|
|
479
|
+
last = (r.stderr.strip().splitlines() or r.stdout.strip().splitlines() or ["no output"])[-1]
|
|
480
|
+
say("composite not sealed: verify exit %d (%s)" % (r.returncode, last))
|
|
481
|
+
return False
|
|
482
|
+
say("workspace: composite sealed: %s" % os.path.join(run_dir, "composite.json"))
|
|
483
|
+
return True
|
|
484
|
+
|
|
485
|
+
|
|
326
486
|
def _runs(base_dir):
|
|
327
487
|
"""Yield (workspace, run_id, evidence dict) for every recorded run, oldest first."""
|
|
328
488
|
root = os.path.join(base_dir, ".loki", "workspaces")
|
|
@@ -372,12 +532,106 @@ def show(name, workspaces, say=say):
|
|
|
372
532
|
return 0
|
|
373
533
|
|
|
374
534
|
|
|
535
|
+
def _read(path):
|
|
536
|
+
try:
|
|
537
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
538
|
+
return f.read()
|
|
539
|
+
except OSError:
|
|
540
|
+
return None
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _identities(entry, root):
|
|
544
|
+
"""Names other repos may use to depend on this one (repo name, package, module)."""
|
|
545
|
+
ids = {entry["repo"].split("/", 1)[1].lower()}
|
|
546
|
+
pj = _read(os.path.join(root, "package.json"))
|
|
547
|
+
if pj:
|
|
548
|
+
try:
|
|
549
|
+
n = json.loads(pj).get("name")
|
|
550
|
+
if isinstance(n, str):
|
|
551
|
+
ids.add(n.lower())
|
|
552
|
+
except ValueError:
|
|
553
|
+
pass
|
|
554
|
+
gm = _read(os.path.join(root, "go.mod"))
|
|
555
|
+
if gm:
|
|
556
|
+
m = re.search(r"^module\s+(\S+)", gm, re.M)
|
|
557
|
+
if m:
|
|
558
|
+
ids.add(m.group(1).lower())
|
|
559
|
+
pp = _read(os.path.join(root, "pyproject.toml"))
|
|
560
|
+
if pp:
|
|
561
|
+
m = re.search(r'^name\s*=\s*["\']([^"\']+)["\']', pp, re.M)
|
|
562
|
+
if m:
|
|
563
|
+
ids.add(m.group(1).lower().replace("_", "-"))
|
|
564
|
+
return ids
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _dep_tokens(root):
|
|
568
|
+
"""Lower-cased dependency names/paths declared by one checkout."""
|
|
569
|
+
toks = set()
|
|
570
|
+
pj = _read(os.path.join(root, "package.json"))
|
|
571
|
+
if pj:
|
|
572
|
+
try:
|
|
573
|
+
d = json.loads(pj)
|
|
574
|
+
for k in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
|
|
575
|
+
toks.update(str(n).lower() for n in (d.get(k) or {}))
|
|
576
|
+
except (ValueError, AttributeError):
|
|
577
|
+
pass
|
|
578
|
+
gm = _read(os.path.join(root, "go.mod"))
|
|
579
|
+
if gm:
|
|
580
|
+
toks.update(t.lower() for t in re.findall(r"^\s*(?:require\s+)?([\w.\-]+(?:/[\w.\-]+)+)\s+v\S+", gm, re.M))
|
|
581
|
+
pp = _read(os.path.join(root, "pyproject.toml"))
|
|
582
|
+
if pp:
|
|
583
|
+
toks.update(t.lower().replace("_", "-") for t in
|
|
584
|
+
re.findall(r'["\']([A-Za-z0-9][A-Za-z0-9_.\-]*)\s*(?:[<>=!~\[;,][^"\']*)?["\']', pp))
|
|
585
|
+
return toks
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def infer(name, workspaces, say=say):
|
|
589
|
+
"""Print suggested `after:` edges from manifests; never writes loki.yaml."""
|
|
590
|
+
ws = workspaces.get(name)
|
|
591
|
+
if ws is None:
|
|
592
|
+
say("workspace: unknown workspace %r (known: %s)" % (name, ", ".join(sorted(workspaces)) or "none"))
|
|
593
|
+
return 2
|
|
594
|
+
entries = [e for e in ws.get("repos") or [] if isinstance(e.get("repo"), str)]
|
|
595
|
+
roots = {}
|
|
596
|
+
for e in entries:
|
|
597
|
+
root = os.path.abspath(os.path.expanduser(e["path"])) if e.get("path") else os.path.join(
|
|
598
|
+
os.path.expanduser("~"), ".loki", "repos", _slug(e["repo"]))
|
|
599
|
+
roots[e["repo"]] = root if os.path.isdir(root) else None
|
|
600
|
+
ids = {e["repo"]: _identities(e, roots[e["repo"]]) if roots[e["repo"]] else set() for e in entries}
|
|
601
|
+
proposed = {}
|
|
602
|
+
for e in entries:
|
|
603
|
+
repo = e["repo"]
|
|
604
|
+
if not roots[repo]:
|
|
605
|
+
say("workspace: %s: no local checkout, skipped" % repo)
|
|
606
|
+
proposed[repo] = list(e.get("after") or [])
|
|
607
|
+
continue
|
|
608
|
+
toks = _dep_tokens(roots[repo])
|
|
609
|
+
deps = list(e.get("after") or [])
|
|
610
|
+
for other in entries:
|
|
611
|
+
o = other["repo"]
|
|
612
|
+
if o == repo or o in deps:
|
|
613
|
+
continue
|
|
614
|
+
if any(t == i or t.startswith(i + "/") for t in toks for i in ids[o]):
|
|
615
|
+
deps.append(o)
|
|
616
|
+
proposed[repo] = deps
|
|
617
|
+
try:
|
|
618
|
+
order_repos({"repos": [{"repo": e["repo"], "after": proposed[e["repo"]]} for e in entries]})
|
|
619
|
+
except ValueError as err:
|
|
620
|
+
say("workspace: %s: inferred edges are not usable: %s" % (name, err))
|
|
621
|
+
return 1
|
|
622
|
+
say("workspace %s: suggested edges (print-only; copy into loki.yaml)" % name)
|
|
623
|
+
for e in entries:
|
|
624
|
+
say("%s" % e["repo"])
|
|
625
|
+
say(" after: [%s]" % ", ".join(proposed[e["repo"]]))
|
|
626
|
+
return 0
|
|
627
|
+
|
|
628
|
+
|
|
375
629
|
def main(argv):
|
|
376
630
|
if not enabled():
|
|
377
631
|
say("loki workspace is disabled (LOKI_WORKSPACES=0). Unset it to use workspaces.")
|
|
378
632
|
return 2
|
|
379
633
|
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
380
|
-
say("Usage: loki workspace list | show <name> | run <name> <owner/repo#N | task text> | status [<run-id>]")
|
|
634
|
+
say("Usage: loki workspace list | show <name> | infer <name> | run <name> <owner/repo#N | task text> | status [<run-id>]")
|
|
381
635
|
return 0 if argv else 2
|
|
382
636
|
try:
|
|
383
637
|
workspaces, errors = load_workspaces()
|
|
@@ -395,6 +649,8 @@ def main(argv):
|
|
|
395
649
|
for n, ws in sorted(workspaces.items()):
|
|
396
650
|
say("%s: %s" % (n, ", ".join(e.get("repo", "?") for e in ws.get("repos") or [])))
|
|
397
651
|
return 0
|
|
652
|
+
if cmd == "infer" and len(argv) == 2:
|
|
653
|
+
return infer(argv[1], workspaces)
|
|
398
654
|
if cmd == "show" and len(argv) == 2:
|
|
399
655
|
return show(argv[1], workspaces)
|
|
400
656
|
if cmd == "status" and len(argv) <= 2:
|