@microck/raft 0.5.0 → 0.7.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.
Files changed (63) hide show
  1. package/README.md +27 -15
  2. package/deploy/build-image.py +4 -2
  3. package/deploy/raft-expire.py +109 -31
  4. package/deploy/verify-controls.py +6 -6
  5. package/deploy/verify-features.py +329 -19
  6. package/deploy/verify-jobs.py +264 -0
  7. package/deploy/verify-limits.py +22 -3
  8. package/deploy/verify-live.py +31 -6
  9. package/deploy/verify-output.py +208 -0
  10. package/deploy/verify-release.py +33 -4
  11. package/deploy/verify-run.py +571 -0
  12. package/deploy/verify-self-update.py +9 -1
  13. package/deploy/verify-sync.py +122 -0
  14. package/docs/benchmarks/read-latency-2026-10-29.json +70 -0
  15. package/docs/boat-capabilities.md +60 -7
  16. package/docs/boat-parity-design.md +1 -0
  17. package/docs/boot-performance.mdx +21 -0
  18. package/docs/contract.md +88 -13
  19. package/docs/first-box.mdx +22 -19
  20. package/docs/guides/background-jobs.mdx +38 -31
  21. package/docs/guides/backup-recovery.mdx +4 -3
  22. package/docs/guides/ci-jobs.mdx +209 -0
  23. package/docs/guides/config-policy.mdx +109 -35
  24. package/docs/guides/files.mdx +2 -2
  25. package/docs/guides/index.mdx +31 -0
  26. package/docs/guides/long-running-services.mdx +9 -3
  27. package/docs/guides/meta.json +7 -2
  28. package/docs/guides/project-setup.mdx +1 -1
  29. package/docs/guides/scripting-ci.mdx +36 -14
  30. package/docs/guides/snapshots-forks.mdx +4 -2
  31. package/docs/guides/troubleshooting.mdx +19 -2
  32. package/docs/guides/upgrades.mdx +11 -9
  33. package/docs/index.mdx +26 -28
  34. package/docs/maintainers/index.mdx +18 -0
  35. package/docs/maintainers/meta.json +12 -0
  36. package/docs/meta.json +5 -9
  37. package/docs/operations.mdx +17 -10
  38. package/docs/publishing.md +14 -4
  39. package/docs/reference/capacity.mdx +2 -1
  40. package/docs/reference/cli.md +135 -43
  41. package/docs/reference/configuration.mdx +10 -6
  42. package/docs/reference/meta.json +3 -1
  43. package/docs/releases/0.2.0.md +1 -0
  44. package/docs/releases/0.2.1.md +1 -0
  45. package/docs/releases/0.3.0.md +1 -0
  46. package/docs/releases/0.4.0.md +1 -0
  47. package/docs/releases/0.5.0.md +1 -0
  48. package/docs/releases/0.6.0.md +17 -0
  49. package/docs/releases/0.7.0.md +23 -0
  50. package/docs/releases/index.mdx +21 -0
  51. package/docs/releases/meta.json +15 -0
  52. package/docs/requirements.mdx +1 -1
  53. package/docs/reviews/docs-audit-2026-10-24.md +92 -0
  54. package/docs/reviews/performance-audit-2026-10-29.md +90 -0
  55. package/docs/site-development.md +1 -0
  56. package/docs/verification.mdx +22 -4
  57. package/package.json +3 -1
  58. package/raft.py +526 -193
  59. package/raft_files.py +13 -1
  60. package/raft_jobs.py +390 -0
  61. package/raft_output.py +166 -0
  62. package/raft_run.py +330 -0
  63. package/skills/raft-cli/SKILL.md +78 -182
package/README.md CHANGED
@@ -16,6 +16,12 @@
16
16
  control, with command execution, Docker, file transfer, snapshots, forks and a
17
17
  private desktop. Raft is not affiliated with Boat.
18
18
 
