@microck/raft 0.3.0 → 0.4.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.
@@ -196,7 +196,21 @@ def verify_location_notes():
196
196
  ]:
197
197
  rejected = cli(*arguments)
198
198
  assert rejected.returncode == 1 and error in rejected.stderr, rejected
199
- assert cli("note", "lab", "x", "--clear").returncode == 2
199
+ # Box note mistakes are rejected before any host is contacted.
200
+ handle = "lab:rf-0123456789abcdef"
201
+ for arguments, error in [
202
+ (["note", "lab", "x", "--clear"], "TEXT or --clear"),
203
+ (["note", "lab"], "TEXT or --clear"),
204
+ (["note", "lab", "--purpose", "x"], "pass a box handle"),
205
+ (["note", handle, "x"], "--purpose, --owner and --next"),
206
+ (["note", handle], "--purpose (with optional"),
207
+ (["note", handle, "--purpose", "x", "--clear"], "--purpose (with optional"),
208
+ (["note", handle, "--owner", "agent"], "need --purpose"),
209
+ (["note", handle, "--purpose", " "], "--purpose cannot be empty"),
210
+ (["new", "--location", "lab", "--next", "later"], "need --purpose"),
211
+ ]:
212
+ rejected = cli(*arguments)
213
+ assert rejected.returncode == 1 and error in rejected.stderr, (arguments, rejected)
200
214
  # Concurrent edits to different locations must all survive.
201
215
  many = {f"host{index}": {"ssh": f"host{index}"} for index in range(8)}
202
216
  saved = config.read_text()
@@ -429,9 +443,27 @@ def verify(location, isolated_worker=False):
429
443
  live.raft("destroy", handle)
430
444
  fixtures.remove(handle)
431
445
  print(f"{location}: noted new kept stdout to the handle and the note on stderr", flush=True)
432
- box = create("--memory", "1GiB")
446
+ box = create("--memory", "1GiB", "--purpose", " Feature verification ", "--owner", "verify")
447
+
448
+ def listed_note(handle):
449
+ for line in live.raft("list", "--location", location).stdout.splitlines():
450
+ record = json.loads(line)
451
+ if record["box"] == handle:
452
+ return record["note"]
453
+ raise AssertionError(f"{handle} missing from list")
454
+
455
+ assert listed_note(box) == {"purpose": "Feature verification", "owner": "verify"}
456
+ # Each note edit replaces the whole note.
457
+ live.raft("note", box, "--purpose", "Feature verification", "--next", "Destroy at the end")
458
+ assert listed_note(box) == {"purpose": "Feature verification", "next": "Destroy at the end"}
459
+ live.raft("note", box, "--clear")
460
+ assert listed_note(box) is None
461
+ live.raft("note", box, "--purpose", "Feature verification")
433
462
  live.wait_running(box)
434
463
  _, name = parse_handle(box)
464
+ print(
465
+ f"{location}: box note template, listing, replacement and clearing passed", flush=True
466
+ )
435
467
  with tempfile.TemporaryDirectory(prefix="raft-tree-test-") as work:
436
468
  root = Path(work)
437
469
  source = root / "source with spaces"
@@ -522,6 +554,7 @@ def verify(location, isolated_worker=False):
522
554
  != metadata(box)["config"]["volatile.eth0.hwaddr"]
523
555
  )
524
556
  assert metadata(template)["config"]["user.raft.disposable"] == "false"
557
+ assert listed_note(template) is None, "new --from inherited the source note"
525
558
  assert metadata(template)["expanded_config"]["limits.cpu"] == "1"
526
559
  live.raft("snapshot-delete", box, "prepared")
527
560
  assert live.execute(template, "cat", "/workspace/proof") == "original"
@@ -536,6 +569,7 @@ def verify(location, isolated_worker=False):
536
569
  fork = live.raft(*fork_args).stdout.strip()
537
570
  fixtures.append(fork)
538
571
  assert metadata(fork)["expanded_config"]["limits.memory"] == "1GiB"
572
+ assert listed_note(fork) == {"purpose": "Feature verification"}, "fork lost the note"
539
573
  assert metadata(fork)["expanded_config"]["limits.cpu"] == "1"
540
574
  assert metadata(box)["expanded_config"]["limits.memory"] == "4GiB"
541
575
  if worker:
