loki-mode 7.80.0 → 7.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/checkpoint_sync.py +210 -3
- package/autonomy/loki +21 -2
- package/autonomy/queue-consumer.sh +346 -0
- package/autonomy/sandbox.sh +27 -0
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +222 -3
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/README.md
CHANGED
|
@@ -279,7 +279,9 @@ Real-time monitoring, agent status, task queue, WebSocket streaming, and Live Ap
|
|
|
279
279
|
<td width="33%" valign="top">
|
|
280
280
|
|
|
281
281
|
### Enterprise Layer
|
|
282
|
-
TLS, OIDC
|
|
282
|
+
TLS, OIDC bearer-token validation (the foundation for SSO; browser SAML login is
|
|
283
|
+
roadmap), scoped RBAC, OTEL tracing, policy engine, audit trails. Activated via
|
|
284
|
+
env vars. See [Enterprise Identity Roadmap](docs/ENTERPRISE-IDENTITY-ROADMAP.md).
|
|
283
285
|
|
|
284
286
|
[Enterprise Guide](docs/enterprise/architecture.md)
|
|
285
287
|
|
|
@@ -305,7 +307,7 @@ The historical feature set (platform pages, Monaco IDE workspace, AI chat panel)
|
|
|
305
307
|
| 5 AI provider failover | Yes | No | No | No |
|
|
306
308
|
| 8 quality gates | Yes | No | No | No |
|
|
307
309
|
| Blind code review | Yes | No | No | No |
|
|
308
|
-
| Enterprise auth (
|
|
310
|
+
| Enterprise auth (OIDC token + scoped RBAC) | Yes | No | Yes | No |
|
|
309
311
|
| Air-gapped deployment | Yes | No | No | No |
|
|
310
312
|
| Docker + CI/CD generation | Yes | No | Yes | No |
|
|
311
313
|
| Source-available (BUSL-1.1) | Yes | No | No | No |
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v7.
|
|
6
|
+
# Loki Mode v7.81.0
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -406,4 +406,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
|
|
|
406
406
|
|
|
407
407
|
---
|
|
408
408
|
|
|
409
|
-
**v7.
|
|
409
|
+
**v7.81.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.
|
|
1
|
+
7.81.0
|
|
@@ -11,9 +11,18 @@ Honest scope
|
|
|
11
11
|
------------
|
|
12
12
|
- This syncs the lightweight checkpoint state (.loki/state/checkpoints/**) to the
|
|
13
13
|
configured object store, and hydrates it back on a durable resume when the
|
|
14
|
-
local volume came up empty.
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
local volume came up empty.
|
|
15
|
+
- It ALSO syncs the git refs/loki/cp/* worktree snapshots (the working-tree
|
|
16
|
+
state captured by `git stash create` and anchored under refs/loki/cp/<id>).
|
|
17
|
+
Those commits live in .git (outside LokiStore's .loki root), so they cannot be
|
|
18
|
+
copied as plain objects; instead, for each checkpoint that carries a
|
|
19
|
+
worktree-snapshot.txt SHA we export a `git bundle` of that commit to the store
|
|
20
|
+
(key runs/<run-id>/worktree-bundles/<checkpoint-id>.bundle) and, on hydrate,
|
|
21
|
+
`git fetch` it back and re-create the ref. This makes a fresh-node resume able
|
|
22
|
+
to restore the full working tree, not just the .loki metadata. Best-effort and
|
|
23
|
+
gated on git being present; a repo without git or without snapshots simply
|
|
24
|
+
skips this part. Honest remaining limit: snapshots capture TRACKED changes only
|
|
25
|
+
(the same limit as create_checkpoint's `git stash create`).
|
|
17
26
|
- All operations are best-effort. A sync/hydrate error never raises to the
|
|
18
27
|
caller's control flow (run.sh treats a nonzero exit as "skip, continue").
|
|
19
28
|
The bash side logs and continues a build on any failure.
|
|
@@ -40,7 +49,9 @@ Exit codes: 0 on success (including "nothing to do"); nonzero on any error
|
|
|
40
49
|
from __future__ import annotations
|
|
41
50
|
|
|
42
51
|
import os
|
|
52
|
+
import subprocess
|
|
43
53
|
import sys
|
|
54
|
+
import tempfile
|
|
44
55
|
|
|
45
56
|
# Local-first guard: this shim is only meaningful for a non-local backend. If the
|
|
46
57
|
# backend is local/unset, do nothing (run.sh should not even call us, but be
|
|
@@ -49,6 +60,61 @@ _BACKEND = (os.environ.get("LOKI_STORAGE_BACKEND") or "local").strip().lower()
|
|
|
49
60
|
|
|
50
61
|
# The subtree of the .loki/ store that holds checkpoint state.
|
|
51
62
|
_CHECKPOINT_PREFIX = "state/checkpoints/"
|
|
63
|
+
# Object-store subkey holding the git-bundle exports of refs/loki/cp/* snapshots.
|
|
64
|
+
_BUNDLE_SUBKEY = "worktree-bundles/"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _project_dir() -> str:
|
|
68
|
+
"""The project working directory (git repo root containing .loki/)."""
|
|
69
|
+
loki_dir = os.environ.get("LOKI_DIR") or os.path.join(
|
|
70
|
+
os.environ.get("TARGET_DIR", "."), ".loki"
|
|
71
|
+
)
|
|
72
|
+
# .loki lives at <project>/.loki, so the project dir is its parent.
|
|
73
|
+
return os.path.dirname(os.path.abspath(loki_dir))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _git(args, cwd, capture=True):
|
|
77
|
+
"""Run a git command best-effort; return (rc, stdout). Never raises."""
|
|
78
|
+
try:
|
|
79
|
+
proc = subprocess.run(
|
|
80
|
+
["git", *args],
|
|
81
|
+
cwd=cwd,
|
|
82
|
+
stdout=subprocess.PIPE if capture else subprocess.DEVNULL,
|
|
83
|
+
stderr=subprocess.DEVNULL,
|
|
84
|
+
text=True,
|
|
85
|
+
check=False,
|
|
86
|
+
)
|
|
87
|
+
return proc.returncode, (proc.stdout or "").strip()
|
|
88
|
+
except (OSError, ValueError):
|
|
89
|
+
return 1, ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _have_git_repo(project_dir) -> bool:
|
|
93
|
+
if not project_dir or not os.path.isdir(project_dir):
|
|
94
|
+
return False
|
|
95
|
+
rc, _ = _git(["rev-parse", "--is-inside-work-tree"], project_dir)
|
|
96
|
+
return rc == 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _iter_checkpoint_snapshots(local):
|
|
100
|
+
"""Yield (checkpoint_id, snapshot_sha) for every worktree-snapshot.txt found
|
|
101
|
+
in the local checkpoint store. checkpoint_id is the directory name under
|
|
102
|
+
state/checkpoints/ that owns the snapshot."""
|
|
103
|
+
for subkey in local.list(_CHECKPOINT_PREFIX):
|
|
104
|
+
# subkey like state/checkpoints/<cp-id>/worktree-snapshot.txt
|
|
105
|
+
if not subkey.endswith("/worktree-snapshot.txt"):
|
|
106
|
+
continue
|
|
107
|
+
parts = subkey.split("/")
|
|
108
|
+
# .../checkpoints/<cp-id>/worktree-snapshot.txt -> cp-id is parts[-2]
|
|
109
|
+
if len(parts) < 2:
|
|
110
|
+
continue
|
|
111
|
+
cp_id = parts[-2]
|
|
112
|
+
try:
|
|
113
|
+
sha = local.get(subkey).decode("utf-8", "replace").strip()
|
|
114
|
+
except Exception:
|
|
115
|
+
continue
|
|
116
|
+
if sha:
|
|
117
|
+
yield cp_id, sha
|
|
52
118
|
|
|
53
119
|
|
|
54
120
|
def _resolve_run_id() -> str:
|
|
@@ -122,9 +188,64 @@ def cmd_sync() -> int:
|
|
|
122
188
|
f"[checkpoint-sync] pushed {count} checkpoint object(s) to "
|
|
123
189
|
f"{_BACKEND} under runs/{run_id}/\n"
|
|
124
190
|
)
|
|
191
|
+
|
|
192
|
+
# Also export the git refs/loki/cp/* worktree snapshots as bundles (V2).
|
|
193
|
+
bundles = _sync_worktree_bundles(local, remote, run_id)
|
|
194
|
+
if bundles:
|
|
195
|
+
sys.stderr.write(
|
|
196
|
+
f"[checkpoint-sync] pushed {bundles} worktree snapshot bundle(s)\n"
|
|
197
|
+
)
|
|
125
198
|
return 0
|
|
126
199
|
|
|
127
200
|
|
|
201
|
+
def _sync_worktree_bundles(local, remote, run_id) -> int:
|
|
202
|
+
"""For each checkpoint snapshot SHA, `git bundle` the commit and store it.
|
|
203
|
+
Best-effort; returns the count pushed. Skips silently without git/snapshots."""
|
|
204
|
+
project_dir = _project_dir()
|
|
205
|
+
if not _have_git_repo(project_dir):
|
|
206
|
+
return 0
|
|
207
|
+
pushed = 0
|
|
208
|
+
for cp_id, sha in _iter_checkpoint_snapshots(local):
|
|
209
|
+
# Symmetric with hydrate: never bundle/store an unsafe cp_id ref name.
|
|
210
|
+
if not _safe_cp_id(cp_id):
|
|
211
|
+
sys.stderr.write(f"[checkpoint-sync] skipped unsafe snapshot id: {cp_id}\n")
|
|
212
|
+
continue
|
|
213
|
+
# Verify the commit still exists locally before bundling.
|
|
214
|
+
rc, _ = _git(["cat-file", "-e", f"{sha}^{{commit}}"], project_dir)
|
|
215
|
+
if rc != 0:
|
|
216
|
+
continue
|
|
217
|
+
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".bundle")
|
|
218
|
+
os.close(tmp_fd)
|
|
219
|
+
try:
|
|
220
|
+
# `git bundle create` needs a REF-style argument; a bare SHA (or a
|
|
221
|
+
# single-rev `sha`/`sha~0..sha`) makes git refuse with "empty bundle"
|
|
222
|
+
# because there is no ref to anchor. The snapshot is already anchored
|
|
223
|
+
# at refs/loki/cp/<cp_id> by create_checkpoint, so bundle THAT ref.
|
|
224
|
+
# The bundle is self-contained for the snapshot commit + its history.
|
|
225
|
+
rc, _ = _git(
|
|
226
|
+
["bundle", "create", tmp_path, f"refs/loki/cp/{cp_id}"],
|
|
227
|
+
project_dir,
|
|
228
|
+
capture=False,
|
|
229
|
+
)
|
|
230
|
+
if rc != 0 or not os.path.exists(tmp_path) or os.path.getsize(tmp_path) == 0:
|
|
231
|
+
continue
|
|
232
|
+
with open(tmp_path, "rb") as f:
|
|
233
|
+
data = f.read()
|
|
234
|
+
remote.put(_run_key(run_id, f"{_BUNDLE_SUBKEY}{cp_id}.bundle"), data)
|
|
235
|
+
# Persist the SHA alongside so hydrate can re-create the exact ref.
|
|
236
|
+
remote.put(
|
|
237
|
+
_run_key(run_id, f"{_BUNDLE_SUBKEY}{cp_id}.sha"),
|
|
238
|
+
(sha + "\n").encode("utf-8"),
|
|
239
|
+
)
|
|
240
|
+
pushed += 1
|
|
241
|
+
finally:
|
|
242
|
+
try:
|
|
243
|
+
os.unlink(tmp_path)
|
|
244
|
+
except OSError:
|
|
245
|
+
pass
|
|
246
|
+
return pushed
|
|
247
|
+
|
|
248
|
+
|
|
128
249
|
def cmd_hydrate() -> int:
|
|
129
250
|
"""
|
|
130
251
|
Pull checkpoint state from the object store into the local volume, but ONLY
|
|
@@ -169,9 +290,95 @@ def cmd_hydrate() -> int:
|
|
|
169
290
|
f"[checkpoint-sync] hydrated {count} checkpoint object(s) from "
|
|
170
291
|
f"{_BACKEND} for runs/{run_id}/\n"
|
|
171
292
|
)
|
|
293
|
+
|
|
294
|
+
# Restore git refs/loki/cp/* worktree snapshots from their bundles (V2).
|
|
295
|
+
restored = _hydrate_worktree_bundles(remote, run_id)
|
|
296
|
+
if restored:
|
|
297
|
+
sys.stderr.write(
|
|
298
|
+
f"[checkpoint-sync] restored {restored} worktree snapshot ref(s)\n"
|
|
299
|
+
)
|
|
172
300
|
return 0
|
|
173
301
|
|
|
174
302
|
|
|
303
|
+
def _hydrate_worktree_bundles(remote, run_id) -> int:
|
|
304
|
+
"""Fetch each stored worktree bundle and re-create refs/loki/cp/<id>.
|
|
305
|
+
Best-effort; returns the count restored. No-op without git or bundles."""
|
|
306
|
+
project_dir = _project_dir()
|
|
307
|
+
if not _have_git_repo(project_dir):
|
|
308
|
+
return 0
|
|
309
|
+
bundle_prefix = _run_key(run_id, _BUNDLE_SUBKEY)
|
|
310
|
+
try:
|
|
311
|
+
remote_keys = remote.list(bundle_prefix)
|
|
312
|
+
except Exception:
|
|
313
|
+
return 0
|
|
314
|
+
restored = 0
|
|
315
|
+
for rk in remote_keys:
|
|
316
|
+
if not rk.endswith(".bundle"):
|
|
317
|
+
continue
|
|
318
|
+
cp_id = rk.rsplit("/", 1)[-1][: -len(".bundle")]
|
|
319
|
+
# Guard cp_id against ref-injection (the bundle key comes from the store).
|
|
320
|
+
if not _safe_cp_id(cp_id):
|
|
321
|
+
sys.stderr.write(f"[checkpoint-sync] skipped unsafe bundle id: {cp_id}\n")
|
|
322
|
+
continue
|
|
323
|
+
# Recover the snapshot SHA (stored sidecar) so we re-create the exact ref.
|
|
324
|
+
sha = ""
|
|
325
|
+
try:
|
|
326
|
+
sha = remote.get(_run_key(run_id, f"{_BUNDLE_SUBKEY}{cp_id}.sha")).decode(
|
|
327
|
+
"utf-8", "replace"
|
|
328
|
+
).strip()
|
|
329
|
+
except Exception:
|
|
330
|
+
sha = ""
|
|
331
|
+
if not sha or any(c not in "0123456789abcdef" for c in sha.lower()):
|
|
332
|
+
continue
|
|
333
|
+
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".bundle")
|
|
334
|
+
os.close(tmp_fd)
|
|
335
|
+
try:
|
|
336
|
+
remote.get_to(rk, tmp_path)
|
|
337
|
+
# Fetch the commit objects out of the bundle into this repo.
|
|
338
|
+
rc, _ = _git(["fetch", tmp_path, sha], project_dir, capture=False)
|
|
339
|
+
if rc != 0:
|
|
340
|
+
continue
|
|
341
|
+
# Re-create the anchored ref so `git gc` cannot prune it and a resume
|
|
342
|
+
# can find the snapshot exactly as create_checkpoint left it.
|
|
343
|
+
rc, _ = _git(
|
|
344
|
+
["update-ref", f"refs/loki/cp/{cp_id}", sha], project_dir, capture=False
|
|
345
|
+
)
|
|
346
|
+
if rc == 0:
|
|
347
|
+
restored += 1
|
|
348
|
+
finally:
|
|
349
|
+
try:
|
|
350
|
+
os.unlink(tmp_path)
|
|
351
|
+
except OSError:
|
|
352
|
+
pass
|
|
353
|
+
return restored
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# Safe characters for a checkpoint id used in a git ref path (defense vs
|
|
357
|
+
# ref-injection from a crafted object-store key). Checkpoint ids are of the form
|
|
358
|
+
# cp-<iter>-<epoch>. We do NOT rely on git's own ref-name validation as the only
|
|
359
|
+
# backstop (defense-in-depth): reject the git-special forms too.
|
|
360
|
+
_SAFE_CP_CHARS = set(
|
|
361
|
+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _safe_cp_id(cp_id: str) -> bool:
|
|
366
|
+
"""True iff cp_id is safe to interpolate into refs/loki/cp/<cp_id>.
|
|
367
|
+
|
|
368
|
+
Char-allowlist PLUS explicit rejection of git-special ref forms that the
|
|
369
|
+
allowlist would otherwise permit ('.', '..', a trailing dot, a '.lock'
|
|
370
|
+
suffix, 'HEAD', '@'). Not exploitable today (cp ids are locally minted), but
|
|
371
|
+
this must not depend on git's validation when the id can come from a store key.
|
|
372
|
+
"""
|
|
373
|
+
if not cp_id or any(c not in _SAFE_CP_CHARS for c in cp_id):
|
|
374
|
+
return False
|
|
375
|
+
if cp_id in (".", "..", "HEAD", "@"):
|
|
376
|
+
return False
|
|
377
|
+
if cp_id.endswith(".") or cp_id.endswith(".lock"):
|
|
378
|
+
return False
|
|
379
|
+
return True
|
|
380
|
+
|
|
381
|
+
|
|
175
382
|
def main(argv) -> int:
|
|
176
383
|
if len(argv) < 2 or argv[1] not in ("sync", "hydrate"):
|
|
177
384
|
sys.stderr.write("usage: checkpoint_sync.py {sync|hydrate}\n")
|
package/autonomy/loki
CHANGED
|
@@ -1501,6 +1501,17 @@ cmd_start() {
|
|
|
1501
1501
|
--config=*|--vars=*|--env-file=*)
|
|
1502
1502
|
shift
|
|
1503
1503
|
;;
|
|
1504
|
+
--)
|
|
1505
|
+
# End-of-options: everything after is positional. Lets a caller
|
|
1506
|
+
# pass an untrusted spec that might start with '-' safely, e.g.
|
|
1507
|
+
# `loki start -- "$spec"` from the queue consumer, so a crafted
|
|
1508
|
+
# work item can never be parsed as a flag.
|
|
1509
|
+
shift
|
|
1510
|
+
if [ -z "$positional_arg" ] && [ "$#" -ge 1 ]; then
|
|
1511
|
+
positional_arg="$1"
|
|
1512
|
+
fi
|
|
1513
|
+
break
|
|
1514
|
+
;;
|
|
1504
1515
|
-*)
|
|
1505
1516
|
echo -e "${RED}Unknown option: $1${NC}"
|
|
1506
1517
|
exit 1
|
|
@@ -29729,8 +29740,16 @@ PYEOF
|
|
|
29729
29740
|
cmd_bench() {
|
|
29730
29741
|
local bench_sh="$SKILL_DIR/benchmarks/bench/run.sh"
|
|
29731
29742
|
if [ ! -f "$bench_sh" ]; then
|
|
29732
|
-
|
|
29733
|
-
|
|
29743
|
+
# The benchmarks/ harness (~8MB, mostly historical result dumps) is a
|
|
29744
|
+
# dev/research feature deliberately omitted from the published package
|
|
29745
|
+
# (.npmignore excludes benchmarks/). On a packaged install it is absent,
|
|
29746
|
+
# so degrade honestly instead of a bare "not found" that reads like a
|
|
29747
|
+
# broken install. Run it from a loki-mode source checkout.
|
|
29748
|
+
echo "loki bench: the benchmark harness is not available in this install." >&2
|
|
29749
|
+
echo "It is a development/research feature shipped only in the loki-mode source" >&2
|
|
29750
|
+
echo "repository, not the published package. Clone the repo and run:" >&2
|
|
29751
|
+
echo " ./benchmarks/run-benchmarks.sh humaneval --execute --loki" >&2
|
|
29752
|
+
return 0
|
|
29734
29753
|
fi
|
|
29735
29754
|
local sub="${1:-}"
|
|
29736
29755
|
case "$sub" in
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# shellcheck disable=SC2155 # Declare and assign separately (acceptable in this codebase)
|
|
3
|
+
#===============================================================================
|
|
4
|
+
# Loki Mode - Reference Queue Consumer
|
|
5
|
+
#
|
|
6
|
+
# A pluggable reference queue-consumer entrypoint for the Helm worker modes:
|
|
7
|
+
#
|
|
8
|
+
# deployment -- long-running consumer. Loops forever, pulling one work item at
|
|
9
|
+
# a time, running `loki start <spec>`, and acking on success.
|
|
10
|
+
# serverless -- one-shot consumer (LOKI_QUEUE_ONESHOT=1). Pulls EXACTLY one
|
|
11
|
+
# item, runs it, exits with that build's exit code. Intended for
|
|
12
|
+
# a KEDA ScaledJob that creates one Job per queued item.
|
|
13
|
+
#
|
|
14
|
+
# A "work item" is a Loki spec ref accepted by `loki start`: a path/brief, a
|
|
15
|
+
# GitHub issue ref (owner/repo#N), or a JSON object {"spec": "..."} (the spec
|
|
16
|
+
# field is extracted; any other JSON keys are ignored by this reference consumer).
|
|
17
|
+
#
|
|
18
|
+
# Backends (LOKI_QUEUE_BACKEND):
|
|
19
|
+
# redis -- REAL. Default when redis-cli is on PATH. BLPOP/LPOP a Redis list
|
|
20
|
+
# (LOKI_QUEUE_KEY). At-most-once: a popped item is removed from the
|
|
21
|
+
# queue before the build runs, so a crashed build does NOT requeue
|
|
22
|
+
# automatically (this is a reference consumer, not a broker with
|
|
23
|
+
# visibility timeouts -- see HONESTY below).
|
|
24
|
+
# file -- REAL. Always-available fallback for testing / airgapped clusters.
|
|
25
|
+
# Pops the OLDEST file from LOKI_QUEUE_DIR/pending atomically (mv to
|
|
26
|
+
# LOKI_QUEUE_DIR/processing), runs it, then moves it to done/ on
|
|
27
|
+
# success or failed/ on terminal failure.
|
|
28
|
+
#
|
|
29
|
+
# HONESTY (do not overstate this):
|
|
30
|
+
# - Only redis and file are shipped. SQS, Pub/Sub, RabbitMQ, Kafka, etc. are
|
|
31
|
+
# BRING-YOUR-OWN: override queue.command in values.yaml with your own
|
|
32
|
+
# consumer. They are documented, not implemented here.
|
|
33
|
+
# - The redis backend is at-most-once (LPOP-then-run). It has no visibility
|
|
34
|
+
# timeout / dead-letter requeue. If a build crashes after the item is popped,
|
|
35
|
+
# that item is lost from the queue. For at-least-once delivery use the file
|
|
36
|
+
# backend (a crashed build leaves the item in processing/ for manual
|
|
37
|
+
# re-drive) or bring a real broker.
|
|
38
|
+
# - The file backend's atomicity relies on `mv` being atomic within a single
|
|
39
|
+
# filesystem (true for a normal PVC). Two consumers racing the same pending
|
|
40
|
+
# dir is safe (mv either wins or fails-and-skips), but is not load-balanced.
|
|
41
|
+
#
|
|
42
|
+
# Robustness:
|
|
43
|
+
# - SIGTERM-graceful: a TERM/INT received mid-build lets the CURRENT item
|
|
44
|
+
# finish and ack, then exits cleanly (no half-acked item).
|
|
45
|
+
# - Bounded empty-poll backoff in loop mode; one-shot returns 0 on empty.
|
|
46
|
+
# - Never silently crash-loops: every exit path logs a reason.
|
|
47
|
+
# - set -u safe throughout.
|
|
48
|
+
#
|
|
49
|
+
# Environment:
|
|
50
|
+
# LOKI_QUEUE_BACKEND redis | file (default: redis if redis-cli present, else file)
|
|
51
|
+
# LOKI_QUEUE_ONESHOT 1 = process one item then exit (serverless); else loop (deployment)
|
|
52
|
+
# LOKI_QUEUE_KEY redis list key (default: loki-builds)
|
|
53
|
+
# LOKI_QUEUE_URL redis-cli -u URL (default: $REDIS_URL or redis://127.0.0.1:6379)
|
|
54
|
+
# LOKI_QUEUE_DIR file backend root (default: .loki/queue)
|
|
55
|
+
# LOKI_QUEUE_POLL_SEC loop-mode empty-poll wait, seconds (default: 5)
|
|
56
|
+
# LOKI_QUEUE_BLOCK_SEC redis BLPOP block timeout, seconds (default: 5)
|
|
57
|
+
# LOKI_TERMINAL_EXIT run.sh terminal-failure exit code (default: 20)
|
|
58
|
+
#===============================================================================
|
|
59
|
+
|
|
60
|
+
set -uo pipefail
|
|
61
|
+
|
|
62
|
+
LOG_PREFIX="[queue-consumer]"
|
|
63
|
+
|
|
64
|
+
log() { printf '%s %s\n' "$LOG_PREFIX" "$*" >&2; }
|
|
65
|
+
|
|
66
|
+
# --- configuration with safe defaults -----------------------------------------
|
|
67
|
+
QUEUE_KEY="${LOKI_QUEUE_KEY:-loki-builds}"
|
|
68
|
+
QUEUE_DIR="${LOKI_QUEUE_DIR:-.loki/queue}"
|
|
69
|
+
POLL_SEC="${LOKI_QUEUE_POLL_SEC:-5}"
|
|
70
|
+
BLOCK_SEC="${LOKI_QUEUE_BLOCK_SEC:-5}"
|
|
71
|
+
TERMINAL_EXIT="${LOKI_TERMINAL_EXIT:-20}"
|
|
72
|
+
ONESHOT="${LOKI_QUEUE_ONESHOT:-0}"
|
|
73
|
+
REDIS_URL_DEFAULT="${REDIS_URL:-redis://127.0.0.1:6379}"
|
|
74
|
+
QUEUE_URL="${LOKI_QUEUE_URL:-$REDIS_URL_DEFAULT}"
|
|
75
|
+
|
|
76
|
+
# Allow the loki binary to be overridden for tests (PATH stub) or vendored paths.
|
|
77
|
+
LOKI_BIN="${LOKI_BIN:-loki}"
|
|
78
|
+
|
|
79
|
+
# Backend selection: explicit env wins; else redis if redis-cli is present; else file.
|
|
80
|
+
select_backend() {
|
|
81
|
+
if [ -n "${LOKI_QUEUE_BACKEND:-}" ]; then
|
|
82
|
+
printf '%s' "$LOKI_QUEUE_BACKEND"
|
|
83
|
+
return 0
|
|
84
|
+
fi
|
|
85
|
+
if command -v redis-cli >/dev/null 2>&1; then
|
|
86
|
+
printf '%s' "redis"
|
|
87
|
+
else
|
|
88
|
+
printf '%s' "file"
|
|
89
|
+
fi
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# --- graceful shutdown --------------------------------------------------------
|
|
93
|
+
# A TERM/INT sets a flag. The loop checks it between items. If it arrives during
|
|
94
|
+
# a build, the build is allowed to finish and ack; only then do we exit. This
|
|
95
|
+
# guarantees no item is left half-processed by our own shutdown.
|
|
96
|
+
STOP_REQUESTED=0
|
|
97
|
+
request_stop() {
|
|
98
|
+
STOP_REQUESTED=1
|
|
99
|
+
log "shutdown signal received; will exit after the current item finishes"
|
|
100
|
+
}
|
|
101
|
+
trap request_stop TERM INT
|
|
102
|
+
|
|
103
|
+
# --- spec extraction ----------------------------------------------------------
|
|
104
|
+
# A work item may be a bare spec ref or a JSON object {"spec": "..."}. Extract
|
|
105
|
+
# the spec string. Bare refs pass through unchanged. JSON is parsed with python3
|
|
106
|
+
# (already a runtime dependency); if python3 is unavailable or parsing fails, the
|
|
107
|
+
# raw item is used as-is (a bare ref is the common case).
|
|
108
|
+
extract_spec() {
|
|
109
|
+
local item="$1"
|
|
110
|
+
# Trim leading/trailing whitespace/newlines.
|
|
111
|
+
item="${item#"${item%%[![:space:]]*}"}"
|
|
112
|
+
item="${item%"${item##*[![:space:]]}"}"
|
|
113
|
+
case "$item" in
|
|
114
|
+
'{'*)
|
|
115
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
116
|
+
local parsed
|
|
117
|
+
parsed="$(printf '%s' "$item" | python3 -c '
|
|
118
|
+
import sys, json
|
|
119
|
+
try:
|
|
120
|
+
d = json.load(sys.stdin)
|
|
121
|
+
s = d.get("spec", "") if isinstance(d, dict) else ""
|
|
122
|
+
sys.stdout.write(str(s))
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
' 2>/dev/null)"
|
|
126
|
+
if [ -n "$parsed" ]; then
|
|
127
|
+
printf '%s' "$parsed"
|
|
128
|
+
return 0
|
|
129
|
+
fi
|
|
130
|
+
fi
|
|
131
|
+
# Fall through: not parseable, return raw (caller may still reject).
|
|
132
|
+
printf '%s' "$item"
|
|
133
|
+
;;
|
|
134
|
+
*)
|
|
135
|
+
printf '%s' "$item"
|
|
136
|
+
;;
|
|
137
|
+
esac
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# --- run one build ------------------------------------------------------------
|
|
141
|
+
# Runs `loki start <spec>` for the given spec. Returns the build's exit code.
|
|
142
|
+
# An empty spec is treated as a terminal failure (a queue item with no spec is
|
|
143
|
+
# malformed; we do not silently run a no-spec codebase-analysis off the queue).
|
|
144
|
+
run_build() {
|
|
145
|
+
local spec="$1"
|
|
146
|
+
if [ -z "$spec" ]; then
|
|
147
|
+
log "ERROR: empty spec extracted from work item; treating as terminal failure"
|
|
148
|
+
return "$TERMINAL_EXIT"
|
|
149
|
+
fi
|
|
150
|
+
# Flag-injection guard: a queue item is untrusted (it came off the queue). A
|
|
151
|
+
# spec that begins with '-' would be parsed by `loki start` as a FLAG, not a
|
|
152
|
+
# PRD path / issue ref / brief (e.g. an item "--ship" would silently switch
|
|
153
|
+
# the build into auto-merge-PR mode). A leading-dash item is never a valid
|
|
154
|
+
# spec, so reject it as malformed rather than letting it steer the build.
|
|
155
|
+
# (The whole item is already a single argv element -- quoting holds -- so this
|
|
156
|
+
# is the remaining surface: a single known flag token.)
|
|
157
|
+
case "$spec" in
|
|
158
|
+
-*)
|
|
159
|
+
log "ERROR: work-item spec starts with '-' (would be parsed as a loki flag): $spec -- treating as terminal failure"
|
|
160
|
+
return "$TERMINAL_EXIT"
|
|
161
|
+
;;
|
|
162
|
+
esac
|
|
163
|
+
log "starting build: spec=$spec"
|
|
164
|
+
# End-of-options separator so even a future leading-dash that slips past the
|
|
165
|
+
# guard cannot be read as a flag (cmd_start gained a `--` handler).
|
|
166
|
+
"$LOKI_BIN" start -- "$spec"
|
|
167
|
+
local rc=$?
|
|
168
|
+
log "build finished: spec=$spec exit=$rc"
|
|
169
|
+
return "$rc"
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# =============================================================================
|
|
173
|
+
# Redis backend
|
|
174
|
+
# =============================================================================
|
|
175
|
+
redis_cli() {
|
|
176
|
+
redis-cli -u "$QUEUE_URL" "$@"
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# Pop one item from the redis list. In loop mode use BLPOP (blocks up to
|
|
180
|
+
# BLOCK_SEC, then returns empty so we can check the stop flag); in one-shot use
|
|
181
|
+
# LPOP (non-blocking, exits immediately on an empty queue).
|
|
182
|
+
# Prints the popped item to stdout, or nothing if the queue was empty.
|
|
183
|
+
redis_pop() {
|
|
184
|
+
if [ "$ONESHOT" = "1" ]; then
|
|
185
|
+
redis-cli -u "$QUEUE_URL" --no-raw LPOP "$QUEUE_KEY" 2>/dev/null | _redis_unquote
|
|
186
|
+
else
|
|
187
|
+
# BLPOP returns two lines: the key name, then the value. Take the value.
|
|
188
|
+
redis-cli -u "$QUEUE_URL" BLPOP "$QUEUE_KEY" "$BLOCK_SEC" 2>/dev/null | sed -n '2p'
|
|
189
|
+
fi
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# --no-raw LPOP wraps the value in quotes; strip a single surrounding pair and
|
|
193
|
+
# a literal "(nil)" sentinel. (BLPOP path uses raw output and skips this.)
|
|
194
|
+
_redis_unquote() {
|
|
195
|
+
local line
|
|
196
|
+
IFS= read -r line || true
|
|
197
|
+
[ "$line" = "(nil)" ] && return 0
|
|
198
|
+
# Strip one leading and trailing double quote if present.
|
|
199
|
+
line="${line#\"}"
|
|
200
|
+
line="${line%\"}"
|
|
201
|
+
printf '%s' "$line"
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
redis_consume_one() {
|
|
205
|
+
local item
|
|
206
|
+
item="$(redis_pop)"
|
|
207
|
+
if [ -z "$item" ]; then
|
|
208
|
+
return 100 # sentinel: queue empty
|
|
209
|
+
fi
|
|
210
|
+
local spec
|
|
211
|
+
spec="$(extract_spec "$item")"
|
|
212
|
+
run_build "$spec"
|
|
213
|
+
return $?
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
# =============================================================================
|
|
217
|
+
# File backend
|
|
218
|
+
# =============================================================================
|
|
219
|
+
# Layout under LOKI_QUEUE_DIR:
|
|
220
|
+
# pending/ work items waiting to be processed (one file each)
|
|
221
|
+
# processing/ the item currently being processed (atomically mv'd here)
|
|
222
|
+
# done/ successfully processed items
|
|
223
|
+
# failed/ terminally-failed items (exit == TERMINAL_EXIT or empty spec)
|
|
224
|
+
file_init_dirs() {
|
|
225
|
+
mkdir -p "$QUEUE_DIR/pending" "$QUEUE_DIR/processing" "$QUEUE_DIR/done" "$QUEUE_DIR/failed" 2>/dev/null || {
|
|
226
|
+
log "ERROR: cannot create queue directories under $QUEUE_DIR"
|
|
227
|
+
return 1
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
# Claim the oldest pending file by atomically mv'ing it to processing/. Prints
|
|
232
|
+
# the claimed processing-path on success; prints nothing if pending is empty.
|
|
233
|
+
# The mv is the atomic claim: if a racing consumer grabbed it first, mv fails and
|
|
234
|
+
# we move on to the next candidate.
|
|
235
|
+
file_claim_oldest() {
|
|
236
|
+
local f base dest
|
|
237
|
+
# Oldest by mtime. `ls -tr` lists oldest first; restrict to regular files.
|
|
238
|
+
while IFS= read -r f; do
|
|
239
|
+
[ -z "$f" ] && continue
|
|
240
|
+
[ -f "$f" ] || continue
|
|
241
|
+
base="$(basename "$f")"
|
|
242
|
+
dest="$QUEUE_DIR/processing/$base"
|
|
243
|
+
if mv "$f" "$dest" 2>/dev/null; then
|
|
244
|
+
printf '%s' "$dest"
|
|
245
|
+
return 0
|
|
246
|
+
fi
|
|
247
|
+
# mv failed (another consumer claimed it): try the next candidate.
|
|
248
|
+
done <<EOF
|
|
249
|
+
$(ls -tr "$QUEUE_DIR/pending" 2>/dev/null | while IFS= read -r n; do printf '%s/pending/%s\n' "$QUEUE_DIR" "$n"; done)
|
|
250
|
+
EOF
|
|
251
|
+
return 0 # nothing claimed
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
file_consume_one() {
|
|
255
|
+
file_init_dirs || return 1
|
|
256
|
+
local claimed
|
|
257
|
+
claimed="$(file_claim_oldest)"
|
|
258
|
+
if [ -z "$claimed" ]; then
|
|
259
|
+
return 100 # sentinel: queue empty
|
|
260
|
+
fi
|
|
261
|
+
local base
|
|
262
|
+
base="$(basename "$claimed")"
|
|
263
|
+
local item
|
|
264
|
+
item="$(cat "$claimed" 2>/dev/null)"
|
|
265
|
+
local spec
|
|
266
|
+
spec="$(extract_spec "$item")"
|
|
267
|
+
run_build "$spec"
|
|
268
|
+
local rc=$?
|
|
269
|
+
if [ "$rc" -eq 0 ]; then
|
|
270
|
+
mv "$claimed" "$QUEUE_DIR/done/$base" 2>/dev/null || log "WARN: could not move $base to done/"
|
|
271
|
+
elif [ "$rc" -eq "$TERMINAL_EXIT" ]; then
|
|
272
|
+
mv "$claimed" "$QUEUE_DIR/failed/$base" 2>/dev/null || log "WARN: could not move $base to failed/"
|
|
273
|
+
log "item $base TERMINAL-FAILED (exit $rc); moved to failed/, not acked"
|
|
274
|
+
else
|
|
275
|
+
# Transient crash: leave it in processing/ for manual re-drive. We do NOT
|
|
276
|
+
# auto-requeue (no retry counter in a flat dir); honest at-least-once.
|
|
277
|
+
log "item $base crashed (exit $rc); left in processing/ for re-drive"
|
|
278
|
+
fi
|
|
279
|
+
return "$rc"
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
# =============================================================================
|
|
283
|
+
# Driver
|
|
284
|
+
# =============================================================================
|
|
285
|
+
consume_one() {
|
|
286
|
+
case "$1" in
|
|
287
|
+
redis) redis_consume_one ;;
|
|
288
|
+
file) file_consume_one ;;
|
|
289
|
+
*)
|
|
290
|
+
log "ERROR: unknown LOKI_QUEUE_BACKEND='$1' (supported: redis, file)"
|
|
291
|
+
return 2
|
|
292
|
+
;;
|
|
293
|
+
esac
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
main() {
|
|
297
|
+
local backend
|
|
298
|
+
backend="$(select_backend)"
|
|
299
|
+
|
|
300
|
+
# Fail fast on a misconfigured redis backend rather than crash-looping.
|
|
301
|
+
if [ "$backend" = "redis" ] && ! command -v redis-cli >/dev/null 2>&1; then
|
|
302
|
+
log "ERROR: LOKI_QUEUE_BACKEND=redis but redis-cli is not on PATH"
|
|
303
|
+
return 2
|
|
304
|
+
fi
|
|
305
|
+
if [ "$backend" != "redis" ] && [ "$backend" != "file" ]; then
|
|
306
|
+
log "ERROR: unknown LOKI_QUEUE_BACKEND='$backend' (supported: redis, file)"
|
|
307
|
+
return 2
|
|
308
|
+
fi
|
|
309
|
+
|
|
310
|
+
if [ "$ONESHOT" = "1" ]; then
|
|
311
|
+
log "mode=oneshot backend=$backend (serverless: process one item then exit)"
|
|
312
|
+
consume_one "$backend"
|
|
313
|
+
local rc=$?
|
|
314
|
+
if [ "$rc" -eq 100 ]; then
|
|
315
|
+
log "queue empty; nothing to process; exiting 0"
|
|
316
|
+
return 0
|
|
317
|
+
fi
|
|
318
|
+
return "$rc"
|
|
319
|
+
fi
|
|
320
|
+
|
|
321
|
+
log "mode=loop backend=$backend (deployment: process items until SIGTERM)"
|
|
322
|
+
while [ "$STOP_REQUESTED" -ne 1 ]; do
|
|
323
|
+
consume_one "$backend"
|
|
324
|
+
local rc=$?
|
|
325
|
+
if [ "$rc" -eq 100 ]; then
|
|
326
|
+
# Empty queue. The redis BLPOP path already blocked; sleep only for
|
|
327
|
+
# the file backend (it polls). A pending stop check happens at loop
|
|
328
|
+
# top, so we never sleep through a shutdown longer than one interval.
|
|
329
|
+
if [ "$backend" = "file" ]; then
|
|
330
|
+
sleep "$POLL_SEC"
|
|
331
|
+
fi
|
|
332
|
+
continue
|
|
333
|
+
fi
|
|
334
|
+
if [ "$rc" -eq 2 ]; then
|
|
335
|
+
# Configuration error: do not hot-loop. Surface and exit non-zero.
|
|
336
|
+
log "fatal configuration error; exiting"
|
|
337
|
+
return 2
|
|
338
|
+
fi
|
|
339
|
+
# rc is a build result (0 success, TERMINAL_EXIT terminal, other crash).
|
|
340
|
+
# Build outcomes are normal operation, not consumer errors: keep looping.
|
|
341
|
+
done
|
|
342
|
+
log "stopped gracefully after current item; exiting 0"
|
|
343
|
+
return 0
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
main "$@"
|
package/autonomy/sandbox.sh
CHANGED
|
@@ -1156,6 +1156,33 @@ start_sandbox() {
|
|
|
1156
1156
|
log_info " Seccomp: enabled"
|
|
1157
1157
|
fi
|
|
1158
1158
|
|
|
1159
|
+
# A5+: workspace-mount allowlist enforcement (opt-in via LOKI_ALLOWED_PATHS).
|
|
1160
|
+
#
|
|
1161
|
+
# The workspace bind-mount below is where provider-driven agent file writes
|
|
1162
|
+
# actually land (the agent writes inside /workspace, which is $PROJECT_DIR on
|
|
1163
|
+
# the host). Unlike the custom --mount surface, this mount was previously
|
|
1164
|
+
# always made writable regardless of LOKI_ALLOWED_PATHS, so an allowlist that
|
|
1165
|
+
# did not include the project dir was silently ignored for the main write
|
|
1166
|
+
# surface.
|
|
1167
|
+
#
|
|
1168
|
+
# When LOKI_ALLOWED_PATHS is set and the workspace itself is OUTSIDE the
|
|
1169
|
+
# allowlist, fail closed: refuse to start rather than bind-mount it writable.
|
|
1170
|
+
# This is genuinely enforceable because docker mount mode (rw vs ro) and which
|
|
1171
|
+
# host paths get bound are decided HERE, before the container exists -- the
|
|
1172
|
+
# kernel enforces the resulting mount, not a wrapper check. We refuse rather
|
|
1173
|
+
# than auto-downgrade to :ro because a read-only workspace cannot be written
|
|
1174
|
+
# by the agent at all and would silently produce a no-op build; refusing makes
|
|
1175
|
+
# the misconfiguration visible. (Allowlisted extra paths are still mounted via
|
|
1176
|
+
# the custom --mount surface above, which also enforces the allowlist.)
|
|
1177
|
+
#
|
|
1178
|
+
# When LOKI_ALLOWED_PATHS is empty (default), _sandbox_path_within_allowed
|
|
1179
|
+
# returns 0 unconditionally, so this is byte-identical to prior behavior.
|
|
1180
|
+
if ! _sandbox_path_within_allowed "$PROJECT_DIR"; then
|
|
1181
|
+
log_error "Workspace is outside LOKI_ALLOWED_PATHS, refusing to mount it writable: $PROJECT_DIR"
|
|
1182
|
+
log_error " Add the project directory to LOKI_ALLOWED_PATHS, or unset LOKI_ALLOWED_PATHS to disable path enforcement."
|
|
1183
|
+
return 1
|
|
1184
|
+
fi
|
|
1185
|
+
|
|
1159
1186
|
# Mount project directory
|
|
1160
1187
|
if [[ "$SANDBOX_READONLY" == "true" ]]; then
|
|
1161
1188
|
docker_args+=("--volume" "$PROJECT_DIR:/workspace:ro")
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -3316,6 +3316,224 @@ async def stop_running_project(request: Request, body: RunningProjectStopRequest
|
|
|
3316
3316
|
}
|
|
3317
3317
|
|
|
3318
3318
|
|
|
3319
|
+
def _recover_spec_source(path: str) -> Optional[_Path]:
|
|
3320
|
+
"""Find a re-launchable spec source inside a registry-stored project path.
|
|
3321
|
+
|
|
3322
|
+
Retry re-runs `loki start` from a project's own working directory, so the
|
|
3323
|
+
spec must already live there. This never accepts a caller-supplied path: the
|
|
3324
|
+
project path comes from the registry and only well-known in-tree locations
|
|
3325
|
+
are probed. Returns the first existing spec as an absolute Path, or None when
|
|
3326
|
+
nothing re-launchable is found (the caller then refuses honestly rather than
|
|
3327
|
+
spawning a run that would no-op).
|
|
3328
|
+
|
|
3329
|
+
Probe order (highest fidelity first):
|
|
3330
|
+
1. A hand-authored PRD in the project root or docs/ (PRD.md, prd.md,
|
|
3331
|
+
docs/PRD.md, docs/prd.md) -- the same set check_project_health uses.
|
|
3332
|
+
2. A previously generated PRD (.loki/generated-prd.md / .json) written by
|
|
3333
|
+
a prior no-PRD codebase-analysis run.
|
|
3334
|
+
3. The most recent dashboard inline spec (.loki/specs/*.md) written by the
|
|
3335
|
+
browser PRD-input flow.
|
|
3336
|
+
"""
|
|
3337
|
+
if not path:
|
|
3338
|
+
return None
|
|
3339
|
+
try:
|
|
3340
|
+
base = _Path(path)
|
|
3341
|
+
except (TypeError, ValueError):
|
|
3342
|
+
return None
|
|
3343
|
+
if not base.is_dir():
|
|
3344
|
+
return None
|
|
3345
|
+
|
|
3346
|
+
# 1. Hand-authored PRD.
|
|
3347
|
+
for rel in ("PRD.md", "prd.md", "docs/PRD.md", "docs/prd.md"):
|
|
3348
|
+
candidate = base / rel
|
|
3349
|
+
if candidate.is_file():
|
|
3350
|
+
return candidate.resolve()
|
|
3351
|
+
|
|
3352
|
+
loki_dir = base / ".loki"
|
|
3353
|
+
|
|
3354
|
+
# 2. Previously generated PRD.
|
|
3355
|
+
for rel in ("generated-prd.md", "generated-prd.json"):
|
|
3356
|
+
candidate = loki_dir / rel
|
|
3357
|
+
if candidate.is_file():
|
|
3358
|
+
return candidate.resolve()
|
|
3359
|
+
|
|
3360
|
+
# 3. Most recent dashboard inline spec.
|
|
3361
|
+
specs_dir = loki_dir / "specs"
|
|
3362
|
+
if specs_dir.is_dir():
|
|
3363
|
+
try:
|
|
3364
|
+
specs = sorted(
|
|
3365
|
+
(s for s in specs_dir.glob("*.md") if s.is_file()),
|
|
3366
|
+
key=lambda s: s.stat().st_mtime,
|
|
3367
|
+
reverse=True,
|
|
3368
|
+
)
|
|
3369
|
+
except OSError:
|
|
3370
|
+
specs = []
|
|
3371
|
+
if specs:
|
|
3372
|
+
return specs[0].resolve()
|
|
3373
|
+
|
|
3374
|
+
return None
|
|
3375
|
+
|
|
3376
|
+
|
|
3377
|
+
@app.post(
|
|
3378
|
+
"/api/fleet/runs/{identifier}/retry",
|
|
3379
|
+
dependencies=[Depends(auth.require_scope("control"))],
|
|
3380
|
+
)
|
|
3381
|
+
async def retry_fleet_run(request: Request, identifier: str):
|
|
3382
|
+
"""Re-launch ONE finished/failed build in the fleet view.
|
|
3383
|
+
|
|
3384
|
+
Resolves the run via the registry (by id / path / alias), exactly like
|
|
3385
|
+
cancel_fleet_run, so the caller identifier is NEVER treated as a filesystem
|
|
3386
|
+
path. Retry re-runs `loki start` (via run.sh) from the project's own stored
|
|
3387
|
+
working directory against a spec source recovered from that directory.
|
|
3388
|
+
|
|
3389
|
+
Guards:
|
|
3390
|
+
- Refuses with 409 if the project is currently running (live pid probe or a
|
|
3391
|
+
fresh session.json), so a retry never double-launches an active build.
|
|
3392
|
+
- Refuses with 409 if no re-launchable spec source exists in the project
|
|
3393
|
+
directory (an honest refusal: retry needs the original spec or working
|
|
3394
|
+
dir; we do not fabricate a launch that would no-op).
|
|
3395
|
+
|
|
3396
|
+
On success the runner re-registers the project as running with its own pid
|
|
3397
|
+
(loki_register_running_project in run.sh); we also flip the registry status
|
|
3398
|
+
to running immediately so the fleet view reflects the relaunch without
|
|
3399
|
+
waiting for the runner's first registry write.
|
|
3400
|
+
"""
|
|
3401
|
+
if not _control_limiter.check("control"):
|
|
3402
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
3403
|
+
|
|
3404
|
+
project = registry.get_project(identifier)
|
|
3405
|
+
if not project:
|
|
3406
|
+
raise HTTPException(status_code=404, detail="Run not found in fleet")
|
|
3407
|
+
|
|
3408
|
+
project_id = project.get("id")
|
|
3409
|
+
audit.log_event(
|
|
3410
|
+
action="retry",
|
|
3411
|
+
resource_type="fleet_run",
|
|
3412
|
+
details={"source": "api", "project_id": project_id},
|
|
3413
|
+
ip_address=request.client.host if request.client else None,
|
|
3414
|
+
)
|
|
3415
|
+
|
|
3416
|
+
# Only ever operate on the registry-stored path (never the identifier).
|
|
3417
|
+
path = project.get("path", "")
|
|
3418
|
+
if not path:
|
|
3419
|
+
raise HTTPException(
|
|
3420
|
+
status_code=409,
|
|
3421
|
+
detail="Project has no recorded working directory; retry needs the original working dir",
|
|
3422
|
+
)
|
|
3423
|
+
proj_dir = _Path(path)
|
|
3424
|
+
if not proj_dir.is_dir():
|
|
3425
|
+
raise HTTPException(
|
|
3426
|
+
status_code=409,
|
|
3427
|
+
detail=f"Project directory no longer exists: {path}",
|
|
3428
|
+
)
|
|
3429
|
+
proj_dir = proj_dir.resolve()
|
|
3430
|
+
loki_dir = proj_dir / ".loki"
|
|
3431
|
+
|
|
3432
|
+
# Recover a re-launchable spec from the project's own directory FIRST (before
|
|
3433
|
+
# the claim, so we can refuse honestly without holding the lock). Honest
|
|
3434
|
+
# refusal when nothing usable is present (no fabricated no-op launch).
|
|
3435
|
+
spec_file = await asyncio.to_thread(_recover_spec_source, str(proj_dir))
|
|
3436
|
+
if spec_file is None:
|
|
3437
|
+
raise HTTPException(
|
|
3438
|
+
status_code=409,
|
|
3439
|
+
detail=(
|
|
3440
|
+
"No re-launchable spec found in the project directory. Retry "
|
|
3441
|
+
"needs the original spec (PRD.md, .loki/generated-prd.md, or a "
|
|
3442
|
+
"prior dashboard spec) or the original working dir."
|
|
3443
|
+
),
|
|
3444
|
+
)
|
|
3445
|
+
|
|
3446
|
+
# Locate run.sh (same resolver start_build uses).
|
|
3447
|
+
skill_dir = find_skill_dir()
|
|
3448
|
+
run_sh = skill_dir / "autonomy" / "run.sh"
|
|
3449
|
+
if not run_sh.exists():
|
|
3450
|
+
raise HTTPException(status_code=500, detail=f"run.sh not found at {run_sh}")
|
|
3451
|
+
|
|
3452
|
+
# Atomic check-and-CLAIM (closes the double-launch TOCTOU). Two concurrent
|
|
3453
|
+
# retry calls on the same stopped project must not both spawn. Under the
|
|
3454
|
+
# registry lock we re-read the live entry, refuse if it is running (pid alive,
|
|
3455
|
+
# the project's own session staleness window, or already "launching" -- a
|
|
3456
|
+
# sibling that just claimed it), else stamp status="launching" + save. The
|
|
3457
|
+
# lock + persisted "launching" marker make the window indivisible: the second
|
|
3458
|
+
# caller sees "launching" and is refused before it can spawn.
|
|
3459
|
+
_live_pid = project.get("pid")
|
|
3460
|
+
if registry._pid_alive(_live_pid):
|
|
3461
|
+
raise HTTPException(
|
|
3462
|
+
status_code=409,
|
|
3463
|
+
detail="Project is currently running; cancel it before retrying",
|
|
3464
|
+
)
|
|
3465
|
+
if loki_dir.is_dir() and _project_run_active(loki_dir) is not None:
|
|
3466
|
+
raise HTTPException(
|
|
3467
|
+
status_code=409,
|
|
3468
|
+
detail="A build is already running in this project",
|
|
3469
|
+
)
|
|
3470
|
+
with registry._registry_lock():
|
|
3471
|
+
reg = registry._load_registry()
|
|
3472
|
+
entry = reg.get("projects", {}).get(project_id)
|
|
3473
|
+
if entry is not None:
|
|
3474
|
+
cur_status = entry.get("status")
|
|
3475
|
+
cur_pid = entry.get("pid")
|
|
3476
|
+
if cur_status == "launching" or registry._pid_alive(cur_pid):
|
|
3477
|
+
raise HTTPException(
|
|
3478
|
+
status_code=409,
|
|
3479
|
+
detail="A retry for this project is already in progress",
|
|
3480
|
+
)
|
|
3481
|
+
entry["status"] = "launching"
|
|
3482
|
+
entry["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
3483
|
+
registry._save_registry(reg)
|
|
3484
|
+
|
|
3485
|
+
# Re-launch from the project's own CWD, mirroring start_build's spawn.
|
|
3486
|
+
args = [str(run_sh), "--bg", str(spec_file)]
|
|
3487
|
+
try:
|
|
3488
|
+
process = subprocess.Popen(
|
|
3489
|
+
args,
|
|
3490
|
+
stdout=subprocess.DEVNULL,
|
|
3491
|
+
stderr=subprocess.DEVNULL,
|
|
3492
|
+
start_new_session=True,
|
|
3493
|
+
cwd=str(proj_dir),
|
|
3494
|
+
)
|
|
3495
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
3496
|
+
# Release the "launching" claim so a failed spawn does not wedge the
|
|
3497
|
+
# project (else every future retry would be refused as in-progress).
|
|
3498
|
+
try:
|
|
3499
|
+
with registry._registry_lock():
|
|
3500
|
+
reg = registry._load_registry()
|
|
3501
|
+
entry = reg.get("projects", {}).get(project_id)
|
|
3502
|
+
if entry is not None and entry.get("status") == "launching":
|
|
3503
|
+
entry["status"] = "stopped"
|
|
3504
|
+
entry["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
3505
|
+
registry._save_registry(reg)
|
|
3506
|
+
except Exception:
|
|
3507
|
+
pass
|
|
3508
|
+
raise HTTPException(status_code=500, detail=f"Failed to retry build: {e}")
|
|
3509
|
+
|
|
3510
|
+
# Flip the registry status to running immediately. The runner also
|
|
3511
|
+
# re-registers with its own pid on startup, but updating here gives the
|
|
3512
|
+
# fleet view an instant, accurate reflection of the relaunch. The
|
|
3513
|
+
# load->mutate->save runs under the registry lock so it does not lost-update
|
|
3514
|
+
# against the runner's concurrent re-registration.
|
|
3515
|
+
try:
|
|
3516
|
+
with registry._registry_lock():
|
|
3517
|
+
reg = registry._load_registry()
|
|
3518
|
+
if project_id in reg.get("projects", {}):
|
|
3519
|
+
reg["projects"][project_id]["status"] = "running"
|
|
3520
|
+
reg["projects"][project_id]["pid"] = process.pid
|
|
3521
|
+
reg["projects"][project_id]["updated_at"] = datetime.now(
|
|
3522
|
+
timezone.utc
|
|
3523
|
+
).isoformat()
|
|
3524
|
+
registry._save_registry(reg)
|
|
3525
|
+
except Exception:
|
|
3526
|
+
pass
|
|
3527
|
+
|
|
3528
|
+
return {
|
|
3529
|
+
"success": True,
|
|
3530
|
+
"project_id": project_id,
|
|
3531
|
+
"retried": True,
|
|
3532
|
+
"pid": process.pid,
|
|
3533
|
+
"spec": str(spec_file),
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3536
|
+
|
|
3319
3537
|
@app.post(
|
|
3320
3538
|
"/api/fleet/runs/{identifier}/cancel",
|
|
3321
3539
|
dependencies=[Depends(auth.require_scope("control"))],
|
|
@@ -3330,9 +3548,10 @@ async def cancel_fleet_run(request: Request, identifier: str):
|
|
|
3330
3548
|
recorded orchestrator pid, group-kill + cwd-scoped reap as backstop, then
|
|
3331
3549
|
mark the registry/session stopped.
|
|
3332
3550
|
|
|
3333
|
-
Retry is
|
|
3334
|
-
|
|
3335
|
-
|
|
3551
|
+
Retry (re-launch) is exposed separately at
|
|
3552
|
+
POST /api/fleet/runs/{identifier}/retry, which re-runs `loki start` from
|
|
3553
|
+
the registry-stored project directory against a spec recovered from that
|
|
3554
|
+
directory (refuses honestly when none exists).
|
|
3336
3555
|
"""
|
|
3337
3556
|
if not _control_limiter.check("control"):
|
|
3338
3557
|
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
package/docs/INSTALLATION.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
|
|
4
4
|
|
|
5
|
-
**Version:** v7.
|
|
5
|
+
**Version:** v7.81.0
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -395,7 +395,7 @@ provider works inside the container. Provide auth with your Anthropic API key:
|
|
|
395
395
|
# Run Loki Mode in Docker (Claude provider, API-key auth)
|
|
396
396
|
docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
|
|
397
397
|
-v $(pwd):/workspace -w /workspace \
|
|
398
|
-
asklokesh/loki-mode:7.
|
|
398
|
+
asklokesh/loki-mode:7.81.0 start ./my-spec.md
|
|
399
399
|
```
|
|
400
400
|
|
|
401
401
|
##### docker compose + .env (no host install)
|
package/loki-ts/dist/loki.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var QQ=Object.defineProperty;var ZQ=($)=>$;function zQ($,Q){this[$]=ZQ.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)QQ($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:zQ.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var h1={};b(h1,{lokiDir:()=>P,homeLokiDir:()=>i$,findRepoRootForVersion:()=>t$,REPO_ROOT:()=>g});import{resolve as a,dirname as r$}from"path";import{fileURLToPath as XQ}from"url";import{existsSync as R$}from"fs";import{homedir as KQ}from"os";function qQ(){let $=b1;for(let Q=0;Q<6;Q++){if(R$(a($,"VERSION"))&&R$(a($,"autonomy/run.sh")))return $;let Z=r$($);if(Z===$)break;$=Z}return a(b1,"..","..","..")}function t$($){let Q=$;for(let Z=0;Z<6;Z++){if(R$(a(Q,"VERSION"))&&R$(a(Q,"autonomy/run.sh")))return Q;let z=r$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function P(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function i$(){return a(KQ(),".loki")}var b1,g;var C=L(()=>{b1=r$(XQ(import.meta.url));g=qQ()});import{readFileSync as VQ}from"fs";import{resolve as JQ,dirname as UQ}from"path";import{fileURLToPath as WQ}from"url";function E$(){if(Q$!==null)return Q$;let $="7.
|
|
2
|
+
var QQ=Object.defineProperty;var ZQ=($)=>$;function zQ($,Q){this[$]=ZQ.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)QQ($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:zQ.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var h1={};b(h1,{lokiDir:()=>P,homeLokiDir:()=>i$,findRepoRootForVersion:()=>t$,REPO_ROOT:()=>g});import{resolve as a,dirname as r$}from"path";import{fileURLToPath as XQ}from"url";import{existsSync as R$}from"fs";import{homedir as KQ}from"os";function qQ(){let $=b1;for(let Q=0;Q<6;Q++){if(R$(a($,"VERSION"))&&R$(a($,"autonomy/run.sh")))return $;let Z=r$($);if(Z===$)break;$=Z}return a(b1,"..","..","..")}function t$($){let Q=$;for(let Z=0;Z<6;Z++){if(R$(a(Q,"VERSION"))&&R$(a(Q,"autonomy/run.sh")))return Q;let z=r$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function P(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function i$(){return a(KQ(),".loki")}var b1,g;var C=L(()=>{b1=r$(XQ(import.meta.url));g=qQ()});import{readFileSync as VQ}from"fs";import{resolve as JQ,dirname as UQ}from"path";import{fileURLToPath as WQ}from"url";function E$(){if(Q$!==null)return Q$;let $="7.81.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=UQ(WQ(import.meta.url)),Z=t$(Q);Q$=VQ(JQ(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var e$=L(()=>{C()});var g1={};b(g1,{runOrThrow:()=>HQ,run:()=>F,readStreamCapped:()=>m1,commandVersion:()=>BQ,commandExists:()=>f,ShellError:()=>$1,MAX_STDOUT_BYTES:()=>v1});async function m1($,Q=v1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:U}=await Z.read();if(K)break;if(!U)continue;if(q+=U.byteLength,q>Q){let J=U.byteLength-(q-Q);X+=z.decode(U.subarray(0,J),{stream:!0});break}X+=z.decode(U,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,U]=await Promise.all([m1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:U}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function HQ($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new $1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=GQ($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function GQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function BQ($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var v1=16777216,$1;var d=L(()=>{$1=class $1 extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function s($){return YQ?"":$}var YQ,O,S,_,_Z,I,k,h,V;var c=L(()=>{YQ=(process.env.NO_COLOR??"").length>0;O=s("\x1B[0;31m"),S=s("\x1B[0;32m"),_=s("\x1B[1;33m"),_Z=s("\x1B[0;34m"),I=s("\x1B[0;36m"),k=s("\x1B[1m"),h=s("\x1B[2m"),V=s("\x1B[0m")});import{existsSync as jQ}from"fs";async function Z$(){if(Y$!==void 0)return Y$;let $="/opt/homebrew/bin/python3.12";if(jQ($))return Y$=$,$;let Q=await f("python3.12");if(Q)return Y$=Q,Q;let Z=await f("python3");return Y$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var Y$;var V$=L(()=>{d()});var X0={};b(X0,{runStatus:()=>oQ});import{existsSync as y,readFileSync as U$,readdirSync as r1,statSync as t1}from"fs";import{resolve as D,basename as vQ}from"path";import{homedir as mQ}from"os";function i1($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function e1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*N$/Q);if(X>N$)X=N$;let q=N$-X,K=S;if(z>=80)K=O;else if(z>=50)K=_;let U="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=i1($),W=i1(Q);return` ${k}${Z}${V} ${K}[${U}]${V} ${z}% (${J} / ${W})`}async function fQ(){if(await f("jq"))return!0;return process.stdout.write(`${O}Error: jq is required but not installed.${V}
|
|
3
3
|
`),process.stdout.write(`Install with:
|
|
4
4
|
`),process.stdout.write(` brew install jq (macOS)
|
|
5
5
|
`),process.stdout.write(` apt install jq (Debian/Ubuntu)
|
|
@@ -793,4 +793,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
|
|
|
793
793
|
`),2}default:return process.stderr.write(`Unknown command: ${Q}
|
|
794
794
|
`),process.stderr.write($Q),2}}s1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var qZ=await KZ(Bun.argv.slice(2));process.exit(qZ);
|
|
795
795
|
|
|
796
|
-
//# debugId=
|
|
796
|
+
//# debugId=C0A7A027BE2CCC6B64756E2164756E21
|
package/mcp/__init__.py
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loki-mode",
|
|
3
3
|
"mcpName": "io.github.asklokesh/loki-mode",
|
|
4
|
-
"version": "7.
|
|
4
|
+
"version": "7.81.0",
|
|
5
5
|
"description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"agent",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "loki-mode",
|
|
4
4
|
"displayName": "Loki Mode",
|
|
5
|
-
"version": "7.
|
|
5
|
+
"version": "7.81.0",
|
|
6
6
|
"description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Autonomi",
|