19
+
20
+
21
+ https://github.com/user-attachments/assets/ad6b0e67-1e33-4e36-a51b-401f29901eae
22
+
23
+
24
+
19
25
  [setup](docs/operations.mdx) | [feature parity](docs/boat-capabilities.md) | [verification](docs/verification.mdx) | [agent skill](skills/raft-cli/SKILL.md) | [docs site](https://raft.micr.dev/)
20
26
 
21
27
  ## quickstart
@@ -55,7 +61,7 @@ once your host and image are configured, run the commands below. replace `lab`
55
61
  with a location from your configuration:
56
62
 
57
63
  ```sh
58
- box=$(raft new --location lab --ttl 600)
64
+ box=$(raft new --location lab --ttl 600 --persistent --purpose "Explore Raft" --owner "local operator" --next "Destroy after exploring")
59
65
  raft exec "$box" -- node --version
60
66
  raft ssh "$box"
61
67
  raft stop "$box"
@@ -64,22 +70,26 @@ raft stop "$box"
64
70
  `raft stop` and expiration keep persistent boxes on disk. `new --disposable`
65
71
  deletes a temporary box when it stops or expires. `raft resume` starts
66
72
  the box with a new lifetime. `raft destroy` deletes the box and its snapshots.
67
- disposable creation and retention cleanup require the current host worker;
68
- redeploy from the current checkout before using them.
69
-
70
- size boxes for the job: `--cpu`, `--memory` and `--disk` take any whole number
71
- up to the host. on hosts deployed with `--disk-pool SIZE`, `--disk 20GiB` gives a
73
+ `raft policy lab --delete-stopped-after 48` makes the host delete boxes left
74
+ stopped for 48 hours, and logs each deletion in its journal. disposable
75
+ creation, prune and retention require the current host worker; redeploy from
76
+ the current checkout before using them.
77
+
78
+ size boxes for the job: `--cpu` takes a positive whole CPU count and
79
+ `--memory` takes whole GiB, each up to the host total. `--disk` takes whole
80
+ GiB bounded by the own-disk pool reservation, or `shared`. on hosts deployed with `--disk-pool SIZE`, `--disk 20GiB` gives a
72
81
  box its own disk that it, and Docker inside it, cannot grow past; forks and
73
82
  templates of it are copy-on-write. raft does not cap how many boxes you keep.
74
83
  `raft capacity` prints an advisory estimate of what fits; it never blocks a box.
75
84
  see [box sizing and capacity](docs/reference/capacity.mdx).
76
85
 
77
- `raft config set` stores your defaults for `new`, such as location, TTL and
78
- size. size defaults apply only to boxes from the image: `new --from` and `fork`
79
- keep the source's size. `raft policy lab --require purpose,owner` makes the host
80
- reject boxes whose note lacks those fields, with an error that names the missing
81
- flags. it is a guardrail for agents: anyone with sudo on the host can change it.
82
- see [defaults and note policies](docs/guides/config-policy.mdx).
86
+ `raft config set` stores your defaults for `new`, such as location, TTL, size
87
+ and `disposable true`; `new --persistent` keeps one box anyway. size defaults
88
+ apply only to boxes from the image: `new --from` and `fork` keep the source's
89
+ size. `raft policy lab --require purpose,owner` makes the host reject boxes whose
90
+ note lacks those fields, with an error that names the missing flags. policies
91
+ are guardrails for agents: anyone with sudo on the host can change them.
92
+ see [defaults and host policies](docs/guides/config-policy.mdx).
83
93
 
84
94
  ## image and workspace control
85
95
 
@@ -99,7 +109,8 @@ and [Docker](docs/guides/docker.mdx).
99
109
 
100
110
  See [project setup and templates](docs/guides/project-setup.mdx),
101
111
  [persistent services](docs/guides/long-running-services.mdx),
102
- [scripting and CI](docs/guides/scripting-ci.mdx), and
112
+ [scripting and CI](docs/guides/scripting-ci.mdx),
113
+ [disposable CI jobs](docs/guides/ci-jobs.mdx), and
103
114
  [upgrades](docs/guides/upgrades.mdx) for complete operator workflows.
104
115
 
105
116
  stop the source box before taking a snapshot, restoring one or making a fork.
@@ -147,6 +158,7 @@ records differences, sources and test coverage.
147
158
  | CPU/memory sizing and usage | ✅ | ✅ | Any whole CPU count and GiB size up to the host. Usage covers the shared pool or the box's own disk |
148
159
  | Host capacity estimates | ❌ | ✅ | Advisory only; Raft has no box count limit. Boat reports plan limits |
149
160
  | Root terminal and command execution | ✅ | ✅ | Host SSH transport |
161
+ | Disposable foreground CI jobs | ✅ | Preview | Unreleased 0.7.0 `raft run`: fixed timeout, Git-aware upload, artifact collection and guarded cleanup; absent from published 0.6.0 |
150
162
  | Background jobs, logs and cancellation | ✅ | ✅ | No process checkpoints |
151
163
  | File upload/download | ✅ | ✅ | Files and recursive trees; new directory destinations only |
152
164
  | Snapshots and same-host forks | ✅ | ✅ | Stopped source, checked under the lifecycle lock |
@@ -158,7 +170,7 @@ records differences, sources and test coverage.
158
170
  | Reverse forwarding | ✅ | ❌ | Not implemented |
159
171
  | Public workspace hosting and browser-only streaming | ✅ | ❌ | Not implemented |
160
172
  | Resize on resume/fork | ✅ | ✅ | Optional CPU/RAM overrides; disks keep their size |
161
- | CLI-wide JSON mode | ✅ | ❌ | Boat JSON/JSONL; Raft list/info/usage/capacity plus prune JSONL |
173
+ | CLI-wide JSON mode | ✅ | ❌ | Boat JSON/JSONL; Raft list/info/usage/capacity/config plus prune JSONL |
162
174
  | Snapshot file browsing/download | ✅ | ❌ | Whole-box backup only |
163
175
  | Strict per-box disk quotas | ✅ | ✅ | On hosts deployed with `--disk-pool`, `new --disk SIZE` gives a box its own fixed-size, copy-on-write disk; the default shared pool has none |
164
176
  | Separate-kernel VM isolation | ✅ | ❌ | Container runtime |
@@ -166,7 +178,7 @@ records differences, sources and test coverage.
166
178
  | Organizations and webhooks | ✅ | ❌ | Raft is single-operator; no event API |
167
179
  | Dashboard, remote API and SDK | ✅ | ❌ | CLI only |
168
180
  | Managed agent conversations | ✅ | ❌ | Not implemented |
169
- | Automatic deletion and retention | ✅ | ✅ | Disposable deletion; opt-in age-based prune with preview |
181
+ | Automatic deletion and retention | ✅ | ✅ | Disposable deletion, optionally by default; per-host retention deletes boxes stopped for N hours; age-based prune with preview |
170
182
  | CLI self-update | ✅ | ✅ | Explicit `raft self-update` for npm and uv installs plus a daily stderr notice. Boat also updates itself after commands; Raft only notifies |
171
183
 
172
184
  ## verification and development
@@ -41,15 +41,17 @@ def build(host, alias):
41
41
  host,
42
42
  prefix + " launch " + fingerprint + " raft-builder -c limits.cpu=2 -c limits.memory=4GiB",
43
43
  )
44
+ # Stage outside /tmp: its boot-time tmpfs mount can hide an early file push.
44
45
  for source, guest, mode in [
45
- ("images/incus/build.sh", "/tmp/raft-build.sh", "755"),
46
+ ("images/incus/build.sh", "/root/raft-build.sh", "755"),
46
47
  ("images/incus/desktop.sh", "/usr/local/bin/raft-desktop", "755"),
47
48
  ("images/incus/raft-desktop.service", "/etc/systemd/system/raft-desktop.service", "644"),
48
49
  ]:
49
50
  staged = "/tmp/" + Path(guest).name
50
51
  copy(host, ROOT / source, staged)
51
52
  remote(host, prefix + f" file push {staged} raft-builder{guest} --mode {mode}")
52
- remote(host, prefix + " exec raft-builder -- bash /tmp/raft-build.sh")
53
+ remote(host, prefix + " exec raft-builder -- bash /root/raft-build.sh")
54
+ remote(host, prefix + " exec raft-builder -- rm /root/raft-build.sh")
53
55
  remote(host, prefix + " stop raft-builder --timeout 30")
54
56
  print(
55
57
  remote(
@@ -1,61 +1,139 @@
1
1
  #!/usr/bin/python3
2
- """Enforce running deadlines and preview or delete explicitly selected old boxes."""
2
+ """Enforce running deadlines and stopped-box retention, and preview or delete
3
+ explicitly selected old boxes."""
3
4
 
4
5
  import argparse
5
6
  import json
7
+ import re
6
8
  import subprocess
9
+ import sys
7
10
  import time
8
11
 
9
- PROTOCOL = 1
12
+ # Checked by CHECK_WORKER in raft.py; bump both together. 2 added retention.
13
+ PROTOCOL = 2
10
14
 
11
15
  PREFIX = ["incus", "--project", "raft"]
16
+ # Stopped-box retention set by `raft policy LOCATION --delete-stopped-after
17
+ # HOURS` on the Incus project, and when it was set. Mirrors RETENTION_KEY in
18
+ # raft.py, whose delete_at() reports the same deadline this worker enforces.
19
+ RETENTION_KEY = "user.raft.delete-stopped-after"
12
20
 
13
21
 
14
22
  def run(*argv):
15
23
  return subprocess.run([*PREFIX, *argv], check=True, capture_output=True, text=True).stdout
16
24
 
17
25
 
18
- def main():
19
- cli = argparse.ArgumentParser(description=__doc__)
20
- cli.add_argument("--prune-days", type=int)
21
- cli.add_argument("--yes", action="store_true")
22
- args = cli.parse_args()
23
- if args.prune_days is not None and args.prune_days < 1:
24
- cli.error("Retention must be at least one day")
25
- if args.yes and args.prune_days is None:
26
- cli.error("--yes requires --prune-days")
27
- now = int(time.time())
28
- # The service, gc and prune all hold the same lifecycle lock. Never select
29
- # unrelated Incus instances or use creation time as a guessed stop time.
30
- for box in json.loads(run("list", "--fast", "--format", "json")):
31
- config = box["config"]
32
- if config.get("user.raft") != "true":
33
- continue
34
- name = box["name"]
35
- if args.prune_days is not None:
36
- stopped_at = config.get("user.raft.stopped-at")
37
- if box["status"] != "Stopped" or not stopped_at:
38
- continue
39
- if int(stopped_at) > now - args.prune_days * 86400:
40
- continue
41
- if args.yes:
42
- run("delete", name)
43
- print(json.dumps({"box": name, "action": "deleted" if args.yes else "would-delete"}))
26
+ def retention():
27
+ """Return the project's (hours, since) retention policy, or None when it is unset."""
28
+ projects = json.loads(run("project", "list", "--format", "json"))
29
+ config = next(item for item in projects if item["name"] == "raft")["config"]
30
+ hours = config.get(RETENTION_KEY, "")
31
+ since = config.get(RETENTION_KEY + ".since", "")
32
+ if not hours:
33
+ return None
34
+ # Raft writes whole numbers; anything else came from a native Incus edit.
35
+ if not re.fullmatch(r"[1-9][0-9]*", hours) or not re.fullmatch(r"[0-9]+", since):
36
+ raise ValueError(
37
+ f"Invalid retention {RETENTION_KEY}={hours!r} .since={since!r}; deleted no stopped "
38
+ "boxes. Fix it with raft policy LOCATION --delete-stopped-after HOURS or never"
39
+ )
40
+ return int(hours), int(since)
41
+
42
+
43
+ def prune(boxes, days, apply, now):
44
+ """Preview or delete stopped boxes whose recorded stop is older than days."""
45
+ for box in boxes:
46
+ stopped_at = box["config"].get("user.raft.stopped-at")
47
+ if box["status"] != "Stopped" or not stopped_at:
44
48
  continue
45
- deadline = config.get("user.raft.expires")
46
- if not deadline or int(deadline) > now:
49
+ if int(stopped_at) > now - days * 86400:
47
50
  continue
51
+ if apply:
52
+ run("delete", box["name"])
53
+ print(json.dumps({"box": box["name"], "action": "deleted" if apply else "would-delete"}))
54
+
55
+
56
+ def enforce(box, policy, now):
57
+ """Apply the running deadline, then the stopped-box retention, to one box."""
58
+ config = box["config"]
59
+ name = box["name"]
60
+ deadline = config.get("user.raft.expires")
61
+ if deadline and int(deadline) <= now:
48
62
  if box["status"] not in ["Running", "Stopped"]:
49
63
  raise RuntimeError(f"Unexpected state for expired box {name}")
50
64
  if config.get("user.raft.disposable") == "true":
51
65
  run("delete", name, "--force")
52
66
  print(f"Deleted expired disposable box {name}", flush=True)
53
- continue
67
+ return
54
68
  if box["status"] == "Running":
55
69
  run("stop", name, "--timeout", "30")
56
70
  run("config", "set", name, f"user.raft.stopped-at={now}")
57
71
  run("config", "unset", name, "user.raft.expires")
58
72
  print(f"Stopped expired box {name}; disk retained", flush=True)
73
+ return
74
+ # Retention never touches a running box. A native incus start keeps the old
75
+ # stop time, so clear it here and the next stop starts a fresh clock. A start
76
+ # and stop between two worker runs still goes unnoticed.
77
+ if box["status"] != "Stopped":
78
+ if box["status"] == "Running" and config.get("user.raft.stopped-at"):
79
+ run("config", "unset", name, "user.raft.stopped-at")
80
+ return
81
+ stopped_at = config.get("user.raft.stopped-at")
82
+ if not stopped_at:
83
+ # Raft records the stop time on stop and expiry. A box stopped any other
84
+ # way (a guest poweroff, a native incus stop, a recovered backup or a
85
+ # failed start) starts its retention clock now. Its real stop time is
86
+ # unknown, and guessing an earlier one could delete it early.
87
+ run("config", "set", name, f"user.raft.stopped-at={now}")
88
+ print(f"Recorded stop time of box {name}, stopped outside Raft", flush=True)
89
+ return
90
+ if policy is None:
91
+ return
92
+ hours, since = policy
93
+ # The clock starts at the later of the stop and the policy change, so a new or
94
+ # shorter policy never deletes a box sooner than its hours after the change.
95
+ if max(int(stopped_at), since) + hours * 3600 > now:
96
+ return
97
+ run("delete", name)
98
+ stopped = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(int(stopped_at)))
99
+ message = f"Deleted box {name}, stopped since {stopped}, under the {hours}-hour retention"
100
+ # Retention deletes data without a command, so every deletion goes to the host
101
+ # journal beside the policy change that caused it (journalctl -t raft), whether
102
+ # the timer or raft gc ran the worker.
103
+ subprocess.run(["logger", "-t", "raft", message], check=True)
104
+ print(message, flush=True)
105
+
106
+
107
+ def main():
108
+ cli = argparse.ArgumentParser(description=__doc__)
109
+ cli.add_argument("--prune-days", type=int)
110
+ cli.add_argument("--yes", action="store_true")
111
+ args = cli.parse_args()
112
+ if args.prune_days is not None and args.prune_days < 1:
113
+ cli.error("Retention must be at least one day")
114
+ if args.yes and args.prune_days is None:
115
+ cli.error("--yes requires --prune-days")
116
+ now = int(time.time())
117
+ # The service, gc and prune all hold the same lifecycle lock. Never select
118
+ # unrelated Incus instances or use creation time as a guessed stop time.
119
+ boxes = [
120
+ box
121
+ for box in json.loads(run("list", "--fast", "--format", "json"))
122
+ if box["config"].get("user.raft") == "true"
123
+ ]
124
+ if args.prune_days is not None:
125
+ prune(boxes, args.prune_days, args.yes, now)
126
+ return
127
+ # An invalid policy must not stop deadline enforcement: apply deadlines, skip
128
+ # retention, and fail afterwards so the service run reports the problem.
129
+ try:
130
+ policy, problem = retention(), None
131
+ except ValueError as error:
132
+ policy, problem = None, str(error)
133
+ for box in boxes:
134
+ enforce(box, policy, now)
135
+ if problem:
136
+ sys.exit(problem)
59
137
 