package/docs/contract.md CHANGED
@@ -42,6 +42,8 @@ Raft manages persistent unprivileged native-architecture Incus system containers
42
42
 
43
43
  `raft locations` lists configured locations as JSONL with `location`, `default` and `note` fields without contacting hosts. `raft note LOCATION TEXT` and `raft note LOCATION --clear` edit the optional `note` field in the controller configuration atomically, preserving location order and mode `0600`. Notes are advisory guidance; Raft shows them in `locations`, `limits` and on stderr from `new`, and never enforces them.
44
44
 
45
+ Boxes accept an optional note with a fixed template: `purpose` (required), `owner` and `next`, all nonempty strings. `raft new --purpose TEXT [--owner TEXT] [--next TEXT]` and `raft note BOX ...` store it as JSON in the box's `user.raft.note` Incus key; `raft note BOX --clear` removes it. Each edit replaces the whole note. `raft list` reports it as `note`, or `null`. `new` always writes the key, so `new --from` never inherits a snapshot's note; forks and recovered backups keep it. Box notes are advisory and never change lifecycle policy.
46
+
45
47
  `raft limits [--location NAME] [--json]` inspects hosts without modifying state. It reports effective CPUs, total and available RAM, saved and active box counts, active resource limits, and disk space.
46
48
 
47
49
  - **Reserve calculations**: Recommends running boxes after reserving 1 CPU and the larger of 2 GiB or 10% host RAM.
@@ -19,7 +19,7 @@ Raft does not have a global `--json` option. Parse each command's actual format:
19
19
  | --- | --- |
20
20
  | `new`, `fork`, `recover` | One location-qualified handle |
21
21
  | `exec --detach` | One `rfcmd-` job ID |
22
- | `list` | JSONL: one object per box, with `box`, `state`, `stopAt` |
22
+ | `list` | JSONL: one object per box, with `box`, `state`, `stopAt`, `note` |
23
23
  | `info` | One pretty-printed native Incus metadata JSON object |
24
24
  | `usage` | One JSON object containing running resource counters |
25
25
  | `limits --json` | One JSON object per location (JSONL across multiple hosts) |
@@ -31,7 +31,8 @@ Raft does not have a global `--json` option. Parse each command's actual format:
31
31
  | Other inspection/maintenance commands | Human-readable text or no output |
32
32
 
33
33
  `list` emits nothing when there are no boxes. `stopAt` is the host Unix deadline
34
- as a string, or an empty string when absent. `info` uses native Incus keys rather
34
+ as a string, or an empty string when absent. `note` is the box note object with
35
+ `purpose` and optional `owner` and `next`, or `null`. `info` uses native Incus keys rather
35
36
  than a normalized Raft schema. `usage` includes `memoryBytes`, `memoryLimit`,
36
37
  `cpuTime`, `effectiveCpus`, `filesystemUsedBytes`, `filesystemAvailableBytes`, and
37
38
  `filesystemScope`; disk values describe the shared pool, not an individual box.
@@ -15,7 +15,7 @@ Install the CLI from npm on your Linux controller, confirm key-based SSH to the
15
15
  host, then let `raft host add` do the remaining steps:
16
16
 
17
17
  ```sh title="CONTROLLER: Onboard a host"
18
- npm install -g @microck/raft@0.3.0
18
+ npm install -g @microck/raft@0.4.0
19
19
  ssh ubuntu@192.0.2.10 true
20
20
  raft host add lab --ssh ubuntu@192.0.2.10 --dry-run
21
21
  raft host add lab --ssh ubuntu@192.0.2.10
@@ -62,7 +62,7 @@ sudo -n true && echo "Passwordless sudo is functional"
62
62
  On your Linux controller with Python 3.11.8+, install the published CLI:
63
63
 
64
64
  ```sh title="CONTROLLER: Install published CLI"
65
- npm install -g @microck/raft@0.3.0
65
+ npm install -g @microck/raft@0.4.0
66
66
  raft --version
67
67
  ```
68
68
 
@@ -70,7 +70,7 @@ Installation does not configure hosts or download images. Clone the matching
70
70
  release to obtain the deployment scripts and example configuration:
71
71
 
72
72
  ```sh title="CONTROLLER: Obtain deployment scripts"
