loki-mode 10.6.7 → 10.6.8
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 +35 -1
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/workspace.py +290 -0
- package/autonomy/lib/worktree_prep.py +143 -0
- package/autonomy/loki +25 -0
- package/bin/loki +1 -1
- package/completions/_loki +1 -0
- package/completions/loki.bash +1 -2
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/WANG-PRINCIPLES-PLAN.md +1 -1
- package/docs/WORKSPACES.md +29 -0
- package/docs/alternative-installations.md +3 -1
- package/docs/control-plane-container.md +45 -0
- package/docs/dev/architecture-reference.md +4 -0
- package/docs/dev/project-structure.md +1 -1
- package/docs/slack.md +26 -0
- package/docs/v10/BOARD.md +26 -1
- package/docs/v10/CONTROL-PLANE.md +4 -0
- package/docs/v10/D61-SPEED.md +7 -2
- package/docs/v10/DECISIONS.md +24 -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 +13 -0
- package/docs/v10/ROADMAP-D63.md +35 -0
- package/loki-ts/dist/loki.js +528 -491
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +76 -0
- package/mcp/tests/test_v10_tools.py +137 -0
- package/mcp/v10_tools.py +172 -0
- package/package.json +1 -1
- package/packages/control-plane/dist/server.js +56 -4
- package/packages/control-plane/src/server/answer.ts +33 -0
- package/packages/control-plane/src/server/app.ts +17 -1
- package/packages/control-plane/src/server/runs.ts +2 -0
- package/packages/control-plane/src/server/serve.ts +2 -2
- package/packages/control-plane/ui/dist/assets/index-53E32yrO.js +114 -0
- package/packages/control-plane/ui/dist/assets/{index-BH3LtKcG.css → index-CFfrqCx2.css} +1 -1
- 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-DN_pe8vo.js} +1 -1
- package/web-app/dist/assets/{Avatar-CY0sQNao.js → Avatar-BnQsr8z-.js} +1 -1
- package/web-app/dist/assets/{Badge-BmwTRu72.js → Badge-DzERiZn2.js} +1 -1
- package/web-app/dist/assets/{Button-BRf5aTlb.js → Button-CaaoxQAt.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-RR94HuxS.js → CockpitPage-SXhnysJA.js} +1 -1
- package/web-app/dist/assets/{ComparePage-CFWAuPRO.js → ComparePage-krxTCFJY.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-4cDADrG-.js → ErrorBoundary-BL96ESMc.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-ChPFx2eM.js → EvidenceReceiptPanel-PUiGSpkB.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-B0TAjyeW.js → GitHubIssuesPanel-C0hPs-P2.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-BbszEwW9.js → GitHubPRsPanel-CLvqNUlu.js} +1 -1
- package/web-app/dist/assets/{HomePage-DrBgzs1d.js → HomePage-X-sq_Rc9.js} +2 -2
- package/web-app/dist/assets/{LoginPage-Bu7b2DGz.js → LoginPage-DhQuwdua.js} +1 -1
- package/web-app/dist/assets/{MagicPage-BFEYp631.js → MagicPage-CP2ODjfn.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-eIe2naVR.js → MetricsPage-C6SYiQES.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-BVWYkNt9.js → NotFoundPage-CCDxxYc-.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-zmxyu732.js → ProjectPage-yDF--8FQ.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-Dq5NdCak.js → ProjectsPage-BBLspdLn.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-CCrJOMe0.js → SettingsPage-C_sWbQRw.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-n-Ipzp01.js → ShowcasePage-xCMuL0N8.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-CP_rjYXK.js → SystemSettingsPage-DnJKGCx7.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-B_P9DepW.js → TeamsPage-BBXdhrSs.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-h-3A6PQT.js → TemplatesPage-AZ1Lr1rn.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-pD0nJ1BU.js → TerminalOutput-CtIm9tXc.js} +1 -1
- package/web-app/dist/assets/{activity-ChFwzT-d.js → activity-Dk1FK25Q.js} +1 -1
- package/web-app/dist/assets/{bell-BX-eK8Gu.js → bell-dhqXyzP4.js} +1 -1
- package/web-app/dist/assets/{bot-fj5PkPtR.js → bot-CExHd4Kf.js} +1 -1
- package/web-app/dist/assets/{check-BGAbHlvD.js → check-D51zaA1a.js} +1 -1
- package/web-app/dist/assets/{chevron-left-BzFUxHgb.js → chevron-left-DEwnQzH1.js} +1 -1
- package/web-app/dist/assets/{circle-alert-BQrg5DWl.js → circle-alert-C6U90Msk.js} +1 -1
- package/web-app/dist/assets/{clock-CTu9lkVo.js → clock-6mFbMzzH.js} +1 -1
- package/web-app/dist/assets/{cloud-DrIENRiE.js → cloud-BrZZtE7F.js} +1 -1
- package/web-app/dist/assets/{code-xml-PaoVpfur.js → code-xml-DTFzeepP.js} +1 -1
- package/web-app/dist/assets/{database-DMmkAotn.js → database-B5rEiTre.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-B2LQdBoX.js → dollar-sign-bQs4rlS_.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-Dfv1XXeR.js → file-code-corner-Mo6e6qbf.js} +1 -1
- package/web-app/dist/assets/{file-plus-D2Wzf2wd.js → file-plus-DzHZ0jpP.js} +1 -1
- package/web-app/dist/assets/{globe-DxotVWt8.js → globe-DYN9UhTa.js} +1 -1
- package/web-app/dist/assets/{hammer-Brfy5ZrJ.js → hammer-bB0hSRcJ.js} +1 -1
- package/web-app/dist/assets/{index-BfhHMwi8.js → index-CIH5CqZB.js} +3 -3
- package/web-app/dist/assets/{layers-DfxSWDvp.js → layers-qhFkY8Nu.js} +1 -1
- package/web-app/dist/assets/{loader-circle-CxIrzNwx.js → loader-circle-BGBkaYH7.js} +1 -1
- package/web-app/dist/assets/{lock-m6nzhtpU.js → lock-DIcsNFum.js} +1 -1
- package/web-app/dist/assets/{package-ri9oE9Jc.js → package-BdIIbZhS.js} +1 -1
- package/web-app/dist/assets/{plus-DWeTAMVV.js → plus-Co1Bv8zT.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-BGMSpaMh.js → refresh-cw-CGF4ABWH.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-DD1xBD_b.js → rotate-ccw-BX10Q79h.js} +1 -1
- package/web-app/dist/assets/{scroll-text-BCCR_AN9.js → scroll-text-CbXDlpGp.js} +1 -1
- package/web-app/dist/assets/{server-CmMWPIjS.js → server-CIhi6yUO.js} +1 -1
- package/web-app/dist/assets/{shield-alert-BZZmBVEJ.js → shield-alert-DJ5fB6Dg.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-OaxbNRXu.js → thumbs-up-BEft2T94.js} +1 -1
- package/web-app/dist/assets/{trash-2-DmcSzTfr.js → trash-2-CWGUPn7Y.js} +1 -1
- package/web-app/dist/assets/{trending-down-BmfXLykz.js → trending-down-CjqstvxQ.js} +1 -1
- package/web-app/dist/assets/{trending-up-Djec3jIq.js → trending-up-CTyLjMzE.js} +1 -1
- package/web-app/dist/assets/{upload-BN8dXIlF.js → upload-BmRx-uv4.js} +1 -1
- package/web-app/dist/assets/{usePolling-Cu4yi04I.js → usePolling-ChHkSJye.js} +1 -1
- package/web-app/dist/assets/{user-IBI-k10o.js → user-ZXh_f35z.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/packages/control-plane/ui/dist/assets/index-B6EiyukW.js +0 -114
package/mcp/__init__.py
CHANGED
package/mcp/server.py
CHANGED
|
@@ -1368,6 +1368,82 @@ async def get_pending_tasks() -> str:
|
|
|
1368
1368
|
# ENTERPRISE TOOLS (P0-1)
|
|
1369
1369
|
# ============================================================
|
|
1370
1370
|
|
|
1371
|
+
@mcp.tool()
|
|
1372
|
+
async def loki_v10_run(ref: str, repo_path: str) -> str:
|
|
1373
|
+
"""
|
|
1374
|
+
Start a Loki 10 run in the background (same engine as `loki <ref>`).
|
|
1375
|
+
|
|
1376
|
+
Args:
|
|
1377
|
+
ref: Task text or issue reference (owner/repo#N or issue URL)
|
|
1378
|
+
repo_path: Repository directory (must be inside the project root)
|
|
1379
|
+
|
|
1380
|
+
Returns:
|
|
1381
|
+
JSON with run_id (null if not yet created), pid and log_path. Never blocks.
|
|
1382
|
+
"""
|
|
1383
|
+
_emit_tool_event_async('loki_v10_run', 'start', parameters={'repo_path': repo_path})
|
|
1384
|
+
try:
|
|
1385
|
+
from mcp import v10_tools
|
|
1386
|
+
result = await asyncio.to_thread(
|
|
1387
|
+
v10_tools.v10_run, ref, repo_path, lambda p: validate_path(p, allowed_dirs=['.']))
|
|
1388
|
+
_emit_tool_event_async('loki_v10_run', 'complete', result_status='error' if isinstance(result, dict) and result.get('error') else 'success')
|
|
1389
|
+
return json.dumps(result)
|
|
1390
|
+
except Exception as e:
|
|
1391
|
+
logger.error(f"loki_v10_run failed: {e}")
|
|
1392
|
+
_emit_tool_event_async('loki_v10_run', 'complete', result_status='error', error=str(e))
|
|
1393
|
+
return json.dumps({"error": str(e)})
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
@mcp.tool()
|
|
1397
|
+
async def loki_v10_status(repo_path: str, run_id: str = "") -> str:
|
|
1398
|
+
"""
|
|
1399
|
+
Read the state of a Loki 10 run from its events log.
|
|
1400
|
+
|
|
1401
|
+
Args:
|
|
1402
|
+
repo_path: Repository directory containing .loki/runs
|
|
1403
|
+
run_id: Run id (e10-...); defaults to the newest run
|
|
1404
|
+
|
|
1405
|
+
Returns:
|
|
1406
|
+
JSON with phase, done, verdict (once done) and cost_usd (null if unmeasured)
|
|
1407
|
+
"""
|
|
1408
|
+
_emit_tool_event_async('loki_v10_status', 'start', parameters={'repo_path': repo_path, 'run_id': run_id})
|
|
1409
|
+
try:
|
|
1410
|
+
from mcp import v10_tools
|
|
1411
|
+
result = v10_tools.v10_status(
|
|
1412
|
+
run_id, repo_path, lambda p: validate_path(p, allowed_dirs=['.']))
|
|
1413
|
+
_emit_tool_event_async('loki_v10_status', 'complete', result_status='error' if isinstance(result, dict) and result.get('error') else 'success')
|
|
1414
|
+
return json.dumps(result)
|
|
1415
|
+
except Exception as e:
|
|
1416
|
+
logger.error(f"loki_v10_status failed: {e}")
|
|
1417
|
+
_emit_tool_event_async('loki_v10_status', 'complete', result_status='error', error=str(e))
|
|
1418
|
+
return json.dumps({"error": str(e)})
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
@mcp.tool()
|
|
1422
|
+
async def loki_v10_verify(receipt_path: str = "", repo_path: str = "") -> str:
|
|
1423
|
+
"""
|
|
1424
|
+
Run `loki verify` on a receipt (or the newest run of a repo).
|
|
1425
|
+
|
|
1426
|
+
Args:
|
|
1427
|
+
receipt_path: Path to a receipt.json (takes priority)
|
|
1428
|
+
repo_path: Repository directory; verifies its newest run
|
|
1429
|
+
|
|
1430
|
+
Returns:
|
|
1431
|
+
JSON with exit_code, verified and an output summary
|
|
1432
|
+
"""
|
|
1433
|
+
_emit_tool_event_async('loki_v10_verify', 'start', parameters={'receipt_path': receipt_path, 'repo_path': repo_path})
|
|
1434
|
+
try:
|
|
1435
|
+
from mcp import v10_tools
|
|
1436
|
+
result = await asyncio.to_thread(
|
|
1437
|
+
v10_tools.v10_verify, receipt_path, repo_path,
|
|
1438
|
+
lambda p: validate_path(p, allowed_dirs=['.']))
|
|
1439
|
+
_emit_tool_event_async('loki_v10_verify', 'complete', result_status='error' if isinstance(result, dict) and result.get('error') else 'success')
|
|
1440
|
+
return json.dumps(result)
|
|
1441
|
+
except Exception as e:
|
|
1442
|
+
logger.error(f"loki_v10_verify failed: {e}")
|
|
1443
|
+
_emit_tool_event_async('loki_v10_verify', 'complete', result_status='error', error=str(e))
|
|
1444
|
+
return json.dumps({"error": str(e)})
|
|
1445
|
+
|
|
1446
|
+
|
|
1371
1447
|
@mcp.tool()
|
|
1372
1448
|
async def loki_start_project(prd_content: str = "", prd_path: str = "") -> str:
|
|
1373
1449
|
"""
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Unit tests for mcp/v10_tools.py with subprocess mocked."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
|
12
|
+
if _REPO_ROOT not in sys.path:
|
|
13
|
+
sys.path.insert(0, _REPO_ROOT)
|
|
14
|
+
|
|
15
|
+
from mcp import v10_tools # noqa: E402
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _ok(p):
|
|
19
|
+
return os.path.realpath(p)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _deny(p):
|
|
23
|
+
raise PermissionError("Access denied: " + p)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write_events(repo, run_id, events):
|
|
27
|
+
d = os.path.join(repo, ".loki", "runs", run_id)
|
|
28
|
+
os.makedirs(d)
|
|
29
|
+
with open(os.path.join(d, "events.jsonl"), "w") as f:
|
|
30
|
+
for e in events:
|
|
31
|
+
f.write(json.dumps(e) + "\n")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RunTests(unittest.TestCase):
|
|
35
|
+
def test_rejects_flag_like_ref(self):
|
|
36
|
+
with mock.patch("subprocess.Popen") as popen:
|
|
37
|
+
r = v10_tools.v10_run("--help", "/x", _ok)
|
|
38
|
+
self.assertIn("error", r)
|
|
39
|
+
popen.assert_not_called()
|
|
40
|
+
|
|
41
|
+
def test_path_validation_failure(self):
|
|
42
|
+
with mock.patch("subprocess.Popen") as popen:
|
|
43
|
+
r = v10_tools.v10_run("fix the bug", "/etc", _deny)
|
|
44
|
+
self.assertIn("Access denied", r["error"])
|
|
45
|
+
popen.assert_not_called()
|
|
46
|
+
|
|
47
|
+
def test_starts_in_background_with_arg_list(self):
|
|
48
|
+
with tempfile.TemporaryDirectory() as repo, \
|
|
49
|
+
mock.patch.object(v10_tools, "V10_RUN_ID_WAIT_S", 0), \
|
|
50
|
+
mock.patch("subprocess.Popen") as popen:
|
|
51
|
+
popen.return_value.pid = 4242
|
|
52
|
+
r = v10_tools.v10_run("fix the bug", repo, _ok)
|
|
53
|
+
args, kwargs = popen.call_args
|
|
54
|
+
self.assertEqual(args[0][1], "fix the bug")
|
|
55
|
+
self.assertIsInstance(args[0], list)
|
|
56
|
+
self.assertNotIn("shell", kwargs)
|
|
57
|
+
self.assertTrue(kwargs["start_new_session"])
|
|
58
|
+
self.assertEqual(kwargs["env"]["LOKI_ENGINE"], "v10")
|
|
59
|
+
self.assertEqual(r["pid"], 4242)
|
|
60
|
+
self.assertIsNone(r["run_id"])
|
|
61
|
+
self.assertTrue(r["log_path"].startswith(os.path.realpath(repo)))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class StatusTests(unittest.TestCase):
|
|
65
|
+
def test_done_run(self):
|
|
66
|
+
ev = [
|
|
67
|
+
{"seq": 0, "type": "run.started", "stage": None, "data": {}},
|
|
68
|
+
{"seq": 1, "type": "stage.started", "stage": "plan", "data": {}},
|
|
69
|
+
{"seq": 2, "type": "cost", "stage": "plan", "data": {"usd": 0.5}},
|
|
70
|
+
{"seq": 3, "type": "stage.completed", "stage": "plan", "data": {}},
|
|
71
|
+
{"seq": 4, "type": "run.completed", "stage": None, "data": {"verdict": "VERIFIED"}},
|
|
72
|
+
]
|
|
73
|
+
with tempfile.TemporaryDirectory() as repo:
|
|
74
|
+
_write_events(repo, "e10-a", ev)
|
|
75
|
+
r = v10_tools.v10_status("", repo, _ok)
|
|
76
|
+
self.assertEqual(r["run_id"], "e10-a")
|
|
77
|
+
self.assertTrue(r["done"])
|
|
78
|
+
self.assertEqual(r["verdict"], "VERIFIED")
|
|
79
|
+
self.assertEqual(r["phase"], "done")
|
|
80
|
+
self.assertAlmostEqual(r["cost_usd"], 0.5)
|
|
81
|
+
|
|
82
|
+
def test_running_and_unmeasured_cost(self):
|
|
83
|
+
ev = [
|
|
84
|
+
{"seq": 0, "type": "stage.started", "stage": "build", "data": {}},
|
|
85
|
+
{"seq": 1, "type": "cost", "stage": "build", "data": {}},
|
|
86
|
+
]
|
|
87
|
+
with tempfile.TemporaryDirectory() as repo:
|
|
88
|
+
_write_events(repo, "e10-b", ev)
|
|
89
|
+
r = v10_tools.v10_status("e10-b", repo, _ok)
|
|
90
|
+
self.assertFalse(r["done"])
|
|
91
|
+
self.assertEqual(r["phase"], "build")
|
|
92
|
+
self.assertIsNone(r["cost_usd"])
|
|
93
|
+
self.assertIsNone(r["verdict"])
|
|
94
|
+
|
|
95
|
+
def test_bad_run_id_and_missing(self):
|
|
96
|
+
with tempfile.TemporaryDirectory() as repo:
|
|
97
|
+
self.assertIn("error", v10_tools.v10_status("../x", repo, _ok))
|
|
98
|
+
self.assertIn("error", v10_tools.v10_status("", repo, _ok))
|
|
99
|
+
self.assertIn("error", v10_tools.v10_status("", "", _ok))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class VerifyTests(unittest.TestCase):
|
|
103
|
+
def test_receipt_path_runs_verify(self):
|
|
104
|
+
with tempfile.TemporaryDirectory() as repo:
|
|
105
|
+
receipt = os.path.join(repo, "receipt.json")
|
|
106
|
+
open(receipt, "w").write("{}")
|
|
107
|
+
cp = subprocess.CompletedProcess([], 0, stdout="VERIFIED\n", stderr="")
|
|
108
|
+
with mock.patch("subprocess.run", return_value=cp) as run:
|
|
109
|
+
r = v10_tools.v10_verify(receipt, "", _ok)
|
|
110
|
+
args, kwargs = run.call_args
|
|
111
|
+
self.assertEqual(args[0][1:], ["verify", os.path.realpath(receipt)])
|
|
112
|
+
self.assertEqual(kwargs["timeout"], v10_tools.V10_VERIFY_TIMEOUT_S)
|
|
113
|
+
self.assertNotIn("shell", kwargs)
|
|
114
|
+
self.assertEqual(r["exit_code"], 0)
|
|
115
|
+
self.assertTrue(r["verified"])
|
|
116
|
+
self.assertIn("VERIFIED", r["output"])
|
|
117
|
+
|
|
118
|
+
def test_repo_path_nonzero_exit(self):
|
|
119
|
+
with tempfile.TemporaryDirectory() as repo:
|
|
120
|
+
cp = subprocess.CompletedProcess([], 1, stdout="", stderr="hash mismatch")
|
|
121
|
+
with mock.patch("subprocess.run", return_value=cp):
|
|
122
|
+
r = v10_tools.v10_verify("", repo, _ok)
|
|
123
|
+
self.assertEqual(r["exit_code"], 1)
|
|
124
|
+
self.assertFalse(r["verified"])
|
|
125
|
+
|
|
126
|
+
def test_timeout_and_validation(self):
|
|
127
|
+
with tempfile.TemporaryDirectory() as repo:
|
|
128
|
+
with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("loki", 1)):
|
|
129
|
+
self.assertIn("timed out", v10_tools.v10_verify("", repo, _ok)["error"])
|
|
130
|
+
with mock.patch("subprocess.run") as run:
|
|
131
|
+
self.assertIn("error", v10_tools.v10_verify("/etc/passwd", "", _deny))
|
|
132
|
+
self.assertIn("error", v10_tools.v10_verify("", "", _ok))
|
|
133
|
+
run.assert_not_called()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
unittest.main()
|
package/mcp/v10_tools.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Loki 10 MCP tool helpers: start a v10 run, read its status, verify a receipt.
|
|
2
|
+
|
|
3
|
+
Plain functions returning dicts so they are testable without the MCP SDK; the
|
|
4
|
+
thin @mcp.tool wrappers live in mcp/server.py. Subprocesses use argument lists
|
|
5
|
+
only (never a shell), and every path is checked by the caller-supplied
|
|
6
|
+
validator before use.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Callable, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
LOKI_BIN = str(Path(__file__).resolve().parent.parent / "bin" / "loki")
|
|
17
|
+
RUNS_REL = os.path.join(".loki", "runs")
|
|
18
|
+
V10_RUN_ID_WAIT_S = 2.0
|
|
19
|
+
V10_VERIFY_TIMEOUT_S = 120
|
|
20
|
+
_TERMINAL = {"stage.completed", "stage.failed", "stage.skipped"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _env() -> Dict[str, str]:
|
|
24
|
+
env = dict(os.environ)
|
|
25
|
+
env["LOKI_ENGINE"] = "v10"
|
|
26
|
+
env["LOKI_NO_BROWSER"] = env.get("LOKI_NO_BROWSER", "1")
|
|
27
|
+
return env
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _run_ids(repo: str) -> List[str]:
|
|
31
|
+
d = os.path.join(repo, RUNS_REL)
|
|
32
|
+
if not os.path.isdir(d):
|
|
33
|
+
return []
|
|
34
|
+
return sorted(n for n in os.listdir(d) if os.path.isdir(os.path.join(d, n)))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def v10_run(ref: str, repo_path: str, validate: Callable[[str], str]) -> dict:
|
|
38
|
+
if not isinstance(ref, str) or not ref.strip() or ref.startswith("-") or "\x00" in ref:
|
|
39
|
+
return {"error": "invalid ref: must be a non-empty task or issue reference that does not start with '-'"}
|
|
40
|
+
try:
|
|
41
|
+
repo = validate(repo_path)
|
|
42
|
+
except Exception as e: # PathTraversalError from the server validator
|
|
43
|
+
return {"error": str(e)}
|
|
44
|
+
if not os.path.isdir(repo):
|
|
45
|
+
return {"error": f"repo_path is not a directory: {repo_path}"}
|
|
46
|
+
before = set(_run_ids(repo))
|
|
47
|
+
log_dir = os.path.join(repo, ".loki")
|
|
48
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
49
|
+
log_path = os.path.join(log_dir, f"v10-mcp-{int(time.time() * 1000)}.log")
|
|
50
|
+
try:
|
|
51
|
+
with open(log_path, "ab") as log:
|
|
52
|
+
proc = subprocess.Popen(
|
|
53
|
+
[LOKI_BIN, ref],
|
|
54
|
+
cwd=repo,
|
|
55
|
+
env=_env(),
|
|
56
|
+
stdin=subprocess.DEVNULL,
|
|
57
|
+
stdout=log,
|
|
58
|
+
stderr=log,
|
|
59
|
+
start_new_session=True,
|
|
60
|
+
)
|
|
61
|
+
except OSError as e:
|
|
62
|
+
return {"error": f"could not start loki: {e}"}
|
|
63
|
+
run_id: Optional[str] = None
|
|
64
|
+
deadline = time.time() + V10_RUN_ID_WAIT_S
|
|
65
|
+
while True:
|
|
66
|
+
new = [r for r in _run_ids(repo) if r not in before]
|
|
67
|
+
if new:
|
|
68
|
+
run_id = new[-1]
|
|
69
|
+
break
|
|
70
|
+
if time.time() >= deadline:
|
|
71
|
+
break
|
|
72
|
+
time.sleep(0.1)
|
|
73
|
+
return {"run_id": run_id, "pid": proc.pid, "log_path": log_path}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _read_events(path: str) -> List[dict]:
|
|
77
|
+
events = []
|
|
78
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
79
|
+
for line in f:
|
|
80
|
+
line = line.strip()
|
|
81
|
+
if not line:
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
e = json.loads(line)
|
|
85
|
+
except ValueError:
|
|
86
|
+
continue
|
|
87
|
+
if isinstance(e, dict):
|
|
88
|
+
events.append(e)
|
|
89
|
+
return events
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def v10_status(run_id: str, repo_path: str, validate: Callable[[str], str]) -> dict:
|
|
93
|
+
if not repo_path:
|
|
94
|
+
return {"error": "repo_path is required"}
|
|
95
|
+
try:
|
|
96
|
+
repo = validate(repo_path)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
return {"error": str(e)}
|
|
99
|
+
if run_id:
|
|
100
|
+
if "/" in run_id or "\\" in run_id or run_id.startswith(".") or "\x00" in run_id:
|
|
101
|
+
return {"error": "invalid run_id"}
|
|
102
|
+
else:
|
|
103
|
+
ids = _run_ids(repo)
|
|
104
|
+
if not ids:
|
|
105
|
+
return {"error": "no v10 runs found under .loki/runs"}
|
|
106
|
+
run_id = ids[-1]
|
|
107
|
+
events_path = os.path.join(repo, RUNS_REL, run_id, "events.jsonl")
|
|
108
|
+
if not os.path.isfile(events_path):
|
|
109
|
+
return {"error": f"no events.jsonl for run {run_id}"}
|
|
110
|
+
events = _read_events(events_path)
|
|
111
|
+
open_stages: Dict[str, bool] = {}
|
|
112
|
+
verdict = None
|
|
113
|
+
done = False
|
|
114
|
+
usd = 0.0
|
|
115
|
+
saw_cost = False
|
|
116
|
+
unmeasured = False
|
|
117
|
+
for e in events:
|
|
118
|
+
t, stage = e.get("type"), e.get("stage")
|
|
119
|
+
data = e.get("data") if isinstance(e.get("data"), dict) else {}
|
|
120
|
+
if stage:
|
|
121
|
+
if t == "stage.started":
|
|
122
|
+
open_stages[stage] = True
|
|
123
|
+
elif t in _TERMINAL:
|
|
124
|
+
open_stages.pop(stage, None)
|
|
125
|
+
if t == "run.completed":
|
|
126
|
+
done = True
|
|
127
|
+
verdict = data.get("verdict") if isinstance(data.get("verdict"), str) else None
|
|
128
|
+
elif t == "cost":
|
|
129
|
+
saw_cost = True
|
|
130
|
+
if isinstance(data.get("usd"), (int, float)):
|
|
131
|
+
usd += data["usd"]
|
|
132
|
+
else:
|
|
133
|
+
unmeasured = True
|
|
134
|
+
phase = "done" if done else ("+".join(sorted(open_stages)) or "starting")
|
|
135
|
+
return {
|
|
136
|
+
"run_id": run_id,
|
|
137
|
+
"phase": phase,
|
|
138
|
+
"done": done,
|
|
139
|
+
"verdict": verdict,
|
|
140
|
+
"cost_usd": usd if saw_cost and not unmeasured else None,
|
|
141
|
+
"events": len(events),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def v10_verify(receipt_path: str, repo_path: str, validate: Callable[[str], str]) -> dict:
|
|
146
|
+
args = [LOKI_BIN, "verify"]
|
|
147
|
+
try:
|
|
148
|
+
if receipt_path:
|
|
149
|
+
receipt = validate(receipt_path)
|
|
150
|
+
if not os.path.isfile(receipt):
|
|
151
|
+
return {"error": f"receipt not found: {receipt_path}"}
|
|
152
|
+
args.append(receipt)
|
|
153
|
+
cwd = os.path.dirname(receipt)
|
|
154
|
+
elif repo_path:
|
|
155
|
+
cwd = validate(repo_path)
|
|
156
|
+
if not os.path.isdir(cwd):
|
|
157
|
+
return {"error": f"repo_path is not a directory: {repo_path}"}
|
|
158
|
+
else:
|
|
159
|
+
return {"error": "receipt_path or repo_path is required"}
|
|
160
|
+
except Exception as e:
|
|
161
|
+
return {"error": str(e)}
|
|
162
|
+
try:
|
|
163
|
+
p = subprocess.run(
|
|
164
|
+
args, cwd=cwd, env=_env(), stdin=subprocess.DEVNULL,
|
|
165
|
+
capture_output=True, text=True, timeout=V10_VERIFY_TIMEOUT_S,
|
|
166
|
+
)
|
|
167
|
+
except subprocess.TimeoutExpired:
|
|
168
|
+
return {"error": f"loki verify timed out after {V10_VERIFY_TIMEOUT_S}s"}
|
|
169
|
+
except OSError as e:
|
|
170
|
+
return {"error": f"could not run loki verify: {e}"}
|
|
171
|
+
out = ((p.stdout or "") + (p.stderr or "")).strip()
|
|
172
|
+
return {"exit_code": p.returncode, "verified": p.returncode == 0, "output": out[-2000:]}
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@ var __export = (target, all) => {
|
|
|
16
16
|
|
|
17
17
|
// src/server/app.ts
|
|
18
18
|
import { existsSync as existsSync2, statSync } from "fs";
|
|
19
|
-
import { join as
|
|
19
|
+
import { join as join3, resolve, sep } from "path";
|
|
20
20
|
|
|
21
21
|
// node_modules/hono/dist/request/constants.js
|
|
22
22
|
var GET_MATCH_RESULT = Symbol();
|
|
@@ -5658,6 +5658,35 @@ var RULES = [
|
|
|
5658
5658
|
];
|
|
5659
5659
|
var redactSecrets = (s) => RULES.reduce((a, [re, to]) => a.replace(re, to), s);
|
|
5660
5660
|
|
|
5661
|
+
// src/server/answer.ts
|
|
5662
|
+
import { chmodSync, mkdirSync, writeFileSync } from "fs";
|
|
5663
|
+
import { homedir } from "os";
|
|
5664
|
+
import { join as join2 } from "path";
|
|
5665
|
+
var MAX_ANSWER = 4000;
|
|
5666
|
+
var ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
5667
|
+
var defaultAnswerDir = (env = process.env) => env.LOKI_CONTROL_ANSWER_DIR || join2(env.HOME || homedir(), ".loki", "control", "answers");
|
|
5668
|
+
function blockedQuestion(evs, verdict) {
|
|
5669
|
+
if (verdict !== "SPEC_CONFLICT")
|
|
5670
|
+
return null;
|
|
5671
|
+
const why = evs.find((e) => e.type === "stage.completed" && e.stage === "implement")?.data.spec_conflict_reason;
|
|
5672
|
+
return (typeof why === "string" && why ? why : "the run is blocked on a spec conflict").replace(/[\x00-\x1f\x7f]+/g, " ").slice(0, 500);
|
|
5673
|
+
}
|
|
5674
|
+
function writeAnswer(dir, source, run, answer) {
|
|
5675
|
+
if (!ID.test(source) || !ID.test(run) || source.includes("..") || run.includes(".."))
|
|
5676
|
+
return { status: 400, body: { error: "invalid run id" } };
|
|
5677
|
+
if (typeof answer !== "string" || answer.trim() === "")
|
|
5678
|
+
return { status: 400, body: { error: "answer must be a non-empty string" } };
|
|
5679
|
+
if (answer.length > MAX_ANSWER)
|
|
5680
|
+
return { status: 413, body: { error: `answer over ${MAX_ANSWER} characters` } };
|
|
5681
|
+
const d = join2(dir, source);
|
|
5682
|
+
mkdirSync(d, { recursive: true, mode: 448 });
|
|
5683
|
+
const path = join2(d, `${run}.answer.txt`);
|
|
5684
|
+
writeFileSync(path, `${answer.trim()}
|
|
5685
|
+
`, { mode: 384 });
|
|
5686
|
+
chmodSync(path, 384);
|
|
5687
|
+
return { status: 200, body: { ok: true, path, resume: `loki "$(cat '${path}')" # re-run the task with the answer, or paste it into the issue` } };
|
|
5688
|
+
}
|
|
5689
|
+
|
|
5661
5690
|
// src/server/runs.ts
|
|
5662
5691
|
var str = (x) => typeof x === "string" ? x : null;
|
|
5663
5692
|
function loadEvents(db, sourceId, runId) {
|
|
@@ -5792,6 +5821,7 @@ function runDetail(db, sourceId, runId) {
|
|
|
5792
5821
|
const sealed = evs.find((e) => e.type === "receipt.sealed")?.data;
|
|
5793
5822
|
return {
|
|
5794
5823
|
...withLive(db, r),
|
|
5824
|
+
blocked_question: blockedQuestion(evs, r.verdict),
|
|
5795
5825
|
stages,
|
|
5796
5826
|
stages_completed: f.completed,
|
|
5797
5827
|
receipt: sealed ? { sha256: str(sealed.receipt_sha256), signed: sealed.signed === true, verdict: str(sealed.verdict), path: str(sealed.path) } : null,
|
|
@@ -5848,11 +5878,12 @@ function ingest(db, body) {
|
|
|
5848
5878
|
|
|
5849
5879
|
// src/server/app.ts
|
|
5850
5880
|
var MAX_BODY = 1e6;
|
|
5851
|
-
var defaultUiDir = () => [
|
|
5881
|
+
var defaultUiDir = () => [join3(import.meta.dir, "../../ui/dist"), join3(import.meta.dir, "../ui/dist")].find((d) => existsSync2(join3(d, "index.html")));
|
|
5852
5882
|
function createApp(opts) {
|
|
5853
5883
|
const uiDir = opts.uiDir ?? defaultUiDir();
|
|
5854
5884
|
const { db, sqlite } = openDb(opts.dbPath);
|
|
5855
5885
|
let ready = true;
|
|
5886
|
+
const answerDir = opts.answerDir ?? defaultAnswerDir();
|
|
5856
5887
|
const app = new Hono3;
|
|
5857
5888
|
app.get("/health", (c) => c.json({ service: "loki-control", pid: process.pid, install_path: import.meta.dir }));
|
|
5858
5889
|
app.get("/ready", (c) => {
|
|
@@ -5885,6 +5916,27 @@ function createApp(opts) {
|
|
|
5885
5916
|
return d ? c.json(d) : c.json({ error: "run not found" }, 404);
|
|
5886
5917
|
};
|
|
5887
5918
|
app.get("/v1/runs/:source/:run", (c) => detail(c, c.req.param("source"), c.req.param("run")));
|
|
5919
|
+
app.post("/v1/runs/:source/:run/answer", async (c) => {
|
|
5920
|
+
const source = c.req.param("source"), run = c.req.param("run");
|
|
5921
|
+
if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json"))
|
|
5922
|
+
return c.json({ error: "content-type must be application/json" }, 400);
|
|
5923
|
+
const text2 = await c.req.text();
|
|
5924
|
+
if (text2.length > 20000)
|
|
5925
|
+
return c.json({ error: "body too large" }, 413);
|
|
5926
|
+
let body;
|
|
5927
|
+
try {
|
|
5928
|
+
body = JSON.parse(text2);
|
|
5929
|
+
} catch {
|
|
5930
|
+
return c.json({ error: "invalid JSON" }, 400);
|
|
5931
|
+
}
|
|
5932
|
+
const d = runDetail(db, source, run);
|
|
5933
|
+
if (!d)
|
|
5934
|
+
return c.json({ error: "run not found" }, 404);
|
|
5935
|
+
if (!d.blocked_question)
|
|
5936
|
+
return c.json({ error: "run is not blocked on a question" }, 409);
|
|
5937
|
+
const r = writeAnswer(answerDir, source, run, body?.answer);
|
|
5938
|
+
return c.json(r.body, r.status);
|
|
5939
|
+
});
|
|
5888
5940
|
app.get("/v1/runs/:id", (c) => {
|
|
5889
5941
|
const id = c.req.param("id");
|
|
5890
5942
|
const i = id.indexOf(":");
|
|
@@ -5900,14 +5952,14 @@ function createApp(opts) {
|
|
|
5900
5952
|
return new Response(Bun.file(f));
|
|
5901
5953
|
if (/\.[a-z0-9]+$/i.test(p))
|
|
5902
5954
|
return c.json({ error: "not found" }, 404);
|
|
5903
|
-
return new Response(Bun.file(
|
|
5955
|
+
return new Response(Bun.file(join3(root, "index.html")));
|
|
5904
5956
|
});
|
|
5905
5957
|
return { app, db, close: () => sqlite.close() };
|
|
5906
5958
|
}
|
|
5907
5959
|
|
|
5908
5960
|
// src/server/serve.ts
|
|
5909
5961
|
var { app } = createApp({ dbPath: process.env.LOKI_CONTROL_DB ?? "control.db" });
|
|
5910
|
-
var serve = (port) => Bun.serve({ port, hostname: "127.0.0.1", fetch: app.fetch });
|
|
5962
|
+
var serve = (port) => Bun.serve({ port, hostname: process.env.LOKI_CONTROL_HOST || "127.0.0.1", fetch: app.fetch });
|
|
5911
5963
|
var s;
|
|
5912
5964
|
try {
|
|
5913
5965
|
s = serve(Number(process.env.PORT ?? 0));
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// BLOCKED answer: a human reply to a run's one question, written to a file the operator resumes from.
|
|
2
|
+
// File: <answerDir>/<source>/<run>.answer.txt (default ~/.loki/control/answers, override LOKI_CONTROL_ANSWER_DIR).
|
|
3
|
+
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { EventEnvelope } from "../../../../loki-ts/src/engine10/types.ts";
|
|
7
|
+
|
|
8
|
+
export const MAX_ANSWER = 4000;
|
|
9
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
10
|
+
|
|
11
|
+
export const defaultAnswerDir = (env: NodeJS.ProcessEnv = process.env): string =>
|
|
12
|
+
env.LOKI_CONTROL_ANSWER_DIR || join(env.HOME || homedir(), ".loki", "control", "answers");
|
|
13
|
+
|
|
14
|
+
/** The question a BLOCKED (spec conflict) run is waiting on, or null when it is not blocked. */
|
|
15
|
+
export function blockedQuestion(evs: EventEnvelope[], verdict: string | null): string | null {
|
|
16
|
+
if (verdict !== "SPEC_CONFLICT") return null;
|
|
17
|
+
const why = evs.find((e) => e.type === "stage.completed" && e.stage === "implement")?.data.spec_conflict_reason;
|
|
18
|
+
return (typeof why === "string" && why ? why : "the run is blocked on a spec conflict").replace(/[\x00-\x1f\x7f]+/g, " ").slice(0, 500);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type AnswerResult = { status: 200; body: { ok: true; path: string; resume: string } } | { status: 400 | 413; body: { error: string } };
|
|
22
|
+
|
|
23
|
+
export function writeAnswer(dir: string, source: string, run: string, answer: unknown): AnswerResult {
|
|
24
|
+
if (!ID.test(source) || !ID.test(run) || source.includes("..") || run.includes("..")) return { status: 400, body: { error: "invalid run id" } };
|
|
25
|
+
if (typeof answer !== "string" || answer.trim() === "") return { status: 400, body: { error: "answer must be a non-empty string" } };
|
|
26
|
+
if (answer.length > MAX_ANSWER) return { status: 413, body: { error: `answer over ${MAX_ANSWER} characters` } };
|
|
27
|
+
const d = join(dir, source);
|
|
28
|
+
mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
29
|
+
const path = join(d, `${run}.answer.txt`);
|
|
30
|
+
writeFileSync(path, `${answer.trim()}\n`, { mode: 0o600 });
|
|
31
|
+
chmodSync(path, 0o600);
|
|
32
|
+
return { status: 200, body: { ok: true, path, resume: `loki "$(cat '${path}')" # re-run the task with the answer, or paste it into the issue` } };
|
|
33
|
+
}
|
|
@@ -3,6 +3,7 @@ import { join, resolve, sep } from "node:path";
|
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import { openDb } from "../db/migrate.ts";
|
|
5
5
|
import { ingest } from "./ingest.ts";
|
|
6
|
+
import { defaultAnswerDir, writeAnswer } from "./answer.ts";
|
|
6
7
|
import { listRuns, runDetail } from "./runs.ts";
|
|
7
8
|
|
|
8
9
|
const MAX_BODY = 1_000_000;
|
|
@@ -11,10 +12,11 @@ const MAX_BODY = 1_000_000;
|
|
|
11
12
|
const defaultUiDir = () => [join(import.meta.dir, "../../ui/dist"), join(import.meta.dir, "../ui/dist")].find((d) => existsSync(join(d, "index.html")));
|
|
12
13
|
|
|
13
14
|
/** dbPath ":memory:" for tests. Migrations run here, so /ready is true as soon as this returns. uiDir overrides the built-UI location. */
|
|
14
|
-
export function createApp(opts: { dbPath: string; uiDir?: string }) {
|
|
15
|
+
export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: string }) {
|
|
15
16
|
const uiDir = opts.uiDir ?? defaultUiDir();
|
|
16
17
|
const { db, sqlite } = openDb(opts.dbPath);
|
|
17
18
|
let ready = true;
|
|
19
|
+
const answerDir = opts.answerDir ?? defaultAnswerDir();
|
|
18
20
|
const app = new Hono();
|
|
19
21
|
|
|
20
22
|
app.get("/health", (c) => c.json({ service: "loki-control", pid: process.pid, install_path: import.meta.dir }));
|
|
@@ -41,6 +43,20 @@ export function createApp(opts: { dbPath: string; uiDir?: string }) {
|
|
|
41
43
|
return d ? c.json(d) : c.json({ error: "run not found" }, 404);
|
|
42
44
|
};
|
|
43
45
|
app.get("/v1/runs/:source/:run", (c) => detail(c, c.req.param("source"), c.req.param("run")));
|
|
46
|
+
// BLOCKED answer: only for a run blocked on a question; JSON content type required (blocks cross-site form posts).
|
|
47
|
+
app.post("/v1/runs/:source/:run/answer", async (c) => {
|
|
48
|
+
const source = c.req.param("source"), run = c.req.param("run");
|
|
49
|
+
if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) return c.json({ error: "content-type must be application/json" }, 400);
|
|
50
|
+
const text = await c.req.text();
|
|
51
|
+
if (text.length > 20_000) return c.json({ error: "body too large" }, 413);
|
|
52
|
+
let body: { answer?: unknown } | null;
|
|
53
|
+
try { body = JSON.parse(text) as { answer?: unknown } | null; } catch { return c.json({ error: "invalid JSON" }, 400); }
|
|
54
|
+
const d = runDetail(db, source, run);
|
|
55
|
+
if (!d) return c.json({ error: "run not found" }, 404);
|
|
56
|
+
if (!d.blocked_question) return c.json({ error: "run is not blocked on a question" }, 409);
|
|
57
|
+
const r = writeAnswer(answerDir, source, run, body?.answer);
|
|
58
|
+
return c.json(r.body, r.status);
|
|
59
|
+
});
|
|
44
60
|
// :id is `source:run` (run ids never contain a colon)
|
|
45
61
|
app.get("/v1/runs/:id", (c) => {
|
|
46
62
|
const id = c.req.param("id");
|
|
@@ -3,6 +3,7 @@ import { fold, partialCost } from "../../../../loki-ts/src/engine10/events.ts";
|
|
|
3
3
|
import type { EventEnvelope } from "../../../../loki-ts/src/engine10/types.ts";
|
|
4
4
|
import type { Db } from "../db/migrate.ts";
|
|
5
5
|
import { events, runs } from "../db/schema.ts";
|
|
6
|
+
import { blockedQuestion } from "./answer.ts";
|
|
6
7
|
|
|
7
8
|
const str = (x: unknown): string | null => (typeof x === "string" ? x : null);
|
|
8
9
|
|
|
@@ -108,6 +109,7 @@ export function runDetail(db: Db, sourceId: string, runId: string) {
|
|
|
108
109
|
const sealed = evs.find((e) => e.type === "receipt.sealed")?.data;
|
|
109
110
|
return {
|
|
110
111
|
...withLive(db, r),
|
|
112
|
+
blocked_question: blockedQuestion(evs, r.verdict),
|
|
111
113
|
stages,
|
|
112
114
|
stages_completed: f.completed,
|
|
113
115
|
receipt: sealed ? { sha256: str(sealed.receipt_sha256), signed: sealed.signed === true, verdict: str(sealed.verdict), path: str(sealed.path) } : null,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Entry for `loki control serve` (bundled to dist/server.js). Config comes from env: PORT, LOKI_CONTROL_DB.
|
|
2
|
-
// Binds loopback
|
|
2
|
+
// Binds loopback unless LOKI_CONTROL_HOST is set (the container image sets 0.0.0.0). LOKI_CONTROL_PORT_FALLBACK=1 (set for the default port, never for an explicit --port)
|
|
3
3
|
// retries on any free port when PORT is taken; the URL printed below is always the real one.
|
|
4
4
|
import { createApp } from "./app.ts";
|
|
5
5
|
|
|
6
6
|
const { app } = createApp({ dbPath: process.env.LOKI_CONTROL_DB ?? "control.db" });
|
|
7
|
-
const serve = (port: number) => Bun.serve({ port, hostname: "127.0.0.1", fetch: app.fetch });
|
|
7
|
+
const serve = (port: number) => Bun.serve({ port, hostname: process.env.LOKI_CONTROL_HOST || "127.0.0.1", fetch: app.fetch });
|
|
8
8
|
let s: ReturnType<typeof serve>;
|
|
9
9
|
try {
|
|
10
10
|
s = serve(Number(process.env.PORT ?? 0));
|