@microck/raft 0.6.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 (59) hide show
  1. package/README.md +13 -4
  2. package/deploy/build-image.py +4 -2
  3. package/deploy/verify-controls.py +5 -5
  4. package/deploy/verify-features.py +35 -2
  5. package/deploy/verify-jobs.py +264 -0
  6. package/deploy/verify-limits.py +20 -2
  7. package/deploy/verify-live.py +12 -3
  8. package/deploy/verify-output.py +208 -0
  9. package/deploy/verify-release.py +33 -4
  10. package/deploy/verify-run.py +571 -0
  11. package/deploy/verify-self-update.py +9 -1
  12. package/deploy/verify-sync.py +122 -0
  13. package/docs/benchmarks/read-latency-2026-10-29.json +70 -0
  14. package/docs/boat-capabilities.md +60 -7
  15. package/docs/boat-parity-design.md +1 -0
  16. package/docs/boot-performance.mdx +21 -0
  17. package/docs/contract.md +76 -2
  18. package/docs/first-box.mdx +22 -19
  19. package/docs/guides/background-jobs.mdx +38 -31
  20. package/docs/guides/backup-recovery.mdx +3 -2
  21. package/docs/guides/ci-jobs.mdx +209 -0
  22. package/docs/guides/files.mdx +2 -2
  23. package/docs/guides/index.mdx +31 -0
  24. package/docs/guides/long-running-services.mdx +1 -1
  25. package/docs/guides/meta.json +7 -2
  26. package/docs/guides/project-setup.mdx +1 -1
  27. package/docs/guides/scripting-ci.mdx +28 -9
  28. package/docs/guides/snapshots-forks.mdx +3 -1
  29. package/docs/guides/upgrades.mdx +7 -7
  30. package/docs/index.mdx +26 -28
  31. package/docs/maintainers/index.mdx +18 -0
  32. package/docs/maintainers/meta.json +12 -0
  33. package/docs/meta.json +5 -9
  34. package/docs/operations.mdx +7 -0
  35. package/docs/publishing.md +14 -4
  36. package/docs/reference/capacity.mdx +2 -1
  37. package/docs/reference/cli.md +85 -19
  38. package/docs/reference/meta.json +3 -1
  39. package/docs/releases/0.2.0.md +1 -0
  40. package/docs/releases/0.2.1.md +1 -0
  41. package/docs/releases/0.3.0.md +1 -0
  42. package/docs/releases/0.4.0.md +1 -0
  43. package/docs/releases/0.5.0.md +1 -0
  44. package/docs/releases/0.6.0.md +1 -0
  45. package/docs/releases/0.7.0.md +23 -0
  46. package/docs/releases/index.mdx +21 -0
  47. package/docs/releases/meta.json +15 -0
  48. package/docs/requirements.mdx +1 -1
  49. package/docs/reviews/docs-audit-2026-10-24.md +92 -0
  50. package/docs/reviews/performance-audit-2026-10-29.md +90 -0
  51. package/docs/site-development.md +1 -0
  52. package/docs/verification.mdx +21 -3
  53. package/package.json +3 -1
  54. package/raft.py +380 -179
  55. package/raft_files.py +13 -1
  56. package/raft_jobs.py +390 -0
  57. package/raft_output.py +166 -0
  58. package/raft_run.py +330 -0
  59. package/skills/raft-cli/SKILL.md +78 -197
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,203 +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 host policies: the box note fields it requires (`require`) and how
22
- long it keeps stopped boxes (`deleteStoppedAfterHours`, `null` for no limit).
23
- Follow each location's `note`: the operator uses it to mark preferred hosts and
24
- hosts to avoid for some work. Pass `--location` explicitly when a note rules out
25
- the default. Do not change notes unless the user asks; `raft note LOCATION TEXT`
26
- sets one and `raft note LOCATION --clear` removes it. Keep the exact
27
- location-qualified handle returned by `new`. There is no implicit current box.
28
-
29
- Note every box you create. Box notes follow one template so the next agent can
30
- tell whether a box is in use:
31
-
32
- - `--purpose`: what the box is for (required), e.g. `Integration tests for PR 42`
33
- - `--owner`: who uses it, e.g. `codex session in ~/work/app`
34
- - `--next`: what happens next, e.g. `Destroy after the tests pass`
35
-
36
- Pass them to `new`, and run `raft note "$box" --purpose ...` when the plan
37
- changes; each call replaces the whole note. If a command fails with
38
- `requires --owner (host policy)` or similar, rerun it with the fields it names;
39
- the error prints the corrected command. Never change or clear a policy with
40
- `raft policy`, or the defaults with `raft config set/unset`, to get past an
41
- error or to keep a box from retention; only do so when the user asks. `fork` and `recover` accept the same note
42
- flags, which replace the whole note on the copy. `raft list` shows every box's
43
- `note`. Read it before you resume, stop, fork or destroy a box. Treat a box whose
44
- note names another owner or a job unrelated to your task as not yours. Forks
45
- keep the source note, so update a fork's note when its job differs.
46
-
47
- ```sh
48
- box=$(raft new --location lab --ttl 600 --purpose "Run the project tests" --owner "codex task 123" --next "Destroy when the tests finish")
49
- raft exec "$box" -- timeout 240 bash -lc 'git clone <url> /workspace/project && cd /workspace/project && <test-command>'
50
- raft download "$box" /workspace/project/report.json ./report.json
51
- raft destroy "$box"
52
- ```
53
-
54
- The default development image has systemd, a guest Docker daemon, Node/npm/pnpm,
55
- Bun, Deno, Python/pip/venv/uv, Go, Rust, Java, Kotlin, Scala, Ruby, PHP,
56
- Erlang/Elixir, .NET, R, C/C++ tools, Git, Chromium and FFmpeg. Coding agents are not included in new images; install
57
- your preferred agent when needed and authenticate separately. Never copy host
58
- credentials into a box without authorization. Inspect `/opt/raft/` inside the
59
- box for recorded package versions. Do not assume every language has every
60
- version manager or that agents have an authenticated account.
61
-
62
- Exec passes literal arguments; use `bash -lc` for shell syntax. A failed command
63
- retains its exit status. `raft ssh "$box"` opens a root terminal through native
64
- Incus execution over verified host SSH. It does not expose a guest SSH port.
65
-
66
- ## Sizing boxes
67
-
68
- Create the boxes your task needs, sized for the work. Raft has no box count
69
- limit and no size budget. Decide what the task needs and why, then ask for it:
70
-
71
- - `--cpu N`: any whole number of CPUs up to the host's count (default 1). Use
72
- more for parallel builds and test suites.
73
- - `--memory SIZE`: any whole GiB up to the host's RAM, e.g. `8GiB` or `32GiB`
74
- (default `2GiB`). Use more for large compiles, browsers or databases.
75
- - `--disk SIZE`: its own disk, e.g. `30GiB`, instead of the shared 60 GiB pool.
76
- The box and Docker inside it cannot write past SIZE, and other boxes cannot
77
- take its space. Use it for Docker builds, large datasets or anything that
78
- could fill the shared pool. The image needs about 5 GiB, so use at least
79
- `8GiB`; 10 GiB or more starts fastest. Only hosts deployed with
80
- `--disk-pool` have own disks; elsewhere `--disk` fails and names the command
81
- that enables them. Own disks, their snapshots, forks and templates are
82
- copy-on-write, so copying one is fast, but each reserves its full size in
83
- the pool.
84
-
85
- ```sh
86
- 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")
87
- ```
88
-
89
- Built-in defaults are the first configured location, one CPU, 2 GiB RAM, the
90
- shared pool, a persistent box and 600 seconds; the operator can change them per
91
- controller, so pass `--location`, `--ttl`, `--cpu`, `--memory` and `--disk`
92
- (`--disk shared` for the shared pool) when the values matter. `new --from` and `fork` keep the
93
- source's CPU, RAM and disk instead of any default; each flag overrides only its
94
- own value. TTL accepts 60 seconds to 30
95
- days. A request larger than the whole host fails with the host's real size;
96
- retry with what it reports. If the own-disk pool has too little unreserved
97
- space for `--disk`, a snapshot or a copy, destroy own-disk boxes or snapshots
98
- the task no longer needs, or pick a smaller disk or the shared pool.
99
- `raft list` shows each box's `disk` (`"shared"` or its size).
100
-
101
- `raft capacity --location lab --json` is an advisory estimate of host CPU/RAM
102
- headroom and free disk. It is advice, never a reason to skip creating a box or
103
- to shrink one below what the task needs. Boxes share host CPUs; an estimate of
104
- zero means new boxes may run slower, not that they are refused.
105
-
106
- ## Persistence and cleanup
107
-
108
- Persistent boxes stop and retain disk at expiry. `new --disposable` deletes a
109
- box and its snapshots on stop or expiry. The operator can make disposable the
110
- default for `new` (`raft config` shows `disposable`); then pass `--persistent`
111
- for a box you plan to stop and resume, snapshot, fork or back up, because
112
- stopping a disposable box deletes it. Forks stay persistent unless you pass
113
- `--disposable`. `raft stop "$box"` releases the
114
- container's running processes; `raft resume "$box" --ttl 600` starts it again.
115
- Stop is not pause and does not keep guest RAM allocated. Installed dependencies,
116
- files and secrets remain on disk. For a temporary run, completion requires
117
- successful `destroy`, even after command failure. Stop reusable runners when
118
- finished. Destroy only boxes owned by the current task unless authorized otherwise.
119
-
120
- Host systemd timers enforce deadlines every 15 seconds, even when the controller
121
- is offline. `raft gc` runs those expiry workers manually and deletes expired disposable
122
- boxes. `raft prune --older-than 7` previews stopped boxes older than seven days;
123
- `--yes` deletes them. It skips running boxes and boxes without a recorded stop
124
- time. Never apply prune outside the task-owned scope without explicit permission. A powered-off host enforces expiry when it returns.
125
-
126
- A location can also delete boxes that stay stopped longer than its retention,
127
- `deleteStoppedAfterHours` in `raft config`, with their snapshots. The clock
128
- starts when the box stops, including a stop by TTL expiry, and resume resets
129
- it. `raft list` shows each stopped box's `deleteAt` (Unix time, empty when
130
- nothing is due). Do not rely on a stopped box surviving past it: copy out
131
- results with `raft download`, or keep the whole box with `raft backup`, before
132
- then. Snapshots are deleted with the box, so they protect nothing against
133
- retention. Resume a box only when its task still needs it, not just to reset
134
- the clock.
135
-
136
- Stop a box before a consistent disk snapshot or fork:
137
-
138
- ```sh
139
- raft stop "$box"
140
- raft snapshot "$box" prepared
141
- raft snapshots "$box"
142
- copy=$(raft fork "$box" --ttl 600)
143
- raft restore "$box" prepared
144
- ```
145
-
146
- Restore replaces disk contents with the snapshot and retains the destination's
147
- current instance configuration, including its MAC and resource limits. Use it
148
- only when the user requested that rollback. A fork copies filesystem data, including any
149
- credentials installed there. Forks stay on the same host. `new --from "$box/prepared"` starts from a named
150
- snapshot; `snapshot-delete "$box" prepared` removes it. Resume and fork accept
151
- optional `--cpu` and `--memory` overrides. Omitted values retain the source limits.
152
- Forks and `new --from` keep the source's CPU, RAM and disk unless a flag
153
- overrides them; `new --from "$box/prepared" --disk 60GiB` copies the snapshot onto
154
- a bigger own disk when a box runs out of space. To spawn many agents from one
155
- prepared own-disk snapshot, run `new --from` once per agent: each copy is a thin
156
- snapshot created in seconds.
157
- Forks default to persistent; use `--disposable` explicitly for temporary clones.
158
-
159
- `upload/download --recursive` copy directory trees into new destinations without
160
- merging or overwriting. They require a running guest with Python 3.11.8+ and
161
- temporary controller disk space. Escaping links and special files are rejected.
162
-
163
- ## Long commands and previews
164
-
165
- `raft exec "$box" --detach -- <command>` returns a job ID. Use `raft status
166
- "$box" <job>` and `raft logs "$box" <job>` to inspect its systemd state, exit
167
- status and output. `raft cancel "$box" <job>` stops the job and its child processes. Stop/destroy
168
- terminates running jobs; this is not automatic checkpointing of processes.
169
- `raft extend "$box" --ttl 1800` resets a running box's deadline without
170
- restarting its work. Use resume for stopped boxes. `raft usage "$box"` reports
171
- cgroup memory/CPU and disk space; `filesystemScope` says whether that is the
172
- shared pool or the box's own disk.
173
-
174
- `raft forward "$box" --remote 8080 --local 8080` opens a foreground private
175
- tunnel. `raft desktop "$box" --local 6080` starts the desktop on demand and
176
- opens its private tunnel. Use the printed localhost address, with `vnc.html`
177
- for the viewer. Ctrl+C closes the tunnel. The desktop service stays inside the
178
- box until the box stops or its service is explicitly stopped.
179
-
180
- Public hosting, reverse tunnels, browser-only confinement, agent conversation
181
- orchestration and KVM-backed VMs are not implemented. Agent binaries can be run
182
- through exec after authorized sign-in. Do not claim `boat prompt` parity.
183
-
184
- ## Portable backups
185
-
186
- Stop before exporting a private archive, including snapshots:
187
-
188
- ```sh
189
- raft stop "$box"
190
- raft backup "$box" ./workspace.tar.gz
191
- recovered=$(raft recover ./workspace.tar.gz --location second-host)
192
- raft resume "$recovered" --ttl 600
193
- ```
194
-
195
- Recovery creates a fresh stopped handle and clears the inherited deadline. It
196
- never overwrites another box. A box with its own disk comes back on an own disk
197
- of the same size, so the destination needs own box disks enabled and room for the
198
- box and each of its snapshots. Only import trusted Raft exports; archives retain
199
- native configuration and guest secrets. Store them privately and check free
200
- space on the controller and destination host. Large backups hold the host
201
- lifecycle lock and delay expiry checks. A controller crash can leave an owned
202
- staging archive or imported box; inspect the reported recovery handle before
203
- 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.