73
- git clone --branch v0.3.0 https://github.com/Microck/raft.git raft
73
+ git clone --branch v0.4.0 https://github.com/Microck/raft.git raft
74
74
  cd raft
75
75
  ```
76
76
 
@@ -159,16 +159,16 @@ Workspaces require an unprivileged Debian 13 development image matching the host
159
159
 
160
160
  <Tab value="Download release image">
161
161
 
162
- Download the release archive, checksum, and manifest from [Raft releases](https://github.com/Microck/raft/releases). These examples pin `v0.3.0`, matching the CLI and deployment checkout above. The `sha256sum` check gates import so corrupted archives are rejected:
162
+ Download the release archive, checksum, and manifest from [Raft releases](https://github.com/Microck/raft/releases). These examples pin `v0.4.0`, matching the CLI and deployment checkout above. The `sha256sum` check gates import so corrupted archives are rejected:
163
163
 
164
164
  <Tabs items={["ARM64", "AMD64"]}>
165
165
 
166
166
  <Tab value="ARM64">
167
167
 
168
168
  ```sh title="HOST (ARM64): Download, verify, and import release image"
169
- curl -fsSL -O https://github.com/Microck/raft/releases/download/v0.3.0/raft-dev-arm64.tar.gz
170
- curl -fsSL -O https://github.com/Microck/raft/releases/download/v0.3.0/SHA256SUMS
171
- curl -fsSL -O https://github.com/Microck/raft/releases/download/v0.3.0/manifest-arm64.json
169
+ curl -fsSL -O https://github.com/Microck/raft/releases/download/v0.4.0/raft-dev-arm64.tar.gz
170
+ curl -fsSL -O https://github.com/Microck/raft/releases/download/v0.4.0/SHA256SUMS
171
+ curl -fsSL -O https://github.com/Microck/raft/releases/download/v0.4.0/manifest-arm64.json
172
172
 
173
173
  sha256sum --ignore-missing -c SHA256SUMS && \
174
174
  sudo incus --project raft image import ./raft-dev-arm64.tar.gz --alias raft-dev
@@ -179,9 +179,9 @@ sha256sum --ignore-missing -c SHA256SUMS && \
179
179
  <Tab value="AMD64">
180
180
 
181
181
  ```sh title="HOST (AMD64): Download, verify, and import release image"
182
- curl -fsSL -O https://github.com/Microck/raft/releases/download/v0.3.0/raft-dev-amd64.tar.gz
183
- curl -fsSL -O https://github.com/Microck/raft/releases/download/v0.3.0/SHA256SUMS
184
- curl -fsSL -O https://github.com/Microck/raft/releases/download/v0.3.0/manifest-amd64.json
182
+ curl -fsSL -O https://github.com/Microck/raft/releases/download/v0.4.0/raft-dev-amd64.tar.gz
183
+ curl -fsSL -O https://github.com/Microck/raft/releases/download/v0.4.0/SHA256SUMS
184
+ curl -fsSL -O https://github.com/Microck/raft/releases/download/v0.4.0/manifest-amd64.json
185
185
 
186
186
  sha256sum --ignore-missing -c SHA256SUMS && \
187
187
  sudo incus --project raft image import ./raft-dev-amd64.tar.gz --alias raft-dev
@@ -36,7 +36,7 @@ Workspaces are referenced by location-qualified handles formatted as `<location>
36
36
 
37
37
  Create and launch a new workspace container.
38
38
 
39
- `raft new [--location LOCATION] [--ttl TTL] [--cpu {1,2}] [--memory {1GiB,2GiB,4GiB,8GiB}] [--disposable] [--from LOCATION:BOX/SNAPSHOT]`
39
+ `raft new [--location LOCATION] [--ttl TTL] [--cpu {1,2}] [--memory {1GiB,2GiB,4GiB,8GiB}] [--disposable] [--from LOCATION:BOX/SNAPSHOT] [--purpose TEXT [--owner TEXT] [--next TEXT]]`
40
40
 
41
41
  - `--location LOCATION`: Target host location in `~/.config/raft/incus.json`. Defaults to first configured location.
42
42
  - `--ttl TTL`: Lifetime in seconds before automatic stop (`60` to `2592000`). Default: `600`.
@@ -45,6 +45,7 @@ Create and launch a new workspace container.
45
45
 
46
46
  - `--disposable`: Delete the box and snapshots on stop or expiry. Off by default.