60
138
 
61
139
  if __name__ == "__main__":
@@ -88,19 +88,19 @@ def verify(source, target, directory, development_image=False):
88
88
  raise RuntimeError("Detached job did not start")
89
89
  time.sleep(0.2)
90
90
  child = live.execute(box, "cat", "/workspace/child-pid")
91
- assert "ActiveState=active" in live.raft("status", box, job).stdout
91
+ assert json.loads(live.raft("status", box, job).stdout)["state"] == "running"
92
92
  live.raft("extend", box, "--ttl", "1200")
93
93
  extended = json.loads(live.raft("info", box).stdout)
94
94
  assert (
95
95
  int(extended["config"]["user.raft.expires"])
96
96
  > int(live.execute(box, "date", "+%s")) + 1100
97
97
  )
98
- assert "ActiveState=active" in live.raft("status", box, job).stdout
99
- # Exercise journal retention without changing any host service or other box.
98
+ assert json.loads(live.raft("status", box, job).stdout)["state"] == "running"
99
+ # Guest journal rotation cannot erase host-retained job state.
100
100
  live.execute(box, "journalctl", "--rotate", "--vacuum-size=1")
101
- assert "ActiveState=active" in live.raft("status", box, job).stdout
101
+ assert json.loads(live.raft("status", box, job).stdout)["state"] == "running"
102
102
  live.raft("cancel", box, job)
103
- assert "ActiveState=inactive" in live.raft("status", box, job).stdout
103
+ assert live.wait_job(box, job)["state"] == "cancelled"
104
104
  assert live.raft("logs", box, job).returncode == 0
105
105
  assert (
106
106
  live.raft("exec", box, "--", "test", "-e", "/proc/" + child, check=False).returncode
@@ -155,7 +155,7 @@ def verify(source, target, directory, development_image=False):
155
155
  ).stdout.strip()
156
156
  fixtures.append(restored)
157
157
  finally:
158
- live.raft("policy", target, "--clear")
158
+ live.raft("policy", target, "--require", "none")
159
159
  print(f"{target}: recovered stopped workspace", flush=True)
160
160
  note = json.loads(live.raft("info", restored).stdout)["config"]["user.raft.note"]
161
161
  assert json.loads(note) == {