@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/raft_run.py ADDED
@@ -0,0 +1,330 @@
1
+ """One-shot CI orchestration; guests are disposable containers, not registered runners."""
2
+
3
+ import argparse
4
+ import os
5
+ from pathlib import Path, PurePosixPath
6
+ import signal
7
+ import re
8
+ import shlex
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ from types import SimpleNamespace
13
+ import uuid
14
+
15
+ import raft_output
16
+
17
+ GRACE = 30
18
+ LEASE_MARGIN = 120
19
+
20
+ # Host-only state. /run is cleared on reboot, when queued SSH processes also die.
21
+ # Never put root-created markers in world-writable /run/lock or follow symlinks.
22
+ CANCELLATIONS = """import os, re, stat, sys
23
+ CANCEL_ROOT = "/run/raft-ci-cancelled"
24
+ def cancel_directory(create=False):
25
+ if create:
26
+ try:
27
+ os.mkdir(CANCEL_ROOT, 0o700)
28
+ except FileExistsError:
29
+ pass
30
+ try:
31
+ directory = os.open(CANCEL_ROOT, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
32
+ except FileNotFoundError:
33
+ if create:
34
+ raise
35
+ return None
36
+ metadata = os.fstat(directory)
37
+ if metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) != 0o700:
38
+ os.close(directory)
39
+ raise RuntimeError('Unsafe CI cancellation directory; refusing to use it')
40
+ return directory
41
+
42
+ def cancellation(token, record=False):
43
+ if not re.fullmatch('[a-f0-9]{32}', token):
44
+ raise ValueError('Invalid CI run identity')
45
+ directory = cancel_directory(record)
46
+ if directory is None:
47
+ return False
48
+ try:
49
+ if record:
50
+ try:
51
+ marker = os.open(token, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
52
+ 0o600, dir_fd=directory)
53
+ except FileExistsError:
54
+ pass
55
+ else:
56
+ os.close(marker)
57
+ try:
58
+ os.stat(token, dir_fd=directory, follow_symlinks=False)
59
+ return True
60
+ except FileNotFoundError:
61
+ return False
62
+ finally:
63
+ os.close(directory)
64
+ """
65
+
66
+ CHECK_CANCELLED = (
67
+ CANCELLATIONS
68
+ + """
69
+ if cancellation(sys.argv[1]):
70
+ sys.exit('CI run was cancelled before creation')
71
+ """
72
+ )
73
+
74
+
75
+ def creation_guard(token):
76
+ if not re.fullmatch("[a-f0-9]{32}", token):
77
+ raise ValueError("Invalid CI run identity")
78
+ return shlex.join(["python3", "-c", CHECK_CANCELLED, token]) + "\n"
79
+
80
+
81
+ # The caller holds the existing host lifecycle lock around this entire program.
82
+ CLEANUP = (
83
+ CANCELLATIONS
84
+ + """import json, subprocess, sys
85
+ name, token, creation = sys.argv[1:]
86
+ if creation not in ("pending", "acknowledged"):
87
+ sys.exit("Invalid CI creation state")
88
+ if creation == "pending":
89
+ cancellation(token, record=True)
90
+ def boxes():
91
+ result = subprocess.run(['incus', '--project', 'raft', 'list', name, '--format', 'json'],
92
+ check=True, stdout=subprocess.PIPE)
93
+ return [box for box in json.loads(result.stdout) if box['name'] == name]
94
+ found = boxes()
95
+ if not found:
96
+ sys.exit(0)
97
+ config = found[0].get('config', {})
98
+ if len(found) != 1 or config.get('user.raft') != 'true' or config.get('user.raft.run') != token:
99
+ sys.exit('Refusing cleanup: box ownership does not match this run')
100
+ subprocess.run(['incus', '--project', 'raft', 'delete', name, '--force'], check=True)
101
+ if boxes():
102
+ sys.exit('Run cleanup deletion was not confirmed')
103
+ """
104
+ )
105
+
106
+
107
+ class Stopped(Exception):
108
+ def __init__(self, status, message):
109
+ super().__init__(message)
110
+ self.status = status
111
+
112
+
113
+ def timeout_seconds(value):
114
+ seconds = int(value)
115
+ if not 1 <= seconds <= 2592000 - LEASE_MARGIN:
116
+ raise argparse.ArgumentTypeError("Timeout must be 1 second to 30 days minus 120 seconds")
117
+ return seconds
118
+
119
+
120
+ def add_options(command):
121
+ command.add_argument(
122
+ "--timeout",
123
+ type=timeout_seconds,
124
+ default=600,
125
+ help="Total setup/upload/command deadline in seconds (default: 600)",
126
+ )
127
+ upload = command.add_mutually_exclusive_group()
128
+ upload.add_argument(
129
+ "--push",
130
+ metavar="DIRECTORY",
131
+ help="Upload Git-tracked and unignored files; exclude .git; reject submodules",
132
+ )
133
+ upload.add_argument(
134
+ "--push-all",
135
+ metavar="DIRECTORY",
136
+ help="Explicitly upload the entire tree, including hidden and ignored files",
137
+ )
138
+ command.add_argument(
139
+ "--cwd", help="Guest working directory (upload: /workspace/project; otherwise /workspace)"
140
+ )
141
+ command.add_argument(
142
+ "--pull",
143
+ nargs=2,
144
+ action="append",
145
+ default=[],
146
+ metavar=("GUEST_DIRECTORY", "NEW_LOCAL_DIRECTORY"),
147
+ help="Collect a directory before cleanup, including after a failed command",
148
+ )
149
+ command.add_argument(
150
+ "command", nargs=argparse.REMAINDER, help="Command argv after --; not a shell string"
151
+ )
152
+
153
+
154
+ def guest_path(value):
155
+ path = PurePosixPath(value)
156
+ if not path.is_absolute() or ".." in path.parts or "\0" in value:
157
+ raise ValueError("Guest paths must be absolute and contain no '..' or NUL")
158
+ return str(path)
159
+
160
+
161
+ def git_files(source, *, all_option="--push-all"):
162
+ """Fail closed on Git errors; do not accidentally upload an ignored credential."""
163
+
164
+ def git(*arguments):
165
+ result = subprocess.run(
166
+ ["git", "-C", str(source), *arguments], stdout=subprocess.PIPE, stderr=subprocess.PIPE
167
+ )
168
+ if result.returncode:
169
+ raise ValueError(
170
+ f"Git-filtered source requires a readable Git working tree; use {all_option} only "
171
+ "after checking the entire tree for secrets: "
172
+ + result.stderr.decode(errors="replace").strip()
173
+ )
174
+ return result.stdout
175
+
176
+ if git("rev-parse", "--is-inside-work-tree").strip() != b"true":
177
+ raise ValueError("Git-filtered source requires a Git working tree")
178
+ for record in git("ls-files", "--stage", "-z", "--", ".").split(b"\0"):
179
+ if record.startswith(b"160000 "):
180
+ raise ValueError(
181
+ "Git-filtered source does not copy submodules; stage their contents explicitly"
182
+ )
183
+ paths = git("ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ".")
184
+ return {
185
+ os.fsdecode(path)
186
+ for path in paths.split(b"\0")
187
+ if path and ".git" not in PurePosixPath(os.fsdecode(path)).parts
188
+ }
189
+
190
+
191
+ def run(args, api):
192
+ command = args.command[1:] if args.command[:1] == ["--"] else args.command
193
+ if not command or any("\0" in value for value in command):
194
+ raise ValueError("run requires a command after --, without NUL characters")
195
+ source = (
196
+ Path(args.push or args.push_all).resolve(strict=True)
197
+ if args.push or args.push_all
198
+ else None
199
+ )
200
+ if source is not None and not source.is_dir():
201
+ raise ValueError("Upload source must be a directory")
202
+ cwd = guest_path(args.cwd or ("/workspace/project" if source else "/workspace"))
203
+ pulls = []
204
+ destinations = set()
205
+ for remote, local in args.pull:
206
+ destination = Path(local).absolute()
207
+ destination = destination.parent.resolve(strict=True) / destination.name
208
+ if os.path.lexists(destination) or destination in destinations:
209
+ raise ValueError("Each pull destination must be a new path: " + str(destination))
210
+ destinations.add(destination)
211
+ pulls.append((guest_path(remote), str(destination)))
212
+ args.disposable = True
213
+ args.ttl = args.timeout + LEASE_MARGIN
214
+ token = uuid.uuid4().hex
215
+ allocated = None
216
+ started = False
217
+ creation_acknowledged = False
218
+ status = 1
219
+ phase = "job"
220
+ began = time.monotonic()
221
+ signals = (signal.SIGINT, signal.SIGTERM, signal.SIGALRM)
222
+ previous = {number: signal.getsignal(number) for number in signals}
223
+ old_timer = signal.getitimer(signal.ITIMER_REAL)
224
+ if old_timer[0]:
225
+ raise RuntimeError("run cannot replace an existing process alarm")
226
+
227
+ def stop(number, _frame):
228
+ if number == signal.SIGALRM:
229
+ raise Stopped(124, f"{phase} deadline exceeded")
230
+ raise Stopped(128 + number, f"interrupted by signal {number}")
231
+
232
+ def record(location, name):
233
+ nonlocal allocated
234
+ allocated = (location, name)
235
+ print(f"raft run: allocated {location}:{name}", file=sys.stderr, flush=True)
236
+
237
+ def issue(error):
238
+ nonlocal status
239
+ raft_output.error("operation", error, prefix="raft run")
240
+ if status == 0:
241
+ status = 1
242
+
243
+ try:
244
+ for number in signals:
245
+ signal.signal(number, stop)
246
+ signal.setitimer(signal.ITIMER_REAL, args.timeout)
247
+ try:
248
+ include = git_files(source) if args.push else None
249
+ api.new(args, run_id=token, on_allocate=record)
250
+ creation_acknowledged = True
251
+ location, name = allocated
252
+ while api.execute(location, name, ["true"], capture=True).returncode:
253
+ time.sleep(0.2)
254
+ if source:
255
+ api.transfer_tree(
256
+ SimpleNamespace(
257
+ action="upload", source=source, destination=cwd, include=include
258
+ ),
259
+ location,
260
+ name,
261
+ )
262
+ started = True
263
+ result = api.execute(location, name, command, cwd=cwd)
264
+ status = result.returncode if result.returncode >= 0 else 128 - result.returncode
265
+ except Stopped as error:
266
+ status = error.status
267
+ issue(error)
268
+ except KeyboardInterrupt:
269
+ status = 130
270
+ issue("interrupted")
271
+ except Exception as error:
272
+ issue(error)
273
+ finally:
274
+ signal.setitimer(signal.ITIMER_REAL, 0)
275
+ # A repeated cancellation cannot interrupt the bounded cleanup grace.
276
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
277
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
278
+ if allocated is not None:
279
+ location, name = allocated
280
+ try:
281
+ if started and pulls:
282
+ phase = "artifact collection"
283
+ signal.setitimer(signal.ITIMER_REAL, GRACE)
284
+ try:
285
+ for remote, local in pulls:
286
+ try:
287
+ api.transfer_tree(
288
+ SimpleNamespace(
289
+ action="download", source=remote, destination=local
290
+ ),
291
+ location,
292
+ name,
293
+ )
294
+ except Stopped:
295
+ raise
296
+ except Exception as error:
297
+ issue(f"could not collect {remote}: {error}")
298
+ except Stopped as error:
299
+ issue(error)
300
+ finally:
301
+ signal.setitimer(signal.ITIMER_REAL, 0)
302
+ finally:
303
+ phase = "cleanup"
304
+ signal.setitimer(signal.ITIMER_REAL, GRACE)
305
+ try:
306
+ api.transaction(
307
+ location,
308
+ 'exec python3 -c "$4" "$1" "$2" "$3"',
309
+ name,
310
+ token,
311
+ "acknowledged" if creation_acknowledged else "pending",
312
+ CLEANUP,
313
+ )
314
+ print(f"raft run: cleanup confirmed for {location}:{name}", file=sys.stderr)
315
+ except Exception as error:
316
+ issue(
317
+ f"cleanup not confirmed for {location}:{name}: {error}; inspect this handle. "
318
+ "The host expiry worker is the backstop, not proof of deletion."
319
+ )
320
+ finally:
321
+ signal.setitimer(signal.ITIMER_REAL, 0)
322
+ finally:
323
+ signal.setitimer(signal.ITIMER_REAL, 0)
324
+ for number, handler in previous.items():
325
+ signal.signal(number, handler)
326
+ signal.setitimer(signal.ITIMER_REAL, *old_timer)
327
+ print(
328
+ f"raft run: finished in {time.monotonic() - began:.1f}s (status {status})", file=sys.stderr
329
+ )
330
+ return status
@@ -1,188 +1,84 @@
1
1
  ---
2
2
  name: raft-cli
3
- description: Create and use private persistent Linux workspaces on configured Linux hosts with Raft and Incus. Use for self-hosted builds, tests, terminals, Docker, file transfer, snapshots, and private desktop access. Hosted Boat uses its separate boat-cli skill.
3
+ description: Create and operate private Linux workspaces with Raft. Discover the installed CLI, host policies and guest tools before choosing commands for builds, tests, files, jobs or desktop access. Hosted Boat uses its separate skill.
4
4
  ---
5
5
 
6
6
  # Raft
7
7
 
8
- Raft runs unprivileged native ARM64 or AMD64 Incus system containers. They share the host kernel.
9
- The current deployment does not implement Incus VMs. Do not describe these boxes as VMs or promise cross-architecture emulation.
10
-
11
- `raft host add` installs packages and services on a machine. Run it only when the
12
- user explicitly asks to onboard that host, and show its `--dry-run` plan first.
13
-
14
- Do not run `raft self-update` unless the user asks; an update in the middle of a
15
- task changes the CLI under it. Report a `raft X is available` stderr notice
16
- instead of acting on it. For pinned or scripted runs, set
17
- `RAFT_NO_UPDATE_CHECK=1` to suppress the notice.
18
-
19
- Run `raft locations`, `raft config`, `raft doctor` and `raft list` before allocating.
20
- `raft config` shows the defaults `new` will use on this controller and each
21
- location's note policy: the box note fields its host requires.
22
- Follow each location's `note`: the operator uses it to mark preferred hosts and
23
- hosts to avoid for some work. Pass `--location` explicitly when a note rules out
24
- the default. Do not change notes unless the user asks; `raft note LOCATION TEXT`
25
- sets one and `raft note LOCATION --clear` removes it. Keep the exact
26
- location-qualified handle returned by `new`. There is no implicit current box.
27
-
28
- Note every box you create. Box notes follow one template so the next agent can
29
- tell whether a box is in use:
30
-
31
- - `--purpose`: what the box is for (required), e.g. `Integration tests for PR 42`
32
- - `--owner`: who uses it, e.g. `codex session in ~/work/app`
33
- - `--next`: what happens next, e.g. `Destroy after the tests pass`
34
-
35
- Pass them to `new`, and run `raft note "$box" --purpose ...` when the plan
36
- changes; each call replaces the whole note. If a command fails with
37
- `requires --owner (host policy)` or similar, rerun it with the fields it names;
38
- the error prints the corrected command. Never change or clear a policy with
39
- `raft policy`, or the defaults with `raft config set/unset`, to get past an
40
- error; only do so when the user asks. `fork` and `recover` accept the same note
41
- flags, which replace the whole note on the copy. `raft list` shows every box's
42
- `note`. Read it before you resume, stop, fork or destroy a box. Treat a box whose
43
- note names another owner or a job unrelated to your task as not yours. Forks
44
- keep the source note, so update a fork's note when its job differs.
45
-
46
- ```sh
47
- box=$(raft new --location lab --ttl 600 --purpose "Run the project tests" --owner "codex task 123" --next "Destroy when the tests finish")
48
- raft exec "$box" -- timeout 240 bash -lc 'git clone <url> /workspace/project && cd /workspace/project && <test-command>'
49
- raft download "$box" /workspace/project/report.json ./report.json
50
- raft destroy "$box"
51
- ```
52
-
53
- The default development image has systemd, a guest Docker daemon, Node/npm/pnpm,
54
- Bun, Deno, Python/pip/venv/uv, Go, Rust, Java, Kotlin, Scala, Ruby, PHP,
55
- Erlang/Elixir, .NET, R, C/C++ tools, Git, Chromium and FFmpeg. Coding agents are not included in new images; install
56
- your preferred agent when needed and authenticate separately. Never copy host
57
- credentials into a box without authorization. Inspect `/opt/raft/` inside the
58
- box for recorded package versions. Do not assume every language has every
59
- version manager or that agents have an authenticated account.
60
-
61
- Exec passes literal arguments; use `bash -lc` for shell syntax. A failed command
62
- retains its exit status. `raft ssh "$box"` opens a root terminal through native
63
- Incus execution over verified host SSH. It does not expose a guest SSH port.
64
-
65
- ## Sizing boxes
66
-
67
- Create the boxes your task needs, sized for the work. Raft has no box count
68
- limit and no size budget. Decide what the task needs and why, then ask for it:
69
-
70
- - `--cpu N`: any whole number of CPUs up to the host's count (default 1). Use
71
- more for parallel builds and test suites.
72
- - `--memory SIZE`: any whole GiB up to the host's RAM, e.g. `8GiB` or `32GiB`
73
- (default `2GiB`). Use more for large compiles, browsers or databases.
74
- - `--disk SIZE`: its own disk, e.g. `30GiB`, instead of the shared 60 GiB pool.
75
- The box and Docker inside it cannot write past SIZE, and other boxes cannot
76
- take its space. Use it for Docker builds, large datasets or anything that
77
- could fill the shared pool. The image needs about 5 GiB, so use at least
78
- `8GiB`; 10 GiB or more starts fastest. Only hosts deployed with
79
- `--disk-pool` have own disks; elsewhere `--disk` fails and names the command
80
- that enables them. Own disks, their snapshots, forks and templates are
81
- copy-on-write, so copying one is fast, but each reserves its full size in
82
- the pool.
83
-
84
- ```sh
85
- box=$(raft new --cpu 4 --memory 16GiB --disk 20GiB --ttl 3600 --purpose "Build and test the monorepo" --owner "codex task 123" --next "Destroy when CI passes")
86
- ```
87
-
88
- Built-in defaults are the first configured location, one CPU, 2 GiB RAM, the
89
- shared pool and 600 seconds; the operator can change them per controller, so
90
- pass `--location`, `--ttl`, `--cpu`, `--memory` and `--disk` (`--disk shared`
91
- for the shared pool) when the values matter. `new --from` and `fork` keep the
92
- source's CPU, RAM and disk instead of any default; each flag overrides only its
93
- own value. TTL accepts 60 seconds to 30
94
- days. A request larger than the whole host fails with the host's real size;
95
- retry with what it reports. If the own-disk pool has too little unreserved
96
- space for `--disk`, a snapshot or a copy, destroy own-disk boxes or snapshots
97
- the task no longer needs, or pick a smaller disk or the shared pool.
98
- `raft list` shows each box's `disk` (`"shared"` or its size).
99
-
100
- `raft capacity --location lab --json` is an advisory estimate of host CPU/RAM
101
- headroom and free disk. It is advice, never a reason to skip creating a box or
102
- to shrink one below what the task needs. Boxes share host CPUs; an estimate of
103
- zero means new boxes may run slower, not that they are refused.
104
-
105
- ## Persistence and cleanup
106
-
107
- Persistent boxes stop and retain disk at expiry. `new --disposable` deletes a
108
- box and its snapshots on stop or expiry. `raft stop "$box"` releases the
109
- container's running processes; `raft resume "$box" --ttl 600` starts it again.
110
- Stop is not pause and does not keep guest RAM allocated. Installed dependencies,
111
- files and secrets remain on disk. For a temporary run, completion requires
112
- successful `destroy`, even after command failure. Stop reusable runners when
113
- finished. Destroy only boxes owned by the current task unless authorized otherwise.
114
-
115
- Host systemd timers enforce deadlines every 15 seconds, even when the controller
116
- is offline. `raft gc` runs those expiry workers manually and deletes expired disposable
117
- boxes. `raft prune --older-than 7` previews stopped boxes older than seven days;
118
- `--yes` deletes them. It skips running boxes and boxes without a recorded stop
119
- time. Never apply prune outside the task-owned scope without explicit permission. A powered-off host enforces expiry when it returns.
120
-
121
- Stop a box before a consistent disk snapshot or fork:
122
-
123
- ```sh
124
- raft stop "$box"
125
- raft snapshot "$box" prepared
126
- raft snapshots "$box"
127
- copy=$(raft fork "$box" --ttl 600)
128
- raft restore "$box" prepared
129
- ```
130
-
131
- Restore replaces disk contents with the snapshot and retains the destination's
132
- current instance configuration, including its MAC and resource limits. Use it
133
- only when the user requested that rollback. A fork copies filesystem data, including any
134
- credentials installed there. Forks stay on the same host. `new --from "$box/prepared"` starts from a named
135
- snapshot; `snapshot-delete "$box" prepared` removes it. Resume and fork accept
136
- optional `--cpu` and `--memory` overrides. Omitted values retain the source limits.
137
- Forks and `new --from` keep the source's CPU, RAM and disk unless a flag
138
- overrides them; `new --from "$box/prepared" --disk 60GiB` copies the snapshot onto
139
- a bigger own disk when a box runs out of space. To spawn many agents from one
140
- prepared own-disk snapshot, run `new --from` once per agent: each copy is a thin
141
- snapshot created in seconds.
142
- Forks default to persistent; use `--disposable` explicitly for temporary clones.
143
-
144
- `upload/download --recursive` copy directory trees into new destinations without
145
- merging or overwriting. They require a running guest with Python 3.11.8+ and
146
- temporary controller disk space. Escaping links and special files are rejected.
147
-
148
- ## Long commands and previews
149
-
150
- `raft exec "$box" --detach -- <command>` returns a job ID. Use `raft status
151
- "$box" <job>` and `raft logs "$box" <job>` to inspect its systemd state, exit
152
- status and output. `raft cancel "$box" <job>` stops the job and its child processes. Stop/destroy
153
- terminates running jobs; this is not automatic checkpointing of processes.
154
- `raft extend "$box" --ttl 1800` resets a running box's deadline without
155
- restarting its work. Use resume for stopped boxes. `raft usage "$box"` reports
156
- cgroup memory/CPU and disk space; `filesystemScope` says whether that is the
157
- shared pool or the box's own disk.
158
-
159
- `raft forward "$box" --remote 8080 --local 8080` opens a foreground private
160
- tunnel. `raft desktop "$box" --local 6080` starts the desktop on demand and
161
- opens its private tunnel. Use the printed localhost address, with `vnc.html`
162
- for the viewer. Ctrl+C closes the tunnel. The desktop service stays inside the
163
- box until the box stops or its service is explicitly stopped.
164
-
165
- Public hosting, reverse tunnels, browser-only confinement, agent conversation
166
- orchestration and KVM-backed VMs are not implemented. Agent binaries can be run
167
- through exec after authorized sign-in. Do not claim `boat prompt` parity.
168
-
169
- ## Portable backups
170
-
171
- Stop before exporting a private archive, including snapshots:
172
-
173
- ```sh
174
- raft stop "$box"
175
- raft backup "$box" ./workspace.tar.gz
176
- recovered=$(raft recover ./workspace.tar.gz --location second-host)
177
- raft resume "$recovered" --ttl 600
178
- ```
179
-
180
- Recovery creates a fresh stopped handle and clears the inherited deadline. It
181
- never overwrites another box. A box with its own disk comes back on an own disk
182
- of the same size, so the destination needs own box disks enabled and room for the
183
- box and each of its snapshots. Only import trusted Raft exports; archives retain
184
- native configuration and guest secrets. Store them privately and check free
185
- space on the controller and destination host. Large backups hold the host
186
- lifecycle lock and delay expiry checks. A controller crash can leave an owned
187
- staging archive or imported box; inspect the reported recovery handle before
188
- cleanup. This is manual recovery, not a scheduled backup service.
8
+ ## Discover before choosing a workflow
9
+
10
+ 1. Run `raft --version` and `raft --help`. The installed command tree is the
11
+ capability source. Do not infer support from Boat, a newer release, or this
12
+ skill. Never update the CLI merely to make an example work.
13
+ 2. Match the task to a command in that help. Read `raft COMMAND --help` and
14
+ nested help until you reach the operation. Read its arguments, defaults,
15
+ output and constraints before invoking it. Revisit help when the version
16
+ changes or the task needs a different operation.
17
+ 3. If help cannot answer a material question, inspect the matching installed
18
+ source or that version's Raft command reference and runtime contract. In a
19
+ checkout these are `docs/reference/cli.md` and `docs/contract.md`; published
20
+ sources are at https://github.com/Microck/raft. Current website documentation
21
+ can describe unreleased code. Report unsupported capabilities instead of
22
+ inventing flags or silently using native Incus to bypass Raft.
23
+
24
+ This skill defines the operating procedure. Command help and runtime inspection
25
+ supply the changing feature set, sizes, limits and software inventory.
26
+
27
+ ## Select a host and establish ownership
28
+
29
+ 1. Read `raft locations` for configured hosts and operator notes. Follow those
30
+ notes when choosing a location; do not change them to fit the task.
31
+ 2. Read help for `config`, `capacity`, `doctor` and `list`. Inspect the chosen
32
+ host's defaults, required note fields, retention, health, capacity and boxes.
33
+ Use location filtering when supported so an unrelated offline host does not
34
+ prevent inspection. On older versions without it, report the limitation;
35
+ do not rewrite the operator's inventory.
36
+ 3. Read existing box notes before using a box. A handle or familiar name does
37
+ not establish ownership. Operate only on task-owned boxes unless authorized
38
+ otherwise, and keep the exact location-qualified handle returned by creation.
39
+ 4. Give each new box a purpose, owner and next action using the flags discovered
40
+ in help. Update its note when the plan changes. Obey host policy errors;
41
+ never weaken policy or defaults to get past them.
42
+ 5. Size the box for the workload using the host's actual capacity and storage
43
+ capabilities. Capacity is advice, not a box-count quota. Check free disk
44
+ before large uploads, builds, snapshots or copies.
45
+
46
+ ## Choose lifecycle and verify work
47
+
48
+ - For bounded one-shot work, check whether the installed CLI provides a command
49
+ that owns creation, execution, collection and cleanup. Read its timeout and
50
+ failure semantics before choosing it. Otherwise manage that lifecycle explicitly.
51
+ - For reusable work, select persistence explicitly and inspect retention.
52
+ Stopping ends processes; it does not preserve memory. A disposable box can
53
+ disappear on stop, and a persistent box can expire under stopped-box retention.
54
+ Snapshots inside a box do not protect it against deletion of that box.
55
+ - Probe the needed guest service after creation; a returned handle does not
56
+ prove readiness. Inspect tool versions inside the guest and its `/opt/raft/`
57
+ inventory instead of assuming an image has every compiler, agent or login.
58
+ - Exec takes literal arguments. Use an explicit shell for shell syntax. Preserve
59
+ the command's exit status and collect artifacts before cleanup, including on
60
+ failure. After an uncertain transport failure, inspect the exact handle or job
61
+ before rerunning a payload that might already have executed.
62
+ - For temporary work, confirm destruction even after a failed command. For
63
+ reusable work, stop when finished and report the handle and retention deadline.
64
+ An SSH error or an eventual TTL is not proof of immediate cleanup.
65
+
66
+ ## Preserve the boundaries
67
+
68
+ Raft workspaces are native-architecture containers sharing the host kernel.
69
+ Do not promise VM isolation or cross-architecture execution. Inspect the actual
70
+ runtime before claiming any isolation or resource limit.
71
+
72
+ Host onboarding, deployment, upgrades and global cleanup affect more than a
73
+ single workspace. Perform them only within explicit authorization; inspect a
74
+ supported dry-run or preview first. Do not change daily-driver hosts or install
75
+ updates as a side effect of an ordinary workspace task.
76
+
77
+ Snapshots, forks, transfers and backups can carry credentials. Copy only the
78
+ required files and never inject host credentials without authorization. Git
79
+ filtering is not a secret scanner. Import only trusted backups and store them
80
+ privately. Restore only an explicitly requested rollback.
81
+
82
+ For a tunnel or desktop, follow current help for listener addresses and service
83
+ binding. Report the address actually printed. A private tunnel is not public
84
+ hosting, and closing it does not necessarily stop the guest service.