47
47
  - `--from LOCATION:BOX/SNAPSHOT`: Copy a named snapshot on its source host instead of the configured image. Normal creation sizing applies; the source can be running. Cannot target a different location.
48
+ - `--purpose TEXT`, `--owner TEXT`, `--next TEXT`: Record a [box note](#note). `--owner` and `--next` need `--purpose`. Without them the box starts with no note, including boxes created `--from` a noted snapshot.
48
49
 
49
50
  Returns the qualified workspace handle. If the location has a note, `new` prints
50
51
  it to stderr; stdout still contains only the handle.
@@ -291,14 +292,32 @@ Prints one JSON object per location (JSONL) in configuration order, with
291
292
 
292
293
  ### `note`
293
294
 
294
- Set or clear operator guidance for a location in `~/.config/raft/incus.json`.
295
+ Set or clear the note of a location or a box.
295
296
 
296
297
  `raft note LOCATION TEXT`
297
298
  `raft note LOCATION --clear`
299
+ `raft note BOX --purpose TEXT [--owner TEXT] [--next TEXT]`
300
+ `raft note BOX --clear`
298
301
 
299
- Notes are advisory. Use them to record which host is preferred or what a host
300
- should not run. Raft shows notes in `locations`, `limits` and `new` but does not
301
- enforce them. Empty notes are rejected; use `--clear` to remove one.
302
+ Location notes are operator guidance stored in `~/.config/raft/incus.json`. Use
303
+ them to record which host is preferred or what a host should not run. Raft shows
304
+ them in `locations`, `limits` and `new`.
305
+
306
+ Box notes tell other agents and people why a box exists. They follow one
307
+ template, stored on the box as JSON in the Incus key `user.raft.note`:
308
+
309
+ | Field | Meaning | Example |
310
+ | --- | --- | --- |
311
+ | `purpose` (required) | What the box is for | `Integration tests for PR 42` |
312
+ | `owner` | Who uses it | `codex session in ~/work/app` |
313
+ | `next` | What happens to it next | `Destroy after the tests pass` |
314
+
315
+ Each call replaces the whole note, on running or stopped boxes. `raft list`
316
+ shows it as `note`. Forks and recovered backups keep the source box's note;
317
+ update it if the copy has a different job.
318
+
319
+ Notes are advisory; Raft never enforces them. Empty values are rejected; use
320
+ `--clear` to remove a note.
302
321
 
303
322
  ## Host inspection and maintenance commands
304
323
 
@@ -325,7 +344,8 @@ List existing workspace containers across configured hosts.
325
344
 
326
345
  `raft list [--location LOCATION]`
327
346
 
328
- Prints one JSON object per box (JSONL), with `box`, `state`, and `stopAt` fields.
347
+ Prints one JSON object per box (JSONL), with `box`, `state`, `stopAt` and `note` fields.
348
+ `note` is the box note object, or `null` when the box has none.
329
349
  An empty inventory prints no records. See [Scripting and CI](../guides/scripting-ci.mdx)
330
350
  for output formats, exit codes, readiness, and failure handling.
331
351
 
@@ -0,0 +1,15 @@
1
+ ---
2
+ title: Raft 0.4.0
3
+ ---
4
+
5
+ # Raft 0.4.0
6
+
7
+ - Boxes accept a note that says why they exist, so agents and people can tell which boxes are in use. The note follows one template: `purpose` (required), `owner` and `next`.
8
+ - `raft new --purpose TEXT [--owner TEXT] [--next TEXT]` notes a box when it is created. `raft note BOX --purpose ...` replaces a box's note and `raft note BOX --clear` removes it. `raft note LOCATION ...` still edits location notes.
9
+ - `raft list` adds a `note` field to each box: the note object, or `null`.
10
+ - Forks and recovered backups keep the source box's note. `new --from` starts without one.
11
+ - The `raft-cli` agent skill asks agents to note every box they create and to read notes before resuming, stopping, forking or destroying a box.
12
+
13
+ Install on Linux with Python 3.11.8+ using `npm install -g @microck/raft@0.4.0`, or install the attached Python wheel. Box notes live in each box's Incus configuration; host workers are unchanged and need no redeployment.
14
+
15
+ ARM64 and AMD64 development images accompany this release. Verify `SHA256SUMS` before importing an image for your host architecture.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microck/raft",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Self-hosted Linux workspaces powered by Incus, an open-source alternative to boat.dev",
5
5
  "license": "MIT",
6
6
  "repository": {
package/raft.py CHANGED
@@ -19,7 +19,7 @@ import uuid
19
19
 
20
20
  import raft_files
21
21
 
22
- __version__ = "0.3.0"
22
+ __version__ = "0.4.0"
23
23
 
24
24
  CONFIG = Path.home() / ".config/raft/incus.json"
25
25
  PROJECT = "raft"
@@ -30,6 +30,9 @@ GIB = 1024**3
30
30
  # all derive from these lists, so adding a size here covers every command.
31
31
  CPU_SIZES = [1, 2]
32
32
  MEMORY_SIZES = ["1GiB", "2GiB", "4GiB", "8GiB"]
33
+ # Box notes follow one template so any agent can tell why a box exists, who uses
34
+ # it and what happens to it next. Each value is free text; purpose is required.
35
+ BOX_NOTE_FIELDS = ["purpose", "owner", "next"]
33
36
 
34
37
  USAGE = """import json, shutil
35
38
  from pathlib import Path
@@ -113,6 +116,26 @@ def set_note(location, text):
113
116
  edit_configuration(change)
114
117
 
115
118
 
119
+ def box_note(args):
120
+ """Build a box note from --purpose, --owner and --next; None when none is given."""
121
+ note = {}
122
+ for field in BOX_NOTE_FIELDS:
123
+ value = getattr(args, field, None)
124
+ if value is not None:
125
+ if not value.strip():
126
+ raise ValueError(f"--{field} cannot be empty")
127
+ note[field] = value.strip()
128
+ if note and "purpose" not in note:
129
+ raise ValueError("Box notes need --purpose")
130
+ return note or None
131
+
132
+
133
+ def read_box_note(box):
134
+ """Return a box's note as a dict, or None. Raft writes it as JSON in user.raft.note."""
135
+ stored = box["config"].get("user.raft.note")
136
+ return json.loads(stored) if stored else None
137
+
138
+
116
139
  def receive(command, destination, *, stdin=None):
117
140
  """Write streamed bytes in Python: SSH can exit zero after a local write error."""
118
141
  with tempfile.TemporaryFile() as errors:
@@ -607,6 +630,7 @@ def sizing(args):
607
630
 
608
631
 
609
632
  def new(args):
633
+ box_fields = box_note(args)
610
634
  source = None
611
635
  if args.template:
612
636
  handle, separator, snapshot = args.template.partition("/")
@@ -658,6 +682,9 @@ def new(args):
658
682
  "-c",
659
683
  f"user.raft.disposable={'true' if args.disposable else 'false'}",
660
684
  "-c",
685
+ # Always set, so a box created from a snapshot never inherits the source note.
686
+ "user.raft.note=" + (json.dumps(box_fields) if box_fields else ""),
687
+ "-c",
661
688
  "volatile.eth0.hwaddr=",
662
689
  *sizing(args),
663
690
  )
@@ -849,6 +876,19 @@ def ttl(value):
849
876
  return seconds
850
877
 
851
878
 
879
+ def note_options(command):
880
+ """Add the box note template flags shared by new and note."""
881
+ command.add_argument(
882
+ "--purpose", help="Box note: what the box is for, e.g. 'Integration tests for PR 42'"
883
+ )
884
+ command.add_argument(
885
+ "--owner", help="Box note: who uses the box, e.g. 'codex session in ~/work/app'"
886
+ )
887
+ command.add_argument(
888
+ "--next", help="Box note: what happens next, e.g. 'Destroy after the tests pass'"
889
+ )
890
+
891
+
852
892
  def parser():
853
893
  cli = argparse.ArgumentParser(description=__doc__)
854
894
  cli.add_argument("--version", action="version", version=__version__)
@@ -860,12 +900,13 @@ def parser():
860
900
  create.add_argument("--memory", choices=MEMORY_SIZES, default="2GiB")
861
901
  create.add_argument("--disposable", action="store_true", help="Delete on stop or expiry")
862
902
  create.add_argument("--from", dest="template", metavar="LOCATION:BOX/SNAPSHOT")
903
+ note_options(create)
863
904
  commands.add_parser("locations", help="List configured locations and their notes")
864
- note = commands.add_parser("note", help="Set or clear operator guidance for a location")
865
- note.add_argument("location")
866
- text = note.add_mutually_exclusive_group(required=True)
867
- text.add_argument("text", nargs="?", help="Guidance, e.g. 'Preferred for builds'")
868
- text.add_argument("--clear", action="store_true", help="Remove the location's note")
905
+ note = commands.add_parser("note", help="Set or clear the note of a location or a box")
906
+ note.add_argument("target", metavar="LOCATION|BOX", help="A location name or a box handle")
907
+ note.add_argument("text", nargs="?", help="Location guidance, e.g. 'Preferred for builds'")
908
+ note.add_argument("--clear", action="store_true", help="Remove the note")
909
+ note_options(note)
869
910
  host = commands.add_parser("host", help="Onboard workspace hosts")
870
911
  host_commands = host.add_subparsers(dest="host_action", required=True)
871
912
  onboard = host_commands.add_parser(
@@ -967,7 +1008,24 @@ def dispatch(args):
967
1008
  host_add(args)
968
1009
  return 0
969
1010
  if args.action == "note":
970
- set_note(args.location, None if args.clear else args.text)
1011
+ fields = box_note(args)
1012
+ # Location names cannot contain ":", so a colon always means a box handle.
1013
+ if ":" not in args.target:
1014
+ if fields is not None:
1015
+ raise ValueError("--purpose, --owner and --next describe boxes; pass a box handle")
1016
+ if args.clear == (args.text is not None):
1017
+ raise ValueError("Give the location note TEXT or --clear")
1018
+ set_note(args.target, None if args.clear else args.text)
1019
+ return 0
1020
+ if args.text is not None:
1021
+ raise ValueError("Describe boxes with --purpose, --owner and --next")
1022
+ if args.clear == (fields is not None):
1023
+ raise ValueError("Give --purpose (with optional --owner and --next) or --clear")
1024
+ location, name = parse_handle(args.target)
1025
+ instance(location, name)
1026
+ # Each call replaces the whole note; an empty value removes the key.
1027
+ value = "" if args.clear else json.dumps(fields)
1028
+ incus(location, "config", "set", name, "user.raft.note=" + value, locked=True)
971
1029
  return 0
972
1030
  if args.action == "locations":
973
1031
  for index, (location, entry) in enumerate(configuration().items()):
@@ -1032,6 +1090,7 @@ def dispatch(args):
1032
1090
  "box": location + ":" + box["name"],
1033
1091
  "state": box["status"],
1034
1092
  "stopAt": box["config"].get("user.raft.expires", ""),
1093
+ "note": read_box_note(box),
1035
1094
  }
1036
1095
  )
