loki-mode 7.13.0 → 7.15.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 +4 -2
- package/VERSION +1 -1
- package/autonomy/loki +364 -16
- package/autonomy/run.sh +62 -12
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +94 -0
- package/dashboard/static/index.html +12 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/OPEN-CORE-BOUNDARY.md +58 -0
- package/docs/R6-ROLLBACK-CHECKPOINT-PLAN.md +107 -0
- package/docs/R9-OPEN-CORE-HOOKS-PLAN.md +113 -0
- package/loki-ts/dist/loki.js +244 -211
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
package/dashboard/server.py
CHANGED
|
@@ -5396,6 +5396,100 @@ async def create_checkpoint(body: CheckpointCreate = None):
|
|
|
5396
5396
|
return metadata
|
|
5397
5397
|
|
|
5398
5398
|
|
|
5399
|
+
@app.post(
|
|
5400
|
+
"/api/checkpoints/{checkpoint_id}/rollback",
|
|
5401
|
+
dependencies=[Depends(auth.require_scope("control"))],
|
|
5402
|
+
)
|
|
5403
|
+
async def rollback_checkpoint(checkpoint_id: str):
|
|
5404
|
+
"""Restore .loki/ state from a checkpoint (R6: un-deads the dashboard
|
|
5405
|
+
rollback button, which already POSTed here).
|
|
5406
|
+
|
|
5407
|
+
Safety:
|
|
5408
|
+
- require_scope("control"): destructive, so it needs the control scope.
|
|
5409
|
+
- _sanitize_checkpoint_id: blocks path traversal.
|
|
5410
|
+
- Re-undoability invariant: a forced pre-rollback snapshot of current state
|
|
5411
|
+
is captured BEFORE overwriting, so the human can undo the undo. The
|
|
5412
|
+
pre_rollback_snapshot id is returned in the response so the caller can
|
|
5413
|
+
surface it to the user.
|
|
5414
|
+
- Glob-restore: copies back whatever files the checkpoint dir contains, so it
|
|
5415
|
+
works regardless of which writer (run.sh / loki / dashboard) created it.
|
|
5416
|
+
"""
|
|
5417
|
+
import shutil
|
|
5418
|
+
|
|
5419
|
+
checkpoint_id = _sanitize_checkpoint_id(checkpoint_id)
|
|
5420
|
+
loki_dir = _get_loki_dir()
|
|
5421
|
+
checkpoints_dir = loki_dir / "state" / "checkpoints"
|
|
5422
|
+
cp_dir = checkpoints_dir / checkpoint_id
|
|
5423
|
+
|
|
5424
|
+
if not cp_dir.is_dir():
|
|
5425
|
+
raise HTTPException(status_code=404, detail="Checkpoint not found")
|
|
5426
|
+
|
|
5427
|
+
# 1. Forced pre-rollback snapshot of current state (re-undoability).
|
|
5428
|
+
now = datetime.now(timezone.utc)
|
|
5429
|
+
pre_id = now.strftime("rb-pre-%Y%m%d-%H%M%S")
|
|
5430
|
+
pre_dir = checkpoints_dir / pre_id
|
|
5431
|
+
pre_dir.mkdir(parents=True, exist_ok=True)
|
|
5432
|
+
for name in ("session.json", "dashboard-state.json", "CONTINUITY.md", "autonomy-state.json"):
|
|
5433
|
+
src = loki_dir / name
|
|
5434
|
+
if src.exists() and src.is_file():
|
|
5435
|
+
try:
|
|
5436
|
+
shutil.copy2(str(src), str(pre_dir / name))
|
|
5437
|
+
except Exception:
|
|
5438
|
+
pass
|
|
5439
|
+
for dname in ("state", "queue"):
|
|
5440
|
+
src = loki_dir / dname
|
|
5441
|
+
if src.exists() and src.is_dir():
|
|
5442
|
+
try:
|
|
5443
|
+
shutil.copytree(str(src), str(pre_dir / dname), dirs_exist_ok=True)
|
|
5444
|
+
except Exception:
|
|
5445
|
+
pass
|
|
5446
|
+
pre_meta = {
|
|
5447
|
+
"id": pre_id,
|
|
5448
|
+
"created_at": now.isoformat(),
|
|
5449
|
+
"message": f"pre-rollback snapshot (before restoring {checkpoint_id})",
|
|
5450
|
+
"created_by": "dashboard rollback",
|
|
5451
|
+
}
|
|
5452
|
+
try:
|
|
5453
|
+
(pre_dir / "metadata.json").write_text(json.dumps(pre_meta, indent=2))
|
|
5454
|
+
with open(str(checkpoints_dir / "index.jsonl"), "a") as f:
|
|
5455
|
+
f.write(json.dumps(pre_meta) + "\n")
|
|
5456
|
+
except Exception:
|
|
5457
|
+
pass
|
|
5458
|
+
|
|
5459
|
+
# 2. Glob-restore the checkpoint contents back into .loki/.
|
|
5460
|
+
# IMPORTANT: never rmtree a destination dir wholesale -- the checkpoint store
|
|
5461
|
+
# itself lives under .loki/state/checkpoints/, so deleting .loki/state/ would
|
|
5462
|
+
# destroy every checkpoint (including the one being restored AND the
|
|
5463
|
+
# pre-rollback snapshot we just made). Merge directories with dirs_exist_ok
|
|
5464
|
+
# so the checkpoints store survives.
|
|
5465
|
+
restored = 0
|
|
5466
|
+
errors = []
|
|
5467
|
+
for item in cp_dir.iterdir():
|
|
5468
|
+
if item.name in ("metadata.json", "worktree-snapshot.txt"):
|
|
5469
|
+
continue
|
|
5470
|
+
dest = loki_dir / item.name
|
|
5471
|
+
try:
|
|
5472
|
+
if item.is_dir():
|
|
5473
|
+
shutil.copytree(str(item), str(dest), dirs_exist_ok=True)
|
|
5474
|
+
else:
|
|
5475
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
5476
|
+
shutil.copy2(str(item), str(dest))
|
|
5477
|
+
restored += 1
|
|
5478
|
+
except Exception as e: # noqa: BLE001 -- report, do not abort other files
|
|
5479
|
+
errors.append(f"{item.name}: {e}")
|
|
5480
|
+
|
|
5481
|
+
return {
|
|
5482
|
+
"id": checkpoint_id,
|
|
5483
|
+
"restored": restored,
|
|
5484
|
+
"pre_rollback_snapshot": pre_id,
|
|
5485
|
+
"errors": errors,
|
|
5486
|
+
"message": (
|
|
5487
|
+
f"Restored {restored} item(s) from {checkpoint_id}. "
|
|
5488
|
+
f"Prior state saved as {pre_id} (undo this rollback by restoring it)."
|
|
5489
|
+
),
|
|
5490
|
+
}
|
|
5491
|
+
|
|
5492
|
+
|
|
5399
5493
|
# =============================================================================
|
|
5400
5494
|
# Agent Management API (v5.25.0)
|
|
5401
5495
|
# =============================================================================
|
|
@@ -6914,7 +6914,7 @@ var LokiDashboard=(()=>{var $t=Object.defineProperty;var ae=Object.getOwnPropert
|
|
|
6914
6914
|
</div>
|
|
6915
6915
|
</div>
|
|
6916
6916
|
</div>
|
|
6917
|
-
`}};customElements.get("loki-cost-dashboard")||customElements.define("loki-cost-dashboard",X);var Z=class extends h{static get observedAttributes(){return["api-url","theme"]}constructor(){super(),this._loading=!1,this._error=null,this._api=null,this._checkpoints=[],this._pollInterval=null,this._lastDataHash=null,this._showCreateForm=!1,this._creating=!1,this._rollingBack=!1,this._rollbackTarget=null}connectedCallback(){super.connectedCallback(),this._setupApi(),this._loadData(),this._startPolling()}disconnectedCallback(){super.disconnectedCallback(),this._stopPolling()}attributeChangedCallback(t,e,i){e!==i&&(t==="api-url"&&this._api&&(this._api.baseUrl=i,this._loadData()),t==="theme"&&this._applyTheme())}_setupApi(){let t=this.getAttribute("api-url")||window.location.origin;this._api=g({baseUrl:t})}_startPolling(){this._pollInterval=setInterval(()=>this._loadData(),3e3),this._visibilityHandler=()=>{document.hidden?this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null):this._pollInterval||(this._loadData(),this._pollInterval=setInterval(()=>this._loadData(),3e3))},document.addEventListener("visibilitychange",this._visibilityHandler)}_stopPolling(){this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null),this._visibilityHandler&&(document.removeEventListener("visibilitychange",this._visibilityHandler),this._visibilityHandler=null)}async _loadData(){try{let[e]=await Promise.allSettled([this._api._get("/api/checkpoints?limit=50")]);e.status==="fulfilled"&&(this._checkpoints=Array.isArray(e.value)?e.value:e.value?.checkpoints||[]),this._error=null}catch(e){this._error=e.message}let t=JSON.stringify({c:this._checkpoints,e:this._error});t!==this._lastDataHash&&(this._lastDataHash=t,this.render())}async _createCheckpoint(){let t=this.shadowRoot.getElementById("checkpoint-message"),e=t?t.value.trim():"";if(e){this._creating=!0,this.render();try{await this._api._post("/api/checkpoints",{message:e}),this._showCreateForm=!1,this._creating=!1,this.dispatchEvent(new CustomEvent("checkpoint-action",{detail:{action:"create",message:e},bubbles:!0})),this._lastDataHash=null,await this._loadData()}catch(i){this._creating=!1,this._error=`Failed to create checkpoint: ${i.message}`,this.render()}}}async _rollbackCheckpoint(t){if(!this._rollingBack){this._rollingBack=!0,this.render();try{await this._api._post(`/api/checkpoints/${t}/rollback`)
|
|
6917
|
+
`}};customElements.get("loki-cost-dashboard")||customElements.define("loki-cost-dashboard",X);var Z=class extends h{static get observedAttributes(){return["api-url","theme"]}constructor(){super(),this._loading=!1,this._error=null,this._api=null,this._checkpoints=[],this._pollInterval=null,this._lastDataHash=null,this._showCreateForm=!1,this._creating=!1,this._rollingBack=!1,this._rollbackTarget=null,this._notice=null}connectedCallback(){super.connectedCallback(),this._setupApi(),this._loadData(),this._startPolling()}disconnectedCallback(){super.disconnectedCallback(),this._stopPolling()}attributeChangedCallback(t,e,i){e!==i&&(t==="api-url"&&this._api&&(this._api.baseUrl=i,this._loadData()),t==="theme"&&this._applyTheme())}_setupApi(){let t=this.getAttribute("api-url")||window.location.origin;this._api=g({baseUrl:t})}_startPolling(){this._pollInterval=setInterval(()=>this._loadData(),3e3),this._visibilityHandler=()=>{document.hidden?this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null):this._pollInterval||(this._loadData(),this._pollInterval=setInterval(()=>this._loadData(),3e3))},document.addEventListener("visibilitychange",this._visibilityHandler)}_stopPolling(){this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null),this._visibilityHandler&&(document.removeEventListener("visibilitychange",this._visibilityHandler),this._visibilityHandler=null)}async _loadData(){try{let[e]=await Promise.allSettled([this._api._get("/api/checkpoints?limit=50")]);e.status==="fulfilled"&&(this._checkpoints=Array.isArray(e.value)?e.value:e.value?.checkpoints||[]),this._error=null}catch(e){this._error=e.message}let t=JSON.stringify({c:this._checkpoints,e:this._error});t!==this._lastDataHash&&(this._lastDataHash=t,this.render())}async _createCheckpoint(){let t=this.shadowRoot.getElementById("checkpoint-message"),e=t?t.value.trim():"";if(e){this._creating=!0,this.render();try{await this._api._post("/api/checkpoints",{message:e}),this._showCreateForm=!1,this._creating=!1,this.dispatchEvent(new CustomEvent("checkpoint-action",{detail:{action:"create",message:e},bubbles:!0})),this._lastDataHash=null,await this._loadData()}catch(i){this._creating=!1,this._error=`Failed to create checkpoint: ${i.message}`,this.render()}}}async _rollbackCheckpoint(t){if(!this._rollingBack){this._rollingBack=!0,this._notice=null,this.render();try{let e=await this._api._post(`/api/checkpoints/${t}/rollback`);this._rollbackTarget=null;let i=e&&e.pre_rollback_snapshot;this._notice=i?`Rolled back to ${t}. Undo this with checkpoint ${i}`:`Rolled back to ${t}.`,this.dispatchEvent(new CustomEvent("checkpoint-action",{detail:{action:"rollback",checkpointId:t,preRollbackSnapshot:i||null},bubbles:!0})),this._lastDataHash=null,await this._loadData()}catch(e){this._rollbackTarget=null,this._error=`Failed to rollback: ${e.message}`}finally{this._rollingBack=!1,this.render()}}}_toggleCreateForm(){this._showCreateForm=!this._showCreateForm,this._rollbackTarget=null,this._notice=null,this.render()}_confirmRollback(t){this._rollbackTarget=t,this.render()}_cancelRollback(){this._rollbackTarget=null,this._notice=null,this.render()}_formatRelativeTime(t){if(!t)return"";try{let e=Date.now(),i=new Date(t).getTime(),a=Math.floor((e-i)/1e3);return a<60?`${a}s ago`:a<3600?`${Math.floor(a/60)}m ago`:a<86400?`${Math.floor(a/3600)}h ago`:`${Math.floor(a/86400)}d ago`}catch{return this._escapeHTML(t)}}render(){let t=this.shadowRoot;if(!t)return;let e=this._checkpoints.length;t.innerHTML=`
|
|
6918
6918
|
<style>${this.getBaseStyles()}${this._getStyles()}</style>
|
|
6919
6919
|
<div class="checkpoint-viewer">
|
|
6920
6920
|
<div class="checkpoint-header">
|
|
@@ -6935,6 +6935,7 @@ var LokiDashboard=(()=>{var $t=Object.defineProperty;var ae=Object.getOwnPropert
|
|
|
6935
6935
|
${this._checkpoints.map(i=>this._renderCheckpointCard(i)).join("")}
|
|
6936
6936
|
</div>
|
|
6937
6937
|
|
|
6938
|
+
${this._notice?`<div class="notice-banner">${this._escapeHTML(this._notice)}</div>`:""}
|
|
6938
6939
|
${this._error?`<div class="error-banner">${this._escapeHTML(this._error)}</div>`:""}
|
|
6939
6940
|
</div>
|
|
6940
6941
|
`,this._attachEventListeners()}_renderCreateForm(){return`
|
|
@@ -7222,6 +7223,16 @@ var LokiDashboard=(()=>{var $t=Object.defineProperty;var ae=Object.getOwnPropert
|
|
|
7222
7223
|
color: var(--loki-red);
|
|
7223
7224
|
font-size: 12px;
|
|
7224
7225
|
}
|
|
7226
|
+
|
|
7227
|
+
.notice-banner {
|
|
7228
|
+
margin-top: 12px;
|
|
7229
|
+
padding: 10px 14px;
|
|
7230
|
+
background: var(--loki-green-muted, rgba(34, 197, 94, 0.12));
|
|
7231
|
+
border: 1px solid var(--loki-green-muted, rgba(34, 197, 94, 0.3));
|
|
7232
|
+
border-radius: 4px;
|
|
7233
|
+
color: var(--loki-green, #22c55e);
|
|
7234
|
+
font-size: 12px;
|
|
7235
|
+
}
|
|
7225
7236
|
`}};customElements.get("loki-checkpoint-viewer")||customElements.define("loki-checkpoint-viewer",Z);var tt=class extends h{static get observedAttributes(){return["api-url","theme"]}constructor(){super(),this._data=null,this._connected=!1,this._activeTab="gauge",this._api=null,this._pollInterval=null}connectedCallback(){super.connectedCallback(),this._setupApi(),this._loadContext(),this._startPolling()}disconnectedCallback(){super.disconnectedCallback(),this._stopPolling()}attributeChangedCallback(t,e,i){e!==i&&(t==="api-url"&&this._api&&(this._api.baseUrl=i,this._loadContext()),t==="theme"&&this._applyTheme())}_setupApi(){let t=this.getAttribute("api-url")||window.location.origin;this._api=g({baseUrl:t})}async _loadContext(){try{let t=this.getAttribute("api-url")||window.location.origin,e=await fetch(t+"/api/context");e.ok&&(this._data=await e.json(),this._connected=!0)}catch{this._connected=!1}this.render()}_startPolling(){this._pollInterval=setInterval(()=>{this._loadContext()},5e3),this._visibilityHandler=()=>{document.hidden?this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null):this._pollInterval||(this._loadContext(),this._pollInterval=setInterval(()=>this._loadContext(),5e3))},document.addEventListener("visibilitychange",this._visibilityHandler)}_stopPolling(){this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null),this._visibilityHandler&&(document.removeEventListener("visibilitychange",this._visibilityHandler),this._visibilityHandler=null)}_setTab(t){this._activeTab=t,this.render()}_formatTokens(t){return!t||t===0?"0":t>=1e6?(t/1e6).toFixed(2)+"M":t>=1e3?(t/1e3).toFixed(1)+"K":String(t)}_formatUSD(t){return!t||t===0?"$0.00":t<.01?"<$0.01":"$"+t.toFixed(2)}_escapeHTML(t){return t?String(t).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""):""}_getGaugeColor(t){return t>80?"var(--loki-red)":t>=60?"var(--loki-yellow)":"var(--loki-green)"}_getGaugeColorClass(t){return t>80?"gauge-red":t>=60?"gauge-yellow":"gauge-green"}_renderGaugeTab(){let t=this._data?.current||{},e=this._data?.totals||{},i=t.context_window_pct||0,a=this._getGaugeColor(i),s=this._getGaugeColorClass(i),r=70,o=2*Math.PI*r,n=o-i/100*o;return`
|
|
7226
7237
|
<div class="gauge-tab">
|
|
7227
7238
|
<div class="gauge-container">
|
package/docs/INSTALLATION.md
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Loki Mode open-core boundary
|
|
2
|
+
|
|
3
|
+
Loki Mode is and stays open source. This document draws the line between what is
|
|
4
|
+
free forever and what hosted/paid/enterprise plans would add on top. R9 ships
|
|
5
|
+
the SEAMS for that line; it does not ship a hosted backend, a license server, or
|
|
6
|
+
any paywall on existing functionality.
|
|
7
|
+
|
|
8
|
+
## Principle
|
|
9
|
+
|
|
10
|
+
OSS is fully functional with zero hosted backend. Every capability Loki has
|
|
11
|
+
today runs locally, free, with no account, no license key, and no network call
|
|
12
|
+
to any Loki service. Hosted/paid features are ADDITIVE convenience and
|
|
13
|
+
team/enterprise layers, never a removal or gating of something that is free
|
|
14
|
+
today.
|
|
15
|
+
|
|
16
|
+
## Free forever (OSS, the default)
|
|
17
|
+
|
|
18
|
+
Everything that exists today, including:
|
|
19
|
+
|
|
20
|
+
- The full RARV-C autonomous loop (`loki start`), all providers
|
|
21
|
+
(Claude/Cline/Codex/Aider), multi-project, dashboard, memory system.
|
|
22
|
+
- 3-reviewer council + RARV-C closure (the trust engine).
|
|
23
|
+
- proof-of-run generation and local inspection: `loki proof list|show|open`.
|
|
24
|
+
- Sharing a proof to a GitHub Gist: `loki proof share <id>` (uses your own `gh`
|
|
25
|
+
auth; no Loki service involved).
|
|
26
|
+
- Benchmark harness (`loki bench`), healing (`loki heal`), all CLI commands.
|
|
27
|
+
- Self-hosting the hosted publish endpoint: `loki proof share --hosted` posts to
|
|
28
|
+
YOUR `LOKI_HOSTED_ENDPOINT`. Running your own endpoint is free.
|
|
29
|
+
- Enterprise auth seams that already exist and are env-gated, not paywalled:
|
|
30
|
+
token auth (`LOKI_ENTERPRISE_AUTH`), OIDC/SSO (`LOKI_OIDC_*`), audit logging.
|
|
31
|
+
|
|
32
|
+
The default tier is `oss` (`LOKI_TIER` unset or `oss`). In OSS tier the
|
|
33
|
+
tier/license gate is a no-op that allows everything.
|
|
34
|
+
|
|
35
|
+
## What hosted / paid / enterprise would add (seams, not yet built)
|
|
36
|
+
|
|
37
|
+
These are the attachment points R9 reserves. None of them are live; none gate
|
|
38
|
+
any free feature.
|
|
39
|
+
|
|
40
|
+
| Capability | Seam (env / hook) | Status |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| Hosted proof publishing to a managed Loki URL (instead of a gist or self-hosted endpoint) | `LOKI_HOSTED_ENDPOINT` + `loki proof share --hosted` | Seam only. No official Loki endpoint exists. Operators can point it at their own. |
|
|
43
|
+
| Tier / license entitlement | `LOKI_TIER` (default `oss`), `LOKI_LICENSE_KEY`, `loki_tier_gate` (bash) / `tierGate` (Bun) | Seam only. No verification backend. OSS = allow-all no-op. |
|
|
44
|
+
| Managed team memory / cross-project sync | `LOKI_MANAGED_MEMORY` (pre-existing) | Pre-existing gated seam. |
|
|
45
|
+
| Enterprise SSO / RBAC / audit retention | `LOKI_ENTERPRISE_AUTH`, `LOKI_OIDC_*` | Pre-existing env seams (free to self-configure). |
|
|
46
|
+
|
|
47
|
+
A future hosted build would replace the honest stubs (no-op allow / "backend not
|
|
48
|
+
available" messages) with real verification and a managed endpoint. Until then,
|
|
49
|
+
the stubs are labeled honestly and never fabricate a hosted service or URL.
|
|
50
|
+
|
|
51
|
+
## Integrity rules (binding for any future hosted work)
|
|
52
|
+
|
|
53
|
+
1. Never gate or remove a feature that is free today.
|
|
54
|
+
2. Never fabricate a hosted URL, a successful license verification, or a hosted
|
|
55
|
+
service that does not exist. Honest "not available yet" messaging only.
|
|
56
|
+
3. OSS path must work with zero hosted env vars set.
|
|
57
|
+
4. Any artifact published via a hosted seam must pass through the same redactor
|
|
58
|
+
the gist path uses (`proof_redact`); never publish an unredacted artifact.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# R6: 1-Click Rollback + Checkpoint UX (Design Note)
|
|
2
|
+
|
|
3
|
+
Status: implemented in this worktree (not committed to main). For integrator cherry-pick.
|
|
4
|
+
Goal: make Loki's existing checkpoint/rollback infra EXCELLENT and OBVIOUS so users
|
|
5
|
+
run autonomous work boldly, knowing a mistake is one action from undone.
|
|
6
|
+
|
|
7
|
+
## 1. Verified existing code (read, not assumed)
|
|
8
|
+
|
|
9
|
+
### Checkpoint writers (THREE divergent formats, one shared index.jsonl)
|
|
10
|
+
|
|
11
|
+
| Writer | ID format | Files captured | Source |
|
|
12
|
+
|---|---|---|---|
|
|
13
|
+
| `create_checkpoint()` (automatic, per iteration) | `cp-{iter}-{epoch}` | `state/orchestrator.json`, `autonomy-state.json`, `queue/{pending,completed,in-progress,current-task}.json` | `autonomy/run.sh:7373` |
|
|
14
|
+
| `cmd_checkpoint create` (manual CLI) | `cp-{ts}` | `session.json`, `dashboard-state.json`, `queue/`, `memory/`, `metrics/`, `council/` (recursive copy) | `autonomy/loki:16122` |
|
|
15
|
+
| `POST /api/checkpoints` (dashboard) | `chk-{ts}` | `dashboard-state.json`, `session.json`, `queue/` | `dashboard/server.py:5085` |
|
|
16
|
+
|
|
17
|
+
All three append to `.loki/state/checkpoints/index.jsonl` (field names differ; the
|
|
18
|
+
dashboard `GET /api/checkpoints` normalizes them).
|
|
19
|
+
|
|
20
|
+
### Restore (rollback) paths
|
|
21
|
+
|
|
22
|
+
| Path | Restores | Source |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `rollback_to_checkpoint()` (internal, run.sh) | state + queue json (NOT autonomy-state) | `autonomy/run.sh:7473` |
|
|
25
|
+
| `loki checkpoint rollback <id>` (bash CLI) | glob-restores whatever is in the cp dir | `autonomy/loki:16263` |
|
|
26
|
+
| `loki rollback to|latest <id>` (Bun) | the 5 RESTORE_FILES | `loki-ts/src/commands/rollback.ts` + `loki-ts/src/runner/checkpoint.ts:632` |
|
|
27
|
+
|
|
28
|
+
### Bun runner checkpoint API (`loki-ts/src/runner/checkpoint.ts`, 700 lines)
|
|
29
|
+
- `createCheckpoint`, `listCheckpoints`, `readCheckpoint`, `rollbackToCheckpoint` (planner),
|
|
30
|
+
`executeRollback` (copier). Byte-for-byte parity with bash `create_checkpoint`.
|
|
31
|
+
- `metadata.json` keys are pinned by `loki-ts/tests/runner/checkpoint.test.ts:62-91`
|
|
32
|
+
(`Object.keys(m).sort()`) -- they MUST NOT change.
|
|
33
|
+
|
|
34
|
+
### Dashboard
|
|
35
|
+
- `GET /api/checkpoints`, `GET /api/checkpoints/{id}`, `POST /api/checkpoints` exist.
|
|
36
|
+
- The UI component `dashboard-ui/components/loki-checkpoint-viewer.js` (601 lines) ALREADY
|
|
37
|
+
renders list + create + rollback-with-two-step-confirm and POSTs to
|
|
38
|
+
`POST /api/checkpoints/{id}/rollback` -- but that endpoint **did not exist**. The
|
|
39
|
+
dashboard rollback button was DEAD.
|
|
40
|
+
|
|
41
|
+
## 2. Three decisions (verified facts, not assumptions)
|
|
42
|
+
|
|
43
|
+
### Decision A: what "truly undo an iteration" restores
|
|
44
|
+
Verified fact: Loki does NOT commit per iteration (`grep "git commit" autonomy/run.sh`
|
|
45
|
+
finds only merge-conflict `git add`, no per-iteration commit). Therefore the captured
|
|
46
|
+
`git_sha` is HEAD (the last commit), and `git reset --hard <git_sha>` would discard the
|
|
47
|
+
iteration's work PLUS anything since the last commit -- it cannot reconstruct a specific
|
|
48
|
+
iteration's working tree. The pre-existing printed hint `git reset --hard <git_sha>`
|
|
49
|
+
(run.sh:7541, loki:16314) was therefore actively MISLEADING.
|
|
50
|
+
|
|
51
|
+
Resolution: capture a real working-tree snapshot at checkpoint time via `git stash create`
|
|
52
|
+
(captures tracked changes without disturbing the tree), then ANCHOR it with
|
|
53
|
+
`git update-ref refs/loki/cp/<id> <sha>` so `git gc` cannot prune the dangling commit.
|
|
54
|
+
The snapshot sha is written to a SIDECAR file `worktree-snapshot.txt` in the checkpoint
|
|
55
|
+
dir (NOT metadata.json, to preserve byte parity). Restore of code is opt-in
|
|
56
|
+
(`loki rollback to <id> --code`) because it overwrites tracked files; by default the
|
|
57
|
+
durable, correct recovery command is printed: `git stash apply refs/loki/cp/<id>`.
|
|
58
|
+
|
|
59
|
+
State + `.loki/CONTINUITY.md` (the iteration/conversation handoff context) are
|
|
60
|
+
auto-restored -- this is the always-on "undo the iteration's state + context".
|
|
61
|
+
|
|
62
|
+
Honest gap: `git stash create` captures tracked changes only; it does NOT capture
|
|
63
|
+
untracked or ignored files. Files the iteration newly ADDED and never committed are not in
|
|
64
|
+
the snapshot, and an apply will not delete them. Documented, not hidden.
|
|
65
|
+
|
|
66
|
+
### Decision B: re-undoability invariant
|
|
67
|
+
Every restore path FORCES a pre-rollback snapshot of current state before overwriting, so
|
|
68
|
+
a rollback is itself trivially undoable. The Bun `executePlan`, the dashboard endpoint, and
|
|
69
|
+
bash `cmd_rollback` all create a forced checkpoint first (bash `create_checkpoint`
|
|
70
|
+
early-returns on a clean tree, so the new code path forces it). The human dashboard path
|
|
71
|
+
ALSO keeps the existing two-step confirm. Autonomous paths never block on a prompt.
|
|
72
|
+
|
|
73
|
+
### Decision C: do not add a 4th format, do not unify the 3
|
|
74
|
+
Unifying would break the byte-for-byte parity test; a 4th compounds the problem. The new
|
|
75
|
+
dashboard restore endpoint GLOB-restores whatever the checkpoint dir contains (mirrors bash
|
|
76
|
+
`cmd_checkpoint rollback`), so it works for all three writers. New fields go in sidecars.
|
|
77
|
+
|
|
78
|
+
## 3. Changes (parity-organized)
|
|
79
|
+
|
|
80
|
+
- **bash `autonomy/loki`**: add top-level `rollback)` dispatch + `cmd_rollback`
|
|
81
|
+
(`list|show|to|latest`, `--code` flag). `to|latest` delegate to the existing
|
|
82
|
+
`cmd_checkpoint rollback` restore body after forcing a pre-rollback snapshot, then
|
|
83
|
+
optionally apply the anchored code snapshot. Prominent "you can undo this" output.
|
|
84
|
+
- **bash `autonomy/run.sh`**: `create_checkpoint` also copies `CONTINUITY.md`, creates +
|
|
85
|
+
anchors the worktree snapshot (sidecar), echoes the checkpoint id; `rollback_to_checkpoint`
|
|
86
|
+
also restores `CONTINUITY.md` and forces the pre-rollback snapshot. The per-iteration call
|
|
87
|
+
site emits a prominent "Checkpoint <id> created -- undo with `loki rollback to <id>`".
|
|
88
|
+
The misleading `git reset --hard` hint replaced with `git stash apply refs/loki/cp/<id>`.
|
|
89
|
+
- **Bun `loki-ts/src/runner/checkpoint.ts`**: copy `CONTINUITY.md` into the checkpoint
|
|
90
|
+
(additive, parity-safe), add `CONTINUITY.md` to RESTORE_FILES, expose
|
|
91
|
+
`executeRollbackWithSnapshot` that forces a pre-rollback snapshot before restoring.
|
|
92
|
+
metadata.json keys unchanged.
|
|
93
|
+
- **Bun `loki-ts/src/commands/rollback.ts`**: `executePlan` forces a pre-rollback snapshot
|
|
94
|
+
before restoring; HELP text made honest about state+context restore and the printed code
|
|
95
|
+
command.
|
|
96
|
+
- **dashboard `dashboard/server.py`**: add `POST /api/checkpoints/{id}/rollback`
|
|
97
|
+
(`require_scope("control")`, `_sanitize_checkpoint_id`, forced pre-snapshot, glob-restore).
|
|
98
|
+
Un-deads the existing UI button.
|
|
99
|
+
|
|
100
|
+
## 4. Tests (extend existing suites, no parallels)
|
|
101
|
+
- `loki-ts/tests/runner/checkpoint.test.ts`: CONTINUITY round-trip restore + forced
|
|
102
|
+
pre-rollback snapshot.
|
|
103
|
+
- `loki-ts/tests/commands/rollback.test.ts`: `to` forces a pre-rollback snapshot (re-undo).
|
|
104
|
+
- `tests/test-checkpoint-cli.sh`: top-level `loki rollback list|to|latest`, restore actually
|
|
105
|
+
reverts, pre-rollback snapshot exists.
|
|
106
|
+
- `dashboard/tests/test_rollback_endpoint.py`: POST rollback reverts, scope, 404.
|
|
107
|
+
No paid model calls anywhere.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# R9: Hosted/paid open-core hooks - design note
|
|
2
|
+
|
|
3
|
+
Status: SEAMS implemented (this worktree). NOT a live hosted backend.
|
|
4
|
+
|
|
5
|
+
R9 in the competitive-stickiness arc is the open-core monetization layer: keep
|
|
6
|
+
Loki fully open source and free, while adding the SEAMS where hosted, enterprise,
|
|
7
|
+
and paid plans would attach later. R9 ships the seams only. There is no Loki
|
|
8
|
+
hosted service, no license-verification backend, and no paid gate on any
|
|
9
|
+
existing feature. Every honest stub is labeled as such.
|
|
10
|
+
|
|
11
|
+
## What already existed (verified in source, pre-R9)
|
|
12
|
+
|
|
13
|
+
- proof-of-run `public_url` seam: `autonomy/lib/proof-generator.py:392` writes
|
|
14
|
+
`"deployment": {"deployed_url": deployed_url, "public_url": None}`. The
|
|
15
|
+
`public_url` field is reserved and always null today (no hosted publish wrote
|
|
16
|
+
it). R9 does NOT populate it (see "Deliberate gaps").
|
|
17
|
+
- `loki proof share --hosted` stub: BOTH routes errored "Hosted publishing is
|
|
18
|
+
not available yet (coming in R9)" -- bash `autonomy/loki` (share case in
|
|
19
|
+
`cmd_proof`) and Bun `loki-ts/src/commands/proof.ts` (`shareProof`). This was
|
|
20
|
+
the explicit seam to implement.
|
|
21
|
+
- `loki proof share` (gist): default opt-in publish via `gh gist create`
|
|
22
|
+
through `_loki_gist_upload` (bash) / `shareProof` (Bun). Redaction-preview +
|
|
23
|
+
confirm. This is the free path and stays byte-unchanged.
|
|
24
|
+
- `cmd_enterprise` (`autonomy/loki`): already env-driven feature flags
|
|
25
|
+
(`LOKI_ENTERPRISE_AUTH`, `LOKI_OIDC_ISSUER`/`LOKI_OIDC_CLIENT_ID`,
|
|
26
|
+
`LOKI_AUDIT_DISABLED`/`LOKI_ENTERPRISE_AUTH`). Good precedent: enterprise
|
|
27
|
+
features are env-gated seams, not paywalls. R9 follows the same pattern.
|
|
28
|
+
- No `LOKI_TIER`, `LOKI_LICENSE_KEY`, or `LOKI_HOSTED_ENDPOINT` existed anywhere
|
|
29
|
+
before R9. All three are new with this change.
|
|
30
|
+
- Redaction: the generator redacts the proof ONCE before writing index.html and
|
|
31
|
+
records `redaction.applied` in proof.json (`proof-generator.py`, module
|
|
32
|
+
`proof_redact`). The share path publishes the already-redacted artifact; it
|
|
33
|
+
does not run a second redaction pass.
|
|
34
|
+
|
|
35
|
+
## What R9 adds (seams, no backend)
|
|
36
|
+
|
|
37
|
+
1. Hosted proof-publish seam. `loki proof share --hosted <id>`:
|
|
38
|
+
- If `LOKI_HOSTED_ENDPOINT` is set: POST the ALREADY-REDACTED `index.html`
|
|
39
|
+
(the same bytes the gist path would publish) to that endpoint. On 2xx,
|
|
40
|
+
print the URL the endpoint returned (`url` or `public_url` JSON field), or,
|
|
41
|
+
if none, the endpoint itself + HTTP status. NEVER a fabricated URL.
|
|
42
|
+
- If `LOKI_HOSTED_ENDPOINT` is NOT set: print an honest "Hosted publishing
|
|
43
|
+
backend not available" message (there is no official Loki hosted service
|
|
44
|
+
yet), tell the user to set the env var or use the gist path, and exit
|
|
45
|
+
non-zero. We do NOT silent-fall-back to gist when the user explicitly asked
|
|
46
|
+
for `--hosted` (see "Fallback decision").
|
|
47
|
+
- If proof.json reports `redaction.applied == false`: refuse to publish.
|
|
48
|
+
- bash: `_loki_hosted_publish_proof` (curl). Bun:
|
|
49
|
+
`hostedPublishProof` (fetch). Parity-matched messages + exit codes.
|
|
50
|
+
|
|
51
|
+
2. Tier/license hook. `LOKI_TIER` (default `oss`) + optional `LOKI_LICENSE_KEY`:
|
|
52
|
+
- bash `loki_tier_gate <capability>`; Bun `tierGate(capability)` in
|
|
53
|
+
`loki-ts/src/util/tier.ts`.
|
|
54
|
+
- OSS (the default): always ALLOW, zero notes, no network, no license. This
|
|
55
|
+
is a pure no-op for every OSS user.
|
|
56
|
+
- Non-OSS without a license key: NOT allowed (honest -- we cannot verify an
|
|
57
|
+
entitlement; there is no backend). Never a fabricated grant.
|
|
58
|
+
- Non-OSS with a license key: allow, but flag that the verification backend
|
|
59
|
+
does not exist yet. We do NOT pretend the key was verified.
|
|
60
|
+
- WIRING: the gate is called ONLY from the opt-in `--hosted` seam. It is not
|
|
61
|
+
wired into any existing command path, so it cannot gate a free feature.
|
|
62
|
+
|
|
63
|
+
3. Open-core boundary doc: `docs/OPEN-CORE-BOUNDARY.md` -- what is free forever
|
|
64
|
+
vs. what hosted/paid would add.
|
|
65
|
+
|
|
66
|
+
## Fallback decision (reconciled)
|
|
67
|
+
|
|
68
|
+
The task phrased the fallback two ways. We follow the precise deliverable:
|
|
69
|
+
`share --hosted` with no endpoint prints "set LOKI_HOSTED_ENDPOINT or use gist"
|
|
70
|
+
and EXITS non-zero. We do NOT silently publish to gist when the user explicitly
|
|
71
|
+
asked for `--hosted`. Rationale: silent fallback would surprise a user who
|
|
72
|
+
intended a private/hosted destination by publishing to a public gist instead.
|
|
73
|
+
The plain `loki proof share <id>` (no flag) remains the gist path, unchanged.
|
|
74
|
+
|
|
75
|
+
## OSS-unchanged guarantee
|
|
76
|
+
|
|
77
|
+
- The default `loki proof share` (no `--hosted`) gist path is byte-identical:
|
|
78
|
+
`--hosted` is captured as a mode flag during arg-parse and branches only AFTER
|
|
79
|
+
id + html validation; the gist code below it is untouched.
|
|
80
|
+
- `LOKI_TIER` unset vs set produces identical output/exit for existing commands
|
|
81
|
+
(asserted in tests).
|
|
82
|
+
- No existing env var, command, or default changed behavior.
|
|
83
|
+
|
|
84
|
+
## Deliberate gaps (honest, not omissions)
|
|
85
|
+
|
|
86
|
+
- No live Loki hosted backend, no SaaS, no license server. `--hosted` only works
|
|
87
|
+
against an endpoint the operator supplies. This is by design for R9.
|
|
88
|
+
- `public_url` in proof.json is NOT written back after a hosted publish.
|
|
89
|
+
Mutating the frozen R1 proof artifact post-hoc is risk we deliberately skip;
|
|
90
|
+
the published URL is printed to the user instead. A future release can wire
|
|
91
|
+
write-back once the artifact-mutation story is designed.
|
|
92
|
+
- The tier gate does not verify license keys (no backend). It is a seam only.
|
|
93
|
+
- No retries/backoff on the hosted POST (clean client stub, not a transport
|
|
94
|
+
library).
|
|
95
|
+
|
|
96
|
+
## Tests
|
|
97
|
+
|
|
98
|
+
`loki-ts/tests/commands/proof_hosted_r9.test.ts` (mock endpoint via Bun.serve,
|
|
99
|
+
no network): tier gate OSS allow-all + honest non-OSS; hosted POST hits the
|
|
100
|
+
mocked endpoint with the redacted payload (both bash + Bun routes); honest
|
|
101
|
+
no-endpoint message + non-zero exit; non-2xx honest error; unredacted-proof
|
|
102
|
+
refusal; license-key auth header; OSS-not-gated guarantee (identical output with
|
|
103
|
+
LOKI_TIER unset vs enterprise). Existing `proof.test.ts` parity unchanged.
|
|
104
|
+
|
|
105
|
+
## Files changed
|
|
106
|
+
|
|
107
|
+
- `autonomy/loki` (bash): `loki_tier_gate`, `_loki_hosted_publish_proof`,
|
|
108
|
+
`--hosted` branch in `cmd_proof` share, help text.
|
|
109
|
+
- `loki-ts/src/util/tier.ts` (new): `tierGate`, `currentTier`.
|
|
110
|
+
- `loki-ts/src/commands/proof.ts` (Bun): `hostedPublishProof`, `--hosted`
|
|
111
|
+
branch, help text.
|
|
112
|
+
- `docs/OPEN-CORE-BOUNDARY.md` (new).
|
|
113
|
+
- `loki-ts/tests/commands/proof_hosted_r9.test.ts` (new).
|