loki-mode 7.78.0 → 7.80.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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/checkpoint_sync.py +189 -0
- package/autonomy/lib/config-map.sh +955 -0
- package/autonomy/loki +273 -8
- package/autonomy/run.sh +230 -195
- package/autonomy/sandbox.sh +98 -0
- package/autonomy/trigger-server.py +419 -43
- package/dashboard/__init__.py +1 -1
- package/dashboard/registry.py +212 -0
- package/dashboard/server.py +299 -7
- package/dashboard/static/index.html +383 -150
- package/docs/CONFIG-FILE-PLAN.md +459 -0
- package/docs/ENTERPRISE-IDENTITY-ROADMAP.md +206 -0
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +2 -2
- package/lokistore/__init__.py +65 -0
- package/lokistore/base.py +172 -0
- package/lokistore/cloud.py +305 -0
- package/lokistore/factory.py +187 -0
- package/lokistore/local.py +219 -0
- package/mcp/__init__.py +1 -1
- package/memory/retrieval.py +147 -0
- package/memory/tree_index.py +499 -0
- package/memory/tree_search.py +305 -0
- package/package.json +2 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/dashboard/registry.py
CHANGED
|
@@ -525,6 +525,218 @@ def get_cross_project_tasks(project_ids: Optional[list[str]] = None) -> list[dic
|
|
|
525
525
|
return all_tasks
|
|
526
526
|
|
|
527
527
|
|
|
528
|
+
def _pid_alive(pid) -> bool:
|
|
529
|
+
"""Return True if pid is a positive int naming a live process.
|
|
530
|
+
|
|
531
|
+
Mirrors the liveness probe used by the dashboard's running-projects view:
|
|
532
|
+
signal 0 delivered -> alive; EPERM (owned by another user) -> alive; ESRCH
|
|
533
|
+
-> dead. Never raises.
|
|
534
|
+
"""
|
|
535
|
+
if not isinstance(pid, int) or pid <= 0:
|
|
536
|
+
return False
|
|
537
|
+
try:
|
|
538
|
+
os.kill(pid, 0)
|
|
539
|
+
return True
|
|
540
|
+
except PermissionError:
|
|
541
|
+
return True
|
|
542
|
+
except (ProcessLookupError, OSError):
|
|
543
|
+
return False
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _read_project_run_snapshot(path: str) -> dict:
|
|
547
|
+
"""Read a single project's live run snapshot from its .loki/ state files.
|
|
548
|
+
|
|
549
|
+
HONEST SCOPE: this polls the shared on-disk metadata that `loki start`
|
|
550
|
+
already writes per project (no new store, no controller). It reads:
|
|
551
|
+
- .loki/dashboard-state.json (phase, iteration; written ~every 2s)
|
|
552
|
+
- .loki/session.json (status, startedAt fallback)
|
|
553
|
+
- .loki/metrics/efficiency/*.json (summed cost_usd) with a
|
|
554
|
+
.loki/context/tracking.json fallback (totals.total_cost_usd)
|
|
555
|
+
|
|
556
|
+
Returns a dict with phase, iteration, cost_usd, started_at, and ended_at
|
|
557
|
+
(best-effort; missing values default to None/0). Never raises: any file
|
|
558
|
+
problem degrades the affected field to its default.
|
|
559
|
+
"""
|
|
560
|
+
snap = {
|
|
561
|
+
"phase": "",
|
|
562
|
+
"iteration": 0,
|
|
563
|
+
"cost_usd": 0.0,
|
|
564
|
+
"started_at": None,
|
|
565
|
+
"ended_at": None,
|
|
566
|
+
}
|
|
567
|
+
if not path:
|
|
568
|
+
return snap
|
|
569
|
+
loki_dir = Path(path) / ".loki"
|
|
570
|
+
|
|
571
|
+
# Phase + iteration from dashboard-state.json (the live writer).
|
|
572
|
+
state_file = loki_dir / "dashboard-state.json"
|
|
573
|
+
if state_file.exists():
|
|
574
|
+
try:
|
|
575
|
+
state = json.loads(state_file.read_text())
|
|
576
|
+
if isinstance(state, dict):
|
|
577
|
+
_p = state.get("phase", "")
|
|
578
|
+
snap["phase"] = _p if isinstance(_p, str) else ""
|
|
579
|
+
_i = state.get("iteration", 0)
|
|
580
|
+
snap["iteration"] = _i if isinstance(_i, int) else 0
|
|
581
|
+
except (json.JSONDecodeError, OSError, ValueError):
|
|
582
|
+
pass
|
|
583
|
+
|
|
584
|
+
# Timestamps from session.json (startedAt; endedAt when present).
|
|
585
|
+
session_file = loki_dir / "session.json"
|
|
586
|
+
if session_file.exists():
|
|
587
|
+
try:
|
|
588
|
+
sd = json.loads(session_file.read_text())
|
|
589
|
+
if isinstance(sd, dict):
|
|
590
|
+
_sa = sd.get("startedAt") or sd.get("started_at")
|
|
591
|
+
snap["started_at"] = _sa if isinstance(_sa, str) else None
|
|
592
|
+
_ea = sd.get("endedAt") or sd.get("ended_at")
|
|
593
|
+
snap["ended_at"] = _ea if isinstance(_ea, str) else None
|
|
594
|
+
except (json.JSONDecodeError, OSError, ValueError):
|
|
595
|
+
pass
|
|
596
|
+
|
|
597
|
+
# Cost: sum per-iteration efficiency files; fall back to context tracking.
|
|
598
|
+
cost = 0.0
|
|
599
|
+
found_cost = False
|
|
600
|
+
eff_dir = loki_dir / "metrics" / "efficiency"
|
|
601
|
+
if eff_dir.is_dir():
|
|
602
|
+
try:
|
|
603
|
+
for eff_file in eff_dir.glob("*.json"):
|
|
604
|
+
try:
|
|
605
|
+
data = json.loads(eff_file.read_text())
|
|
606
|
+
if not isinstance(data, dict):
|
|
607
|
+
continue
|
|
608
|
+
c = data.get("cost_usd")
|
|
609
|
+
if isinstance(c, (int, float)):
|
|
610
|
+
cost += float(c)
|
|
611
|
+
found_cost = True
|
|
612
|
+
except (json.JSONDecodeError, OSError, ValueError):
|
|
613
|
+
continue
|
|
614
|
+
except OSError:
|
|
615
|
+
pass
|
|
616
|
+
if not found_cost:
|
|
617
|
+
ctx_file = loki_dir / "context" / "tracking.json"
|
|
618
|
+
if ctx_file.exists():
|
|
619
|
+
try:
|
|
620
|
+
ctx = json.loads(ctx_file.read_text())
|
|
621
|
+
if isinstance(ctx, dict):
|
|
622
|
+
totals = ctx.get("totals", {})
|
|
623
|
+
if isinstance(totals, dict):
|
|
624
|
+
tc = totals.get("total_cost_usd")
|
|
625
|
+
if isinstance(tc, (int, float)):
|
|
626
|
+
cost = float(tc)
|
|
627
|
+
except (json.JSONDecodeError, OSError, ValueError):
|
|
628
|
+
pass
|
|
629
|
+
snap["cost_usd"] = round(cost, 6)
|
|
630
|
+
return snap
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def get_fleet_runs(include_inactive: bool = True) -> list[dict]:
|
|
634
|
+
"""Build a fleet-wide view of builds across ALL registered projects.
|
|
635
|
+
|
|
636
|
+
v1 SCOPE (honest): this polls the shared metadata store that `loki start`
|
|
637
|
+
already maintains -- the machine-global registry (~/.loki/dashboard/
|
|
638
|
+
projects.json) plus each project's own .loki/ state files. There is NO
|
|
639
|
+
controller, CRD, or Job-watcher here; a real k8s operator watching Jobs is
|
|
640
|
+
future work. One registered project maps to one "run" entry (its current /
|
|
641
|
+
most-recent build), which is the granularity the registry tracks.
|
|
642
|
+
|
|
643
|
+
Each entry carries: id, name, path, status (running|stopped|<registry
|
|
644
|
+
status>), running (live pid probe), phase, iteration, cost_usd, started_at,
|
|
645
|
+
duration_seconds, port. Never raises: registry problems degrade to an empty
|
|
646
|
+
list and per-project read problems degrade that entry's fields.
|
|
647
|
+
"""
|
|
648
|
+
try:
|
|
649
|
+
projects = list_projects(include_inactive=include_inactive)
|
|
650
|
+
except Exception:
|
|
651
|
+
return []
|
|
652
|
+
|
|
653
|
+
out = []
|
|
654
|
+
for p in projects:
|
|
655
|
+
path = p.get("path", "")
|
|
656
|
+
pid = p.get("pid")
|
|
657
|
+
running = _pid_alive(pid)
|
|
658
|
+
snap = _read_project_run_snapshot(path)
|
|
659
|
+
|
|
660
|
+
# A live pid is authoritative for "running"; otherwise reflect the
|
|
661
|
+
# registry status (stopped/active/missing). This mirrors the
|
|
662
|
+
# running-projects endpoint's pid-first precedence.
|
|
663
|
+
if running:
|
|
664
|
+
status = "running"
|
|
665
|
+
else:
|
|
666
|
+
status = p.get("status") or "unknown"
|
|
667
|
+
|
|
668
|
+
# Duration: wall time from started_at to ended_at (or now if running).
|
|
669
|
+
duration_seconds = None
|
|
670
|
+
started_at = snap.get("started_at")
|
|
671
|
+
if started_at:
|
|
672
|
+
try:
|
|
673
|
+
st = datetime.fromisoformat(str(started_at).replace("Z", "+00:00"))
|
|
674
|
+
if st.tzinfo is None:
|
|
675
|
+
st = st.replace(tzinfo=timezone.utc)
|
|
676
|
+
end_ref = None
|
|
677
|
+
ended_at = snap.get("ended_at")
|
|
678
|
+
if ended_at and not running:
|
|
679
|
+
try:
|
|
680
|
+
end_ref = datetime.fromisoformat(
|
|
681
|
+
str(ended_at).replace("Z", "+00:00")
|
|
682
|
+
)
|
|
683
|
+
if end_ref.tzinfo is None:
|
|
684
|
+
end_ref = end_ref.replace(tzinfo=timezone.utc)
|
|
685
|
+
except (ValueError, TypeError):
|
|
686
|
+
end_ref = None
|
|
687
|
+
if end_ref is None:
|
|
688
|
+
end_ref = datetime.now(timezone.utc)
|
|
689
|
+
duration_seconds = max(0, int((end_ref - st).total_seconds()))
|
|
690
|
+
except (ValueError, TypeError):
|
|
691
|
+
duration_seconds = None
|
|
692
|
+
|
|
693
|
+
out.append({
|
|
694
|
+
"id": p.get("id"),
|
|
695
|
+
"name": p.get("name") or (os.path.basename(path) if path else "project"),
|
|
696
|
+
"path": path,
|
|
697
|
+
"status": status,
|
|
698
|
+
"running": running,
|
|
699
|
+
"phase": snap.get("phase", ""),
|
|
700
|
+
"iteration": snap.get("iteration", 0),
|
|
701
|
+
"cost_usd": snap.get("cost_usd", 0.0),
|
|
702
|
+
"started_at": started_at,
|
|
703
|
+
"duration_seconds": duration_seconds,
|
|
704
|
+
"port": p.get("port"),
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
# Running builds first, then by most-recent start time.
|
|
708
|
+
out.sort(
|
|
709
|
+
key=lambda r: (
|
|
710
|
+
0 if r.get("running") else 1,
|
|
711
|
+
r.get("started_at") or "",
|
|
712
|
+
),
|
|
713
|
+
reverse=False,
|
|
714
|
+
)
|
|
715
|
+
# Within the same running-group, most recent start first.
|
|
716
|
+
out.sort(key=lambda r: r.get("started_at") or "", reverse=True)
|
|
717
|
+
out.sort(key=lambda r: 0 if r.get("running") else 1)
|
|
718
|
+
return out
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def get_fleet_summary(include_inactive: bool = True) -> dict:
|
|
722
|
+
"""Aggregate fleet-wide totals from get_fleet_runs().
|
|
723
|
+
|
|
724
|
+
Returns counts (total / running / stopped) and a summed cost across all
|
|
725
|
+
registered projects. v1 polls the shared metadata store (see
|
|
726
|
+
get_fleet_runs); not a controller. Never raises.
|
|
727
|
+
"""
|
|
728
|
+
runs = get_fleet_runs(include_inactive=include_inactive)
|
|
729
|
+
total = len(runs)
|
|
730
|
+
running = sum(1 for r in runs if r.get("running"))
|
|
731
|
+
total_cost = round(sum(float(r.get("cost_usd") or 0.0) for r in runs), 6)
|
|
732
|
+
return {
|
|
733
|
+
"total_runs": total,
|
|
734
|
+
"running_runs": running,
|
|
735
|
+
"stopped_runs": total - running,
|
|
736
|
+
"total_cost_usd": total_cost,
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
|
|
528
740
|
def get_cross_project_learnings() -> dict:
|
|
529
741
|
"""
|
|
530
742
|
Get learnings from the global learnings database.
|
package/dashboard/server.py
CHANGED
|
@@ -2389,6 +2389,118 @@ async def get_cross_project_learnings():
|
|
|
2389
2389
|
return learnings
|
|
2390
2390
|
|
|
2391
2391
|
|
|
2392
|
+
# =============================================================================
|
|
2393
|
+
# Fleet Observability (v1: poll the shared metadata store)
|
|
2394
|
+
# =============================================================================
|
|
2395
|
+
#
|
|
2396
|
+
# HONEST SCOPE: these endpoints aggregate the data `loki start` already writes
|
|
2397
|
+
# -- the machine-global registry (~/.loki/dashboard/projects.json) plus each
|
|
2398
|
+
# project's own .loki/ state files. There is NO controller, CRD, or Kubernetes
|
|
2399
|
+
# Job-watcher; a real operator watching Jobs is future work. One registered
|
|
2400
|
+
# project maps to one fleet "run" (its current / most-recent build), which is
|
|
2401
|
+
# the granularity the registry tracks. Cancel reuses the same STOP-file + pid
|
|
2402
|
+
# teardown as the per-project switcher Stop. Retry is intentionally NOT exposed
|
|
2403
|
+
# here: there is no clean cross-project re-launch primitive in the registry
|
|
2404
|
+
# path (the original spec source lives only in each project's CWD), so retry is
|
|
2405
|
+
# a follow-up.
|
|
2406
|
+
|
|
2407
|
+
|
|
2408
|
+
class FleetRunResponse(BaseModel):
|
|
2409
|
+
"""One fleet run = one registered project's current/most-recent build."""
|
|
2410
|
+
id: Optional[str] = None
|
|
2411
|
+
name: str
|
|
2412
|
+
path: str
|
|
2413
|
+
status: str
|
|
2414
|
+
running: bool
|
|
2415
|
+
phase: str = ""
|
|
2416
|
+
iteration: int = 0
|
|
2417
|
+
cost_usd: float = 0.0
|
|
2418
|
+
started_at: Optional[str] = None
|
|
2419
|
+
duration_seconds: Optional[int] = None
|
|
2420
|
+
port: Optional[int] = None
|
|
2421
|
+
|
|
2422
|
+
|
|
2423
|
+
class FleetSummaryResponse(BaseModel):
|
|
2424
|
+
"""Fleet-wide totals across all registered projects."""
|
|
2425
|
+
total_runs: int
|
|
2426
|
+
running_runs: int
|
|
2427
|
+
stopped_runs: int
|
|
2428
|
+
total_cost_usd: float
|
|
2429
|
+
|
|
2430
|
+
|
|
2431
|
+
@app.get(
|
|
2432
|
+
"/api/fleet/runs",
|
|
2433
|
+
response_model=list[FleetRunResponse],
|
|
2434
|
+
dependencies=[Depends(auth.require_scope("read"))],
|
|
2435
|
+
)
|
|
2436
|
+
async def list_fleet_runs(include_inactive: bool = True):
|
|
2437
|
+
"""List all builds across every registered project (fleet view).
|
|
2438
|
+
|
|
2439
|
+
v1 polls the shared metadata store (registry + per-project .loki/ state);
|
|
2440
|
+
it is not a controller. Never raises: registry problems degrade to [].
|
|
2441
|
+
"""
|
|
2442
|
+
if not _read_limiter.check("fleet_runs"):
|
|
2443
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
2444
|
+
return await asyncio.to_thread(registry.get_fleet_runs, include_inactive)
|
|
2445
|
+
|
|
2446
|
+
|
|
2447
|
+
@app.get(
|
|
2448
|
+
"/api/fleet/summary",
|
|
2449
|
+
response_model=FleetSummaryResponse,
|
|
2450
|
+
dependencies=[Depends(auth.require_scope("read"))],
|
|
2451
|
+
)
|
|
2452
|
+
async def get_fleet_summary(include_inactive: bool = True):
|
|
2453
|
+
"""Fleet-wide totals (counts + summed cost) across registered projects."""
|
|
2454
|
+
if not _read_limiter.check("fleet_summary"):
|
|
2455
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
2456
|
+
return await asyncio.to_thread(registry.get_fleet_summary, include_inactive)
|
|
2457
|
+
|
|
2458
|
+
|
|
2459
|
+
@app.get(
|
|
2460
|
+
"/api/fleet/runs/{identifier}",
|
|
2461
|
+
response_model=FleetRunResponse,
|
|
2462
|
+
dependencies=[Depends(auth.require_scope("read"))],
|
|
2463
|
+
)
|
|
2464
|
+
async def get_fleet_run(identifier: str):
|
|
2465
|
+
"""Get a single fleet run by registry id / path / alias.
|
|
2466
|
+
|
|
2467
|
+
Resolves the identifier through the registry (never a caller-supplied
|
|
2468
|
+
arbitrary path), then returns that project's current build snapshot.
|
|
2469
|
+
"""
|
|
2470
|
+
project = await asyncio.to_thread(registry.get_project, identifier)
|
|
2471
|
+
if not project:
|
|
2472
|
+
raise HTTPException(status_code=404, detail="Run not found in fleet")
|
|
2473
|
+
pid = project.get("pid")
|
|
2474
|
+
running = registry._pid_alive(pid)
|
|
2475
|
+
snap = registry._read_project_run_snapshot(project.get("path", ""))
|
|
2476
|
+
duration_seconds = None
|
|
2477
|
+
started_at = snap.get("started_at")
|
|
2478
|
+
if started_at:
|
|
2479
|
+
try:
|
|
2480
|
+
st = datetime.fromisoformat(str(started_at).replace("Z", "+00:00"))
|
|
2481
|
+
if st.tzinfo is None:
|
|
2482
|
+
st = st.replace(tzinfo=timezone.utc)
|
|
2483
|
+
duration_seconds = max(
|
|
2484
|
+
0, int((datetime.now(timezone.utc) - st).total_seconds())
|
|
2485
|
+
)
|
|
2486
|
+
except (ValueError, TypeError):
|
|
2487
|
+
duration_seconds = None
|
|
2488
|
+
path = project.get("path", "")
|
|
2489
|
+
return FleetRunResponse(
|
|
2490
|
+
id=project.get("id"),
|
|
2491
|
+
name=project.get("name") or (os.path.basename(path) if path else "project"),
|
|
2492
|
+
path=path,
|
|
2493
|
+
status="running" if running else (project.get("status") or "unknown"),
|
|
2494
|
+
running=running,
|
|
2495
|
+
phase=snap.get("phase", ""),
|
|
2496
|
+
iteration=snap.get("iteration", 0),
|
|
2497
|
+
cost_usd=snap.get("cost_usd", 0.0),
|
|
2498
|
+
started_at=started_at,
|
|
2499
|
+
duration_seconds=duration_seconds,
|
|
2500
|
+
port=project.get("port"),
|
|
2501
|
+
)
|
|
2502
|
+
|
|
2503
|
+
|
|
2392
2504
|
# =============================================================================
|
|
2393
2505
|
# Active Project Focus (for AI Chat / cross-directory usage)
|
|
2394
2506
|
# =============================================================================
|
|
@@ -3204,6 +3316,130 @@ async def stop_running_project(request: Request, body: RunningProjectStopRequest
|
|
|
3204
3316
|
}
|
|
3205
3317
|
|
|
3206
3318
|
|
|
3319
|
+
@app.post(
|
|
3320
|
+
"/api/fleet/runs/{identifier}/cancel",
|
|
3321
|
+
dependencies=[Depends(auth.require_scope("control"))],
|
|
3322
|
+
)
|
|
3323
|
+
async def cancel_fleet_run(request: Request, identifier: str):
|
|
3324
|
+
"""Cancel ONE build in the fleet view.
|
|
3325
|
+
|
|
3326
|
+
Resolves the run via the registry (by id / path / alias), then runs the
|
|
3327
|
+
same teardown the per-project switcher Stop uses: write a STOP file into the
|
|
3328
|
+
registry-resolved .loki dir (the only path ever written -- never a
|
|
3329
|
+
caller-supplied one) for a clean runner exit, SIGTERM->poll->SIGKILL the
|
|
3330
|
+
recorded orchestrator pid, group-kill + cwd-scoped reap as backstop, then
|
|
3331
|
+
mark the registry/session stopped.
|
|
3332
|
+
|
|
3333
|
+
Retry is intentionally NOT exposed: there is no clean cross-project
|
|
3334
|
+
re-launch primitive in the registry path (the original spec source lives
|
|
3335
|
+
only in each project's CWD). Retry is a documented follow-up.
|
|
3336
|
+
"""
|
|
3337
|
+
if not _control_limiter.check("control"):
|
|
3338
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
3339
|
+
|
|
3340
|
+
project = registry.get_project(identifier)
|
|
3341
|
+
if not project:
|
|
3342
|
+
raise HTTPException(status_code=404, detail="Run not found in fleet")
|
|
3343
|
+
|
|
3344
|
+
project_id = project.get("id")
|
|
3345
|
+
audit.log_event(
|
|
3346
|
+
action="cancel",
|
|
3347
|
+
resource_type="fleet_run",
|
|
3348
|
+
details={"source": "api", "project_id": project_id},
|
|
3349
|
+
ip_address=request.client.host if request.client else None,
|
|
3350
|
+
)
|
|
3351
|
+
|
|
3352
|
+
# Only ever operate on the registry-stored path.
|
|
3353
|
+
path = project.get("path", "")
|
|
3354
|
+
loki_dir = None
|
|
3355
|
+
if path:
|
|
3356
|
+
p = _Path(path)
|
|
3357
|
+
if p.is_dir() and (p / ".loki").is_dir():
|
|
3358
|
+
loki_dir = p / ".loki"
|
|
3359
|
+
|
|
3360
|
+
# STOP file: clean runner teardown (also stops a containerized `loki docker`
|
|
3361
|
+
# build polling the bind-mounted .loki/STOP).
|
|
3362
|
+
stop_signaled = False
|
|
3363
|
+
if loki_dir is not None:
|
|
3364
|
+
try:
|
|
3365
|
+
(loki_dir / "STOP").write_text(datetime.now(timezone.utc).isoformat())
|
|
3366
|
+
stop_signaled = True
|
|
3367
|
+
except OSError:
|
|
3368
|
+
pass
|
|
3369
|
+
|
|
3370
|
+
pid = project.get("pid")
|
|
3371
|
+
stopped = False
|
|
3372
|
+
# Ownership guard (hardening): only direct-kill the registry pid if it is STILL
|
|
3373
|
+
# a process whose cwd is this project's path. A stale pid reused by an unrelated
|
|
3374
|
+
# host process after a crash must NOT be SIGKILLed. If ownership cannot be
|
|
3375
|
+
# confirmed, skip the direct kill and let the cwd-scoped reaper below handle it.
|
|
3376
|
+
if isinstance(pid, int) and pid > 0:
|
|
3377
|
+
_owned = True
|
|
3378
|
+
try:
|
|
3379
|
+
_cwd = _pid_cwd(pid)
|
|
3380
|
+
if _cwd is not None and path:
|
|
3381
|
+
_owned = os.path.realpath(_cwd) == os.path.realpath(path)
|
|
3382
|
+
except Exception:
|
|
3383
|
+
_owned = True # best-effort: if we cannot tell, preserve prior behavior
|
|
3384
|
+
if not _owned:
|
|
3385
|
+
pid = None # do not direct-kill a pid that is not this project's
|
|
3386
|
+
if isinstance(pid, int) and pid > 0:
|
|
3387
|
+
try:
|
|
3388
|
+
os.kill(pid, 15) # SIGTERM
|
|
3389
|
+
for _ in range(10):
|
|
3390
|
+
await asyncio.sleep(0.5)
|
|
3391
|
+
try:
|
|
3392
|
+
os.kill(pid, 0)
|
|
3393
|
+
except OSError:
|
|
3394
|
+
stopped = True
|
|
3395
|
+
break
|
|
3396
|
+
if not stopped:
|
|
3397
|
+
try:
|
|
3398
|
+
os.kill(pid, 9) # SIGKILL
|
|
3399
|
+
stopped = True
|
|
3400
|
+
except (OSError, ProcessLookupError):
|
|
3401
|
+
stopped = True
|
|
3402
|
+
except (ValueError, OSError, ProcessLookupError):
|
|
3403
|
+
stopped = True
|
|
3404
|
+
else:
|
|
3405
|
+
# No host pid (genuinely stopped, or a docker build): the STOP write is
|
|
3406
|
+
# the cancel signal. Treat a successful STOP write as cancelled.
|
|
3407
|
+
stopped = stop_signaled
|
|
3408
|
+
|
|
3409
|
+
# Group-kill + cwd-scoped reaper backstop against a stale pid.
|
|
3410
|
+
if loki_dir is not None:
|
|
3411
|
+
proj_dir = loki_dir.parent
|
|
3412
|
+
_pgid = _read_pgid(loki_dir)
|
|
3413
|
+
if _pgid is not None:
|
|
3414
|
+
await asyncio.to_thread(
|
|
3415
|
+
_killpg_project, _pgid, _collect_protected_pids(loki_dir)
|
|
3416
|
+
)
|
|
3417
|
+
found_any, all_gone = await asyncio.to_thread(
|
|
3418
|
+
_reap_orchestrators_until_clear, proj_dir, str(proj_dir)
|
|
3419
|
+
)
|
|
3420
|
+
if found_any:
|
|
3421
|
+
stopped = all_gone
|
|
3422
|
+
|
|
3423
|
+
# Mark session.json stopped.
|
|
3424
|
+
session_file = loki_dir / "session.json"
|
|
3425
|
+
if session_file.exists():
|
|
3426
|
+
try:
|
|
3427
|
+
sd = json.loads(session_file.read_text())
|
|
3428
|
+
sd["status"] = "stopped"
|
|
3429
|
+
atomic_write_json(session_file, sd, use_lock=True)
|
|
3430
|
+
except Exception:
|
|
3431
|
+
pass
|
|
3432
|
+
|
|
3433
|
+
registry.mark_project_stopped(project_id)
|
|
3434
|
+
|
|
3435
|
+
return {
|
|
3436
|
+
"success": True,
|
|
3437
|
+
"project_id": project_id,
|
|
3438
|
+
"cancelled": stopped,
|
|
3439
|
+
"stop_signaled": stop_signaled,
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3442
|
+
|
|
3207
3443
|
# =============================================================================
|
|
3208
3444
|
# Enterprise Features (Optional - enabled via environment variables)
|
|
3209
3445
|
# =============================================================================
|
|
@@ -4008,7 +4244,7 @@ async def get_memory_summary():
|
|
|
4008
4244
|
return summary
|
|
4009
4245
|
|
|
4010
4246
|
|
|
4011
|
-
@app.get("/api/memory/episodes")
|
|
4247
|
+
@app.get("/api/memory/episodes", dependencies=[Depends(auth.require_scope("read"))])
|
|
4012
4248
|
async def list_episodes(limit: int = Query(default=50, ge=1, le=1000)):
|
|
4013
4249
|
"""List episodic memory entries."""
|
|
4014
4250
|
# Both backends below are blocking (SQLite queries / a glob+read loop over
|
|
@@ -4079,7 +4315,7 @@ async def get_episode(episode_id: str):
|
|
|
4079
4315
|
raise HTTPException(status_code=404, detail="Episode not found")
|
|
4080
4316
|
|
|
4081
4317
|
|
|
4082
|
-
@app.get("/api/memory/patterns")
|
|
4318
|
+
@app.get("/api/memory/patterns", dependencies=[Depends(auth.require_scope("read"))])
|
|
4083
4319
|
async def list_patterns():
|
|
4084
4320
|
"""List semantic patterns."""
|
|
4085
4321
|
# Try SQLite first
|
|
@@ -4118,7 +4354,7 @@ async def get_pattern(pattern_id: str):
|
|
|
4118
4354
|
raise HTTPException(status_code=404, detail="Pattern not found")
|
|
4119
4355
|
|
|
4120
4356
|
|
|
4121
|
-
@app.get("/api/memory/skills")
|
|
4357
|
+
@app.get("/api/memory/skills", dependencies=[Depends(auth.require_scope("read"))])
|
|
4122
4358
|
async def list_skills():
|
|
4123
4359
|
"""List procedural skills."""
|
|
4124
4360
|
# Blocking SQLite query / glob+read loop; offload the whole read so the
|
|
@@ -4343,7 +4579,7 @@ async def retrieve_memory(query: dict = None):
|
|
|
4343
4579
|
raise HTTPException(status_code=503, detail=f"Retrieval unavailable: {e}")
|
|
4344
4580
|
|
|
4345
4581
|
|
|
4346
|
-
@app.get("/api/memory/index")
|
|
4582
|
+
@app.get("/api/memory/index", dependencies=[Depends(auth.require_scope("read"))])
|
|
4347
4583
|
async def get_memory_index():
|
|
4348
4584
|
"""Get memory index (Layer 1 - lightweight discovery)."""
|
|
4349
4585
|
index_file = _get_loki_dir() / "memory" / "index.json"
|
|
@@ -4355,7 +4591,7 @@ async def get_memory_index():
|
|
|
4355
4591
|
return {"topics": [], "lastUpdated": None}
|
|
4356
4592
|
|
|
4357
4593
|
|
|
4358
|
-
@app.get("/api/memory/timeline")
|
|
4594
|
+
@app.get("/api/memory/timeline", dependencies=[Depends(auth.require_scope("read"))])
|
|
4359
4595
|
async def get_memory_timeline():
|
|
4360
4596
|
"""Get memory timeline (Layer 2 - progressive disclosure)."""
|
|
4361
4597
|
timeline_file = _get_loki_dir() / "memory" / "timeline.json"
|
|
@@ -4550,7 +4786,7 @@ def _get_memory_storage():
|
|
|
4550
4786
|
return None
|
|
4551
4787
|
|
|
4552
4788
|
|
|
4553
|
-
@app.get("/api/memory/search")
|
|
4789
|
+
@app.get("/api/memory/search", dependencies=[Depends(auth.require_scope("read"))])
|
|
4554
4790
|
async def search_memory(
|
|
4555
4791
|
q: str = Query(..., min_length=1, max_length=500, description="Search query"),
|
|
4556
4792
|
collection: str = Query(default="all", pattern="^(episodes|patterns|skills|all)$"),
|
|
@@ -4588,7 +4824,7 @@ async def search_memory(
|
|
|
4588
4824
|
raise HTTPException(status_code=500, detail=f"Search failed: {e}")
|
|
4589
4825
|
|
|
4590
4826
|
|
|
4591
|
-
@app.get("/api/memory/stats")
|
|
4827
|
+
@app.get("/api/memory/stats", dependencies=[Depends(auth.require_scope("read"))])
|
|
4592
4828
|
async def get_memory_stats():
|
|
4593
4829
|
"""Get memory system statistics (counts, size, backend info)."""
|
|
4594
4830
|
# SQLite stats query or a directory-walk over many JSON files; both block,
|
|
@@ -6860,6 +7096,62 @@ except ImportError as e:
|
|
|
6860
7096
|
logger.debug(f"Collaboration module not available: {e}")
|
|
6861
7097
|
|
|
6862
7098
|
|
|
7099
|
+
class _CollabWsAuthMiddleware:
|
|
7100
|
+
"""ASGI middleware that auth-gates the native /ws/collab WebSocket.
|
|
7101
|
+
|
|
7102
|
+
The collaboration module registers @app.websocket("/ws/collab") inside
|
|
7103
|
+
create_collab_routes() and performs NO token validation: it accepts any
|
|
7104
|
+
connection and trusts a client-supplied ?user_id=. With enterprise auth or
|
|
7105
|
+
OIDC enabled this native WS is therefore reachable UNAUTHENTICATED, exposing
|
|
7106
|
+
user presence, shared state, and operation sync to any client. The dashboard
|
|
7107
|
+
cannot rely on route dependencies for WebSockets (FastAPI Depends() is not
|
|
7108
|
+
supported on @app.websocket routes), so this middleware validates the token
|
|
7109
|
+
on the /ws/collab handshake before the route runs, mirroring the native /ws
|
|
7110
|
+
gate and the _MountAuthGuard WS logic.
|
|
7111
|
+
|
|
7112
|
+
Scope: the collab WS handle_message path applies state operations (writes)
|
|
7113
|
+
via ws_manager.handle_message -> sync.apply_operation, so a valid but
|
|
7114
|
+
read-only token must not be admitted. This requires the "control" scope to
|
|
7115
|
+
match the _MountAuthGuard WS scope-check pattern (a valid token alone is not
|
|
7116
|
+
enough), so a read-only token is closed 1008 even though it authenticates.
|
|
7117
|
+
|
|
7118
|
+
When enterprise auth and OIDC are both OFF this is a pass-through, so local
|
|
7119
|
+
default-mode behavior is unchanged. Non-websocket scopes and other websocket
|
|
7120
|
+
paths (the native /ws self-guards in-route) are passed through untouched.
|
|
7121
|
+
Added via app.add_middleware(), so app stays a FastAPI instance and all
|
|
7122
|
+
later route registrations are unaffected.
|
|
7123
|
+
"""
|
|
7124
|
+
|
|
7125
|
+
def __init__(self, app) -> None:
|
|
7126
|
+
self._app = app
|
|
7127
|
+
|
|
7128
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
7129
|
+
if scope.get("type") != "websocket" or scope.get("path") != "/ws/collab":
|
|
7130
|
+
await self._app(scope, receive, send)
|
|
7131
|
+
return
|
|
7132
|
+
if not auth.is_enterprise_mode() and not auth.is_oidc_mode():
|
|
7133
|
+
await self._app(scope, receive, send)
|
|
7134
|
+
return
|
|
7135
|
+
token_str = _MountAuthGuard._ws_token_from_scope(scope)
|
|
7136
|
+
token_info = _MountAuthGuard._validate_ws_token(token_str)
|
|
7137
|
+
if token_info is None or not auth.has_scope(token_info, "control"):
|
|
7138
|
+
# Accept-then-close is the portable ASGI way to surface a policy
|
|
7139
|
+
# violation to the client before any route code runs. 1008 = policy
|
|
7140
|
+
# violation, matching the native /ws and _MountAuthGuard behavior.
|
|
7141
|
+
# "control" (not just a valid token) is required because the collab
|
|
7142
|
+
# WS path performs state writes; a read-only token is rejected here.
|
|
7143
|
+
await send({"type": "websocket.accept"})
|
|
7144
|
+
await send({"type": "websocket.close", "code": 1008})
|
|
7145
|
+
return
|
|
7146
|
+
await self._app(scope, receive, send)
|
|
7147
|
+
|
|
7148
|
+
|
|
7149
|
+
# Gate the /ws/collab handshake before the unauthenticated collab route runs.
|
|
7150
|
+
# Registered as middleware so app remains a FastAPI instance (route decorators
|
|
7151
|
+
# below keep working).
|
|
7152
|
+
app.add_middleware(_CollabWsAuthMiddleware)
|
|
7153
|
+
|
|
7154
|
+
|
|
6863
7155
|
# =============================================================================
|
|
6864
7156
|
# Secrets / Credential Status
|
|
6865
7157
|
# =============================================================================
|