1037
1096
  )
@@ -18,8 +18,21 @@ the default. Do not change notes unless the user asks; `raft note LOCATION TEXT`
18
18
  sets one and `raft note LOCATION --clear` removes it. Keep the exact
19
19
  location-qualified handle returned by `new`. There is no implicit current box.
20
20
 
21
+ Note every box you create. Box notes follow one template so the next agent can
22
+ tell whether a box is in use:
23
+
24
+ - `--purpose`: what the box is for (required), e.g. `Integration tests for PR 42`
25
+ - `--owner`: who uses it, e.g. `codex session in ~/work/app`
26
+ - `--next`: what happens next, e.g. `Destroy after the tests pass`
27
+
28
+ Pass them to `new`, and run `raft note "$box" --purpose ...` when the plan
29
+ changes; each call replaces the whole note. `raft list` shows every box's
30
+ `note`. Read it before you resume, stop, fork or destroy a box. Treat a box whose
31
+ note names another owner or a job unrelated to your task as not yours. Forks
32
+ keep the source note, so update a fork's note when its job differs.
33
+
21
34
  ```sh
22
- box=$(raft new --location lab --ttl 600)
35
+ box=$(raft new --location lab --ttl 600 --purpose "Run the project tests" --owner "codex task 123" --next "Destroy when the tests finish")
23
36
  raft exec "$box" -- timeout 240 bash -lc 'git clone <url> /workspace/project && cd /workspace/project && <test-command>'
24
37
  raft download "$box" /workspace/project/report.json ./report.json
25
38
  raft destroy "$box"