pixie-lab-dibs 0.1.1__tar.gz → 0.2.0__tar.gz

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 (30) hide show
  1. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/.pre-commit-config.yaml +4 -0
  2. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/CHANGELOG.md +13 -0
  3. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/PKG-INFO +38 -8
  4. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/README.md +37 -7
  5. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/src/dibs/__init__.py +1 -1
  6. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/src/dibs/_sources.py +10 -2
  7. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/src/dibs/_store.py +69 -9
  8. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/src/dibs/app.py +28 -15
  9. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/src/dibs/cli.py +24 -0
  10. pixie_lab_dibs-0.2.0/src/dibs/skill/SKILL.md +65 -0
  11. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/tests/test_app.py +23 -0
  12. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/tests/test_cli.py +26 -0
  13. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/tests/test_sources.py +8 -0
  14. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/tests/test_store.py +72 -0
  15. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/.github/workflows/format.yml +0 -0
  16. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/.github/workflows/publish.yml +0 -0
  17. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/.github/workflows/test.yml +0 -0
  18. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/.gitignore +0 -0
  19. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/LICENSE +0 -0
  20. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/LICENSES/BSD-3-Clause.txt +0 -0
  21. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/Makefile +0 -0
  22. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/REUSE.toml +0 -0
  23. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/pyproject.toml +0 -0
  24. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/scripts/service-smoke.sh +0 -0
  25. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/src/dibs/_sshsig.py +0 -0
  26. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/src/dibs/_users.py +0 -0
  27. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/tests/__init__.py +0 -0
  28. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/tests/conftest.py +0 -0
  29. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/tests/test_users.py +0 -0
  30. {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/uv.lock +0 -0
@@ -34,6 +34,10 @@ repos:
34
34
  # fail CI there.
35
35
  additional_dependencies:
36
36
  - mdformat-gfm==1.0.0
37
+ # Without this, mdformat rewrites the agent skill's YAML frontmatter
38
+ # into a horizontal rule and a heading, and Claude Code stops
39
+ # finding the skill.
40
+ - mdformat-frontmatter==2.1.2
37
41
 
38
42
  - repo: https://github.com/fsfe/reuse-tool
39
43
  rev: v6.2.0
@@ -10,6 +10,19 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.2.0] - 2026-10-27
14
+
15
+ ### Added
16
+
17
+ - Parts: a thing can have parts named `<thing>.<part>`, claimable on
18
+ their own. Claiming the thing covers its parts, so it conflicts with
19
+ another user's claim on any part, and a part conflicts with another
20
+ user's claim on the thing. Forcing the thing takes over its parts.
21
+ - `dibs skill install` puts a Claude Code skill in
22
+ `~/.claude/skills/dibs/` that teaches an agent to claim a lab machine
23
+ before using it, to claim the whole machine for anything through the
24
+ BMC, and to release when done. `dibs skill print` shows it.
25
+
13
26
  ## [0.1.1] - 2026-10-27
14
27
 
15
28
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pixie-lab-dibs
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Call dibs on shared lab machines: see who is using what, claim it, release it
5
5
  Project-URL: Homepage, https://github.com/safl/pixie-lab-dibs
6
6
  Author-email: "Simon A. F. Lund" <os@safl.dk>
@@ -144,10 +144,38 @@ those rather than parse the text.
144
144
  The exit status is 0 when done, 1 when refused because someone else
145
145
  holds a thing, and 2 for anything else.
146
146
 
147
+ ### Parts
148
+
149
+ A thing can have parts that people use independently, named
150
+ `<thing>.<part>` and listed in the config like any other thing:
151
+
152
+ ```toml
153
+ [[thing]]
154
+ name = "epyc-02.gpu1"
155
+ ```
156
+
157
+ Claiming `epyc-02` takes the whole machine, parts included, so it is
158
+ refused while someone else holds a part. Claiming `epyc-02.gpu1` leaves
159
+ the other parts to others, and is refused while someone else holds the
160
+ whole machine. A part listed without its thing still makes the thing
161
+ claimable.
162
+
163
+ Only make something a part if using it cannot disturb the rest of the
164
+ machine. Anything that can, such as the BMC, power, the console or
165
+ reflashing, needs a claim on the whole machine, so it is an attribute of
166
+ the thing (`bmc = "10.20.30.82"`) rather than a part.
167
+
147
168
  ### AI-agents
148
169
 
149
- Point your agent at this section. An agent claims as its human, and
150
- names itself the way an `Assisted-by:` trailer does:
170
+ For Claude Code, install the skill that teaches an agent all of this,
171
+ and it applies whenever the agent is about to use a lab machine:
172
+
173
+ ```
174
+ dibs skill install
175
+ ```
176
+
177
+ `dibs skill print` shows it, to adapt for another agent. An agent claims
178
+ as its human, and names itself the way an `Assisted-by:` trailer does:
151
179
 
152
180
  ```
153
181
  export DIBS_ASSISTED_BY="Claude Code:claude-opus-5-5"
@@ -157,11 +185,13 @@ The claim then reads `'alice' assisted by 'Claude Code:claude-opus-5-5' has clai
157
185
 
158
186
  If you are an agent reading this: before you run anything on a lab
159
187
  machine, run `dibs list` and claim the machine with
160
- `dibs claim <machine> --for <purpose>`. Never use a machine someone else
161
- has claimed, and never pass `--force`; ask your human instead. Release
162
- what you claimed with `dibs release --mine` when the work is done, and
163
- also when it fails or is abandoned. That releases only what you claimed,
164
- not your human's own claims.
188
+ `dibs claim <machine> --for <purpose>`. Power-cycling, the console,
189
+ reflashing or anything else through the BMC needs the whole machine,
190
+ never just a part. Never use a machine someone else has claimed, and
191
+ never pass `--force`; ask your human instead. Release what you claimed
192
+ with `dibs release --mine` when the work is done, and also when it fails
193
+ or is abandoned. That releases only what you claimed, not your human's
194
+ own claims.
165
195
 
166
196
  ### Automation-runners
167
197
 
@@ -122,10 +122,38 @@ those rather than parse the text.
122
122
  The exit status is 0 when done, 1 when refused because someone else
123
123
  holds a thing, and 2 for anything else.
124
124
 
125
+ ### Parts
126
+
127
+ A thing can have parts that people use independently, named
128
+ `<thing>.<part>` and listed in the config like any other thing:
129
+
130
+ ```toml
131
+ [[thing]]
132
+ name = "epyc-02.gpu1"
133
+ ```
134
+
135
+ Claiming `epyc-02` takes the whole machine, parts included, so it is
136
+ refused while someone else holds a part. Claiming `epyc-02.gpu1` leaves
137
+ the other parts to others, and is refused while someone else holds the
138
+ whole machine. A part listed without its thing still makes the thing
139
+ claimable.
140
+
141
+ Only make something a part if using it cannot disturb the rest of the
142
+ machine. Anything that can, such as the BMC, power, the console or
143
+ reflashing, needs a claim on the whole machine, so it is an attribute of
144
+ the thing (`bmc = "10.20.30.82"`) rather than a part.
145
+
125
146
  ### AI-agents
126
147
 
127
- Point your agent at this section. An agent claims as its human, and
128
- names itself the way an `Assisted-by:` trailer does:
148
+ For Claude Code, install the skill that teaches an agent all of this,
149
+ and it applies whenever the agent is about to use a lab machine:
150
+
151
+ ```
152
+ dibs skill install
153
+ ```
154
+
155
+ `dibs skill print` shows it, to adapt for another agent. An agent claims
156
+ as its human, and names itself the way an `Assisted-by:` trailer does:
129
157
 
130
158
  ```
131
159
  export DIBS_ASSISTED_BY="Claude Code:claude-opus-5-5"
@@ -135,11 +163,13 @@ The claim then reads `'alice' assisted by 'Claude Code:claude-opus-5-5' has clai
135
163
 
136
164
  If you are an agent reading this: before you run anything on a lab
137
165
  machine, run `dibs list` and claim the machine with
138
- `dibs claim <machine> --for <purpose>`. Never use a machine someone else
139
- has claimed, and never pass `--force`; ask your human instead. Release
140
- what you claimed with `dibs release --mine` when the work is done, and
141
- also when it fails or is abandoned. That releases only what you claimed,
142
- not your human's own claims.
166
+ `dibs claim <machine> --for <purpose>`. Power-cycling, the console,
167
+ reflashing or anything else through the BMC needs the whole machine,
168
+ never just a part. Never use a machine someone else has claimed, and
169
+ never pass `--force`; ask your human instead. Release what you claimed
170
+ with `dibs release --mine` when the work is done, and also when it fails
171
+ or is abandoned. That releases only what you claimed, not your human's
172
+ own claims.
143
173
 
144
174
  ### Automation-runners
145
175
 
@@ -12,4 +12,4 @@ everyone who is on a machine, and it never stops anyone from logging
12
12
  in.
13
13
  """
14
14
 
15
- __version__ = "0.1.1"
15
+ __version__ = "0.2.0"
@@ -18,7 +18,9 @@ hand and names the pixie servers to poll::
18
18
  mac = "aa:bb:cc:dd:ee:ff" # any other key is kept as an attribute
19
19
 
20
20
  A ``[[thing]]`` with the name of a pixie machine adds its attributes to
21
- that machine rather than making a second thing.
21
+ that machine rather than making a second thing. A name with a dot is a
22
+ part of the thing before the dot, ``epyc-02.bmc`` of ``epyc-02``, claimable
23
+ on its own or along with the whole thing.
22
24
  """
23
25
 
24
26
  from __future__ import annotations
@@ -32,7 +34,7 @@ from typing import Any
32
34
 
33
35
  import httpx
34
36
 
35
- from dibs._store import Store, Thing, ts, validate_thing_name
37
+ from dibs._store import Store, Thing, owner, ts, validate_thing_name
36
38
 
37
39
  log = logging.getLogger("dibs")
38
40
 
@@ -150,4 +152,10 @@ def sync(
150
152
  base.kind = t.kind
151
153
  else:
152
154
  merged[t.name] = Thing(t.name, t.kind, t.source, dict(t.attrs), stamp)
155
+ # A part listed without its thing still makes the thing claimable as a
156
+ # whole, so "epyc-02.bmc" alone gives you "epyc-02" too.
157
+ for t in list(merged.values()):
158
+ whole = owner(t.name)
159
+ if whole not in merged:
160
+ merged[whole] = Thing(whole, t.kind, t.source, {}, stamp)
153
161
  store.replace_things(sorted(merged.values(), key=lambda t: t.name))
@@ -10,6 +10,13 @@ things table is rebuilt from the sources on every sync (see
10
10
  a table of their own, keyed by thing name, so a claim outlives a sync in
11
11
  which its source was unreachable or briefly forgot the thing.
12
12
 
13
+ A thing may have **parts**, named ``<thing>.<part>``, such as
14
+ ``epyc-02.ssh`` and ``epyc-02.bmc``. Claiming the thing covers all of its
15
+ parts; claiming a part leaves the others free for someone else. So a
16
+ claim on a thing conflicts with another user's claim on any of its parts,
17
+ and a claim on a part conflicts with another user's claim on that part
18
+ or on the thing.
19
+
13
20
  Every surface (``dibs``, the web page, the login hook on a machine)
14
21
  prints a claim as the same sentence, from :func:`sentence`.
15
22
  """
@@ -31,8 +38,11 @@ from typing import Any
31
38
  _WRITE_LOCK = threading.Lock()
32
39
 
33
40
  # What people type and put in configs next to the hostname, so a host
34
- # label: lowercase a-z, 0-9 and -, at most 63 chars.
35
- THING_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
41
+ # label (lowercase a-z, 0-9 and -, at most 63 chars), optionally followed
42
+ # by one ".<part>" label. A hostname label has no dot, so the dot is free
43
+ # to mean "part of".
44
+ _LABEL = r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"
45
+ THING_NAME_RE = re.compile(rf"^{_LABEL}(\.{_LABEL})?$")
36
46
 
37
47
  PURPOSE_MAX = 128
38
48
  ASSISTED_BY_MAX = 64
@@ -68,12 +78,23 @@ def validate_thing_name(raw: str) -> str:
68
78
  name = (raw or "").strip().lower()
69
79
  if not THING_NAME_RE.match(name):
70
80
  raise ValueError(
71
- f"name {name!r} must be a hostname label: a-z / 0-9 / -, "
72
- "1-63 chars, no leading or trailing -"
81
+ f"name {name!r} must be a hostname label (a-z / 0-9 / -, 1-63 chars, "
82
+ "no leading or trailing -), optionally with one .part, e.g. epyc-02.bmc"
73
83
  )
74
84
  return name
75
85
 
76
86
 
87
+ def owner(name: str) -> str:
88
+ """The thing a part belongs to; a thing is its own owner."""
89
+ return name.split(".", 1)[0]
90
+
91
+
92
+ def overlaps(a: str, b: str) -> bool:
93
+ """Whether claims on ``a`` and ``b`` cover any of the same thing: the
94
+ same name, or a thing and one of its parts."""
95
+ return a == b or a == owner(b) or b == owner(a)
96
+
97
+
77
98
  def _one_line(field_name: str, raw: str, limit: int) -> str:
78
99
  value = (raw or "").strip()
79
100
  if not value:
@@ -160,17 +181,42 @@ def remaining(expires_at: str, now: datetime) -> str:
160
181
 
161
182
 
162
183
  def sentence(name: str, claim: Claim | None, now: datetime) -> str:
184
+ """One claim as it reads everywhere. The claim names the thing it is
185
+ on, which for a part covered by its thing's claim is not ``name``."""
163
186
  if claim is None or not claim.is_live(now):
164
187
  return f"'{name}' is free"
165
188
  who = f"'{claim.holder}'"
166
189
  if claim.assisted_by:
167
190
  who += f" assisted by '{claim.assisted_by}'"
168
191
  return (
169
- f"{who} has claimed '{name}' for '{claim.purpose}' "
192
+ f"{who} has claimed '{claim.thing}' for '{claim.purpose}' "
170
193
  f"expires in: '{remaining(claim.expires_at, now)}'"
171
194
  )
172
195
 
173
196
 
197
+ def covering(name: str, claims: dict[str, Claim], now: datetime) -> builtins.list[Claim]:
198
+ """The live claims that say who is on ``name``: its own claim, else
199
+ its thing's claim for a part, else its parts' claims for a thing."""
200
+ own = claims.get(name)
201
+ if own is not None and own.is_live(now):
202
+ return [own]
203
+ if "." in name:
204
+ whole = claims.get(owner(name))
205
+ return [whole] if whole is not None and whole.is_live(now) else []
206
+ return sorted(
207
+ (c for c in claims.values() if owner(c.thing) == name and c.is_live(now)),
208
+ key=lambda c: c.thing,
209
+ )
210
+
211
+
212
+ def describe(name: str, claims: dict[str, Claim], now: datetime) -> str:
213
+ """The sentence for ``name``, counting claims on its thing or parts."""
214
+ found = covering(name, claims, now)
215
+ if not found:
216
+ return f"'{name}' is free"
217
+ return "; ".join(sentence(c.thing, c, now) for c in found)
218
+
219
+
174
220
  class Store:
175
221
  def __init__(self, db_path: Path) -> None:
176
222
  self.db_path = Path(db_path)
@@ -248,8 +294,10 @@ class Store:
248
294
  or without an agent, updates the purpose, expiry and
249
295
  ``assisted_by`` and keeps ``claimed_at``. ``expires_in`` is
250
296
  seconds from now; ``None`` never expires. Returns ``(before,
251
- after)`` pairs so the caller can log what a ``force`` took over.
252
- Callers check that the things exist."""
297
+ after)`` pairs so the caller can log what a ``force`` took over;
298
+ a forced claim on a thing also takes over other users' claims on
299
+ its parts, and those appear as pairs too. Callers check that the
300
+ things exist."""
253
301
  purpose = validate_purpose(purpose)
254
302
  assisted_by = validate_assisted_by(assisted_by)
255
303
  if expires_in is not None and expires_in <= 0:
@@ -260,14 +308,16 @@ class Store:
260
308
  with _WRITE_LOCK, self._conn() as conn:
261
309
  before = {n: self._claim_row(conn, n) for n in unique}
262
310
  live = {n: c for n, c in before.items() if c is not None and c.is_live(at)}
263
- blockers = [c for c in live.values() if c.holder != holder]
311
+ others = [c for c in self._live_rows(conn, at) if c.holder != holder]
312
+ blockers = [c for c in others if any(overlaps(c.thing, n) for n in unique)]
264
313
  if blockers and not force:
265
314
  raise ClaimConflict(blockers, at)
266
315
  out: builtins.list[tuple[Claim | None, Claim]] = []
316
+ afters: dict[str, Claim] = {}
267
317
  for n in unique:
268
318
  prior = live.get(n)
269
319
  since = prior.claimed_at if prior and prior.holder == holder else ts(at)
270
- after = Claim(n, holder, purpose, since, assisted_by, expires_at)
320
+ after = afters[n] = Claim(n, holder, purpose, since, assisted_by, expires_at)
271
321
  conn.execute(
272
322
  "INSERT OR REPLACE INTO claims "
273
323
  "(thing, holder, assisted_by, purpose, claimed_at, expires_at) "
@@ -275,6 +325,11 @@ class Store:
275
325
  (n, holder, assisted_by, purpose, since, expires_at),
276
326
  )
277
327
  out.append((prior, after))
328
+ for b in blockers:
329
+ if b.thing not in afters:
330
+ conn.execute("DELETE FROM claims WHERE thing = ?", (b.thing,))
331
+ covering_name = next(n for n in unique if overlaps(b.thing, n))
332
+ out.append((b, afters[covering_name]))
278
333
  return out
279
334
 
280
335
  def release(
@@ -313,6 +368,11 @@ class Store:
313
368
  ).fetchall()
314
369
  return [c for c in (Claim(**dict(r)) for r in rows) if c.is_live(at)]
315
370
 
371
+ @staticmethod
372
+ def _live_rows(conn: sqlite3.Connection, at: datetime) -> builtins.list[Claim]:
373
+ rows = conn.execute("SELECT * FROM claims").fetchall()
374
+ return [c for c in (Claim(**dict(r)) for r in rows) if c.is_live(at)]
375
+
316
376
  @staticmethod
317
377
  def _claim_row(conn: sqlite3.Connection, name: str) -> Claim | None:
318
378
  row = conn.execute("SELECT * FROM claims WHERE thing = ?", (name,)).fetchone()
@@ -31,7 +31,7 @@ from pydantic import BaseModel, Field
31
31
 
32
32
  import dibs
33
33
  from dibs._sources import Config, PixieSource, load_config, sync
34
- from dibs._store import Claim, ClaimConflict, Store, Thing, sentence
34
+ from dibs._store import Claim, ClaimConflict, Store, Thing, covering, describe, sentence
35
35
  from dibs._users import DuplicateUser, LoginFailed, NotApproved, UsersStore
36
36
 
37
37
  log = logging.getLogger("dibs")
@@ -80,19 +80,28 @@ def current_user(request: Request) -> str:
80
80
  return str(user)
81
81
 
82
82
 
83
- def _view(name: str, thing: Thing | None, claim: Claim | None, now: datetime) -> dict[str, Any]:
84
- """One thing as the API shows it. A claim on a thing no source lists
85
- any more still shows, with ``source`` ``gone``, so it can be released."""
83
+ def _view(
84
+ name: str, thing: Thing | None, claims: dict[str, Claim], now: datetime
85
+ ) -> dict[str, Any]:
86
+ """One thing as the API shows it. ``claim`` is a claim on this very
87
+ name; ``covered_by`` lists every live claim that says who is on it,
88
+ which for a part includes its thing's claim and for a thing its
89
+ parts'. A claim on a thing no source lists any more still shows,
90
+ with ``source`` ``gone``, so it can be released."""
86
91
  out: dict[str, Any] = {
87
92
  "name": name,
88
93
  "kind": thing.kind if thing else "unknown",
89
94
  "source": thing.source if thing else "gone",
90
- "sentence": sentence(name, claim, now),
95
+ "sentence": describe(name, claims, now),
91
96
  }
92
97
  if thing and thing.attrs:
93
98
  out["attrs"] = thing.attrs
94
- if claim is not None and claim.is_live(now):
95
- out["claim"] = claim.to_dict()
99
+ own = claims.get(name)
100
+ if own is not None and own.is_live(now):
101
+ out["claim"] = own.to_dict()
102
+ found = covering(name, claims, now)
103
+ if found:
104
+ out["covered_by"] = [{"thing": c.thing, **c.to_dict()} for c in found]
96
105
  return out
97
106
 
98
107
 
@@ -153,16 +162,17 @@ def create_app(
153
162
  things = {t.name: t for t in store.list_things()}
154
163
  claims = store.list_claims()
155
164
  names = sorted(set(things) | {n for n, c in claims.items() if c.is_live(now)})
156
- return {"things": [_view(n, things.get(n), claims.get(n), now) for n in names]}
165
+ return {"things": [_view(n, things.get(n), claims, now) for n in names]}
157
166
 
158
167
  @app.get("/things/{name}")
159
168
  def get_thing(name: str) -> dict[str, Any]:
160
169
  now = datetime.now(UTC)
170
+ name = name.strip().lower()
161
171
  thing = store.get_thing(name)
162
- claim = store.get_claim(name.strip().lower())
163
- if thing is None and claim is None:
172
+ claims = store.list_claims()
173
+ if thing is None and name not in claims:
164
174
  raise HTTPException(status_code=404, detail=f"no such thing: {name}")
165
- out = _view(name.strip().lower(), thing, claim, now)
175
+ out = _view(name, thing, claims, now)
166
176
  if thing is not None and thing.source in app.state.pixies:
167
177
  info = app.state.pixies[thing.source].info(thing.name)
168
178
  if info is not None:
@@ -174,7 +184,7 @@ def create_app(
174
184
  name = name.strip().lower()
175
185
  if store.get_thing(name) is None:
176
186
  raise HTTPException(status_code=404, detail=f"no such thing: {name}")
177
- return PlainTextResponse(sentence(name, store.get_claim(name), datetime.now(UTC)) + "\n")
187
+ return PlainTextResponse(describe(name, store.list_claims(), datetime.now(UTC)) + "\n")
178
188
 
179
189
  @app.get("/claim", response_class=PlainTextResponse)
180
190
  def claim_for_host(mac: str = "", host: str = "") -> PlainTextResponse:
@@ -183,7 +193,7 @@ def create_app(
183
193
  thing = store.thing_by_mac(mac.split(",")) or (store.get_thing(host) if host else None)
184
194
  if thing is None:
185
195
  raise HTTPException(status_code=404, detail="not a thing dibs knows")
186
- text = sentence(thing.name, store.get_claim(thing.name), datetime.now(UTC))
196
+ text = describe(thing.name, store.list_claims(), datetime.now(UTC))
187
197
  return PlainTextResponse(text + "\n")
188
198
 
189
199
  @app.post("/claims")
@@ -209,7 +219,9 @@ def create_app(
209
219
  log.warning("%s took %s from %s", user, after.thing, before.holder)
210
220
  log.info("%s", sentence(after.thing, after, now))
211
221
  things = {t.name: t for t in store.list_things()}
212
- return {"things": [_view(a.thing, things.get(a.thing), a, now) for _, a in pairs]}
222
+ claims = store.list_claims()
223
+ claimed = list(dict.fromkeys(a.thing for _, a in pairs))
224
+ return {"things": [_view(n, things.get(n), claims, now) for n in claimed]}
213
225
 
214
226
  @app.post("/claims/release")
215
227
  def release(body: ReleaseBody, user: str = Depends(current_user)) -> dict[str, Any]:
@@ -225,9 +237,10 @@ def create_app(
225
237
  except ClaimConflict as exc:
226
238
  raise _conflict(exc) from exc
227
239
  things = {t.name: t for t in store.list_things()}
240
+ claims = store.list_claims()
228
241
  for c in released:
229
242
  log.info("%s released %s (held by %s)", user, c.thing, c.holder)
230
- return {"things": [_view(c.thing, things.get(c.thing), None, now) for c in released]}
243
+ return {"things": [_view(c.thing, things.get(c.thing), claims, now) for c in released]}
231
244
 
232
245
  @app.post("/auth/register", status_code=202)
233
246
  def register(body: RegisterBody) -> dict[str, str]:
@@ -548,6 +548,25 @@ def cmd_admin_revoke(args: argparse.Namespace) -> None:
548
548
  print(f"Revoked {count} token(s) of '{args.name}'; they must log in again.")
549
549
 
550
550
 
551
+ # ---- agent skill -----------------------------------------------------------
552
+
553
+ SKILL = Path(__file__).parent / "skill" / "SKILL.md"
554
+
555
+
556
+ def cmd_skill(args: argparse.Namespace) -> None:
557
+ """Install the Claude Code skill that teaches an agent to claim
558
+ machines before using them, or print it for another agent's setup."""
559
+ text = SKILL.read_text()
560
+ if args.action == "print":
561
+ sys.stdout.write(text)
562
+ return
563
+ target = Path(args.dir).expanduser() if args.dir else Path.home() / ".claude" / "skills"
564
+ path = target / "dibs" / "SKILL.md"
565
+ path.parent.mkdir(parents=True, exist_ok=True)
566
+ path.write_text(text)
567
+ print(f"Installed {path}. Claude Code picks it up in new sessions.")
568
+
569
+
551
570
  # ---- shell completion ------------------------------------------------------
552
571
 
553
572
  # Commands whose positional arguments are thing names, which the
@@ -730,6 +749,11 @@ def build_parser() -> argparse.ArgumentParser:
730
749
  user_mode(p)
731
750
  p.set_defaults(func=func)
732
751
 
752
+ p = sub.add_parser("skill", help="install the agent skill that says how to use dibs")
753
+ p.add_argument("action", choices=["install", "print"])
754
+ p.add_argument("--dir", help="skills directory (default: ~/.claude/skills)")
755
+ p.set_defaults(func=cmd_skill)
756
+
733
757
  p = sub.add_parser("completion", help="print a shell completion script")
734
758
  p.add_argument("shell", choices=["bash"])
735
759
  p.set_defaults(func=cmd_completion)
@@ -0,0 +1,65 @@
1
+ ---
2
+ name: dibs
3
+ description: Claim a shared lab machine with dibs before using it, and release it after. Use before you SSH into, run tests on, flash, reboot, power-cycle, or open the BMC or console of any lab machine (e.g. epyc-01, epyc-02, ryzen-01), including through cijoe, ipmitool or Redfish.
4
+ ---
5
+
6
+ <!--
7
+ SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
8
+
9
+ SPDX-License-Identifier: BSD-3-Clause
10
+ -->
11
+
12
+ # Claim lab machines with dibs
13
+
14
+ Lab machines are shared with people, other agents and CI. dibs says who
15
+ is on which. A claim is a note, not a lock: nothing stops you from
16
+ logging in to a machine someone else holds, so it is on you not to.
17
+
18
+ ## Before you touch a machine
19
+
20
+ 1. Look: `dibs list`. Use `dibs list --json` to read it, not the text.
21
+
22
+ 1. Claim it, naming yourself as an agent and saying what for:
23
+
24
+ ```
25
+ DIBS_ASSISTED_BY="Claude Code:<your model id>" dibs claim <machine> --for "<what you are doing>"
26
+ ```
27
+
28
+ Set `DIBS_ASSISTED_BY` on every dibs command, inline, so each one
29
+ agrees on who you are. It uses your human's login; you need no login
30
+ of your own.
31
+
32
+ 1. Exit status 1 means someone else holds it. Do not use the machine and
33
+ do not pass `--force`. Tell your human who holds it (the error says)
34
+ and ask what to do.
35
+
36
+ Anything that goes through the BMC or affects the whole box needs the
37
+ whole machine: power-cycling, the serial or graphical console, booting
38
+ or reflashing it, firmware, and kernel or driver changes. Claim
39
+ `<machine>` for those.
40
+
41
+ Some machines list parts in `dibs list`, named `<machine>.<part>`. Claim
42
+ a part only when your work stays inside it and cannot disturb the rest
43
+ of the machine; claiming the machine takes all of its parts. Claim
44
+ several things in one command; it is all or nothing.
45
+
46
+ ## When you are done
47
+
48
+ Release what you claimed, and also when the work fails, is abandoned, or
49
+ waits on your human for long:
50
+
51
+ ```
52
+ DIBS_ASSISTED_BY="Claude Code:<your model id>" dibs release --mine
53
+ ```
54
+
55
+ This releases only what you claimed, not your human's own claims.
56
+
57
+ ## Never
58
+
59
+ - Use a machine claimed by someone else, or one you have not claimed.
60
+ - Pass `--force`, or release a claim you did not make.
61
+ - Power-cycle, reflash, reboot or use the BMC of a machine when you hold
62
+ only a part of it.
63
+
64
+ If `dibs` is not installed or `DIBS_URL` is not set, stop and tell your
65
+ human rather than going ahead without a claim.
@@ -153,3 +153,26 @@ def test_hook_sends_macs_and_hostname_once(hook: Path, tmp_path: Path) -> None:
153
153
  assert r.stdout == ""
154
154
  sent = calls.read_text().splitlines()
155
155
  assert len(sent) == 1 and "mac=" in sent[0] and "host=" in sent[0]
156
+
157
+
158
+ def test_parts_over_the_api(client: TestClient, tokens, tmp_path: Path) -> None:
159
+ """A part and its thing, through claim, list, the text route and the
160
+ login hook's lookup."""
161
+ config = client.app.state.config # type: ignore[attr-defined]
162
+ from dibs._store import Thing
163
+
164
+ config.things.append(Thing("ryzen-01.bmc", "machine", "config", {"host": "10.0.0.61"}))
165
+ client.app.state.sync() # type: ignore[attr-defined]
166
+ assert _claim(client, tokens, "ryzen-01.bmc", who="bob").status_code == 200
167
+ r = _claim(client, tokens, "ryzen-01")
168
+ assert r.status_code == 409
169
+ assert r.json()["detail"]["claimed"] == [
170
+ "'bob' has claimed 'ryzen-01.bmc' for 'xnvme' expires in: 'never'"
171
+ ]
172
+ things = {t["name"]: t for t in client.get("/things").json()["things"]}
173
+ assert things["ryzen-01"]["sentence"].startswith("'bob' has claimed 'ryzen-01.bmc'")
174
+ assert "claim" not in things["ryzen-01"]
175
+ assert things["ryzen-01"]["covered_by"][0]["thing"] == "ryzen-01.bmc"
176
+ # The login hook on ryzen-01 (by MAC) sees the part's claim.
177
+ hook = client.get("/claim", params={"mac": "aa:bb:cc:dd:ee:60"}).text
178
+ assert hook.startswith("'bob' has claimed 'ryzen-01.bmc'")
@@ -259,3 +259,29 @@ def test_bash_completion(tmp_path: Path, capsys) -> None:
259
259
  assert _complete(script, "dibs claim --e", tmp_path) == ["--expires"]
260
260
  assert _complete(script, "dibs claim w", tmp_path) == ["warp"]
261
261
  assert "--names" not in _complete(script, "dibs list --", tmp_path)
262
+
263
+
264
+ def test_skill_install_and_print(tmp_path: Path, capsys) -> None:
265
+ assert cli.main(["skill", "install", "--dir", str(tmp_path)]) == 0
266
+ installed = (tmp_path / "dibs" / "SKILL.md").read_text()
267
+ assert installed.startswith("---\nname: dibs\n")
268
+ assert "dibs release --mine" in installed and "--force" in installed
269
+ capsys.readouterr()
270
+ assert cli.main(["skill", "print"]) == 0
271
+ assert capsys.readouterr().out == installed
272
+
273
+
274
+ def test_skill_ships_in_the_wheel(tmp_path: Path) -> None:
275
+ """The skill is data, not code, so check the built wheel carries it."""
276
+ import zipfile
277
+
278
+ if shutil.which("uv") is None:
279
+ pytest.skip("needs uv")
280
+ subprocess.run(
281
+ ["uv", "build", "--wheel", "--out-dir", str(tmp_path)],
282
+ cwd=Path(__file__).parent.parent,
283
+ check=True,
284
+ capture_output=True,
285
+ )
286
+ wheel = next(tmp_path.glob("*.whl"))
287
+ assert "dibs/skill/SKILL.md" in zipfile.ZipFile(wheel).namelist()
@@ -72,3 +72,11 @@ def test_a_machine_pixie_forgets_disappears(tmp_path: Path, pixie: FakePixie) ->
72
72
  pixie.machines = [m for m in pixie.machines if m.get("name") != "warp"]
73
73
  sync(store, load_config(tmp_path / "dibs.toml"), [pixie.source()])
74
74
  assert "warp" not in {t.name for t in store.list_things()}
75
+
76
+
77
+ def test_a_part_alone_makes_its_thing_claimable(tmp_path: Path) -> None:
78
+ path = tmp_path / "dibs.toml"
79
+ path.write_text('[[thing]]\nname = "box.bmc"\nhost = "10.0.0.2"\n')
80
+ store = Store(tmp_path / "dibs.db")
81
+ sync(store, load_config(path), [])
82
+ assert [t.name for t in store.list_things()] == ["box", "box.bmc"]
@@ -113,3 +113,75 @@ def test_claimed_by_is_scoped_to_assisted_by(store: Store) -> None:
113
113
  def test_claim_rejects_bad_text(store: Store, purpose: str, assisted_by: str) -> None:
114
114
  with pytest.raises(ValueError):
115
115
  store.claim(["a"], holder="alice", purpose=purpose, assisted_by=assisted_by, now=T0)
116
+
117
+
118
+ # ---- parts ----------------------------------------------------------------
119
+
120
+
121
+ @pytest.mark.parametrize("name", ["epyc-02", "epyc-02.bmc", "a.b"])
122
+ def test_part_names_are_accepted(name: str) -> None:
123
+ from dibs._store import validate_thing_name
124
+
125
+ assert validate_thing_name(name) == name
126
+
127
+
128
+ @pytest.mark.parametrize("name", ["epyc-02.", ".bmc", "a.b.c", "a..b", "a.-b"])
129
+ def test_bad_part_names_are_refused(name: str) -> None:
130
+ from dibs._store import validate_thing_name
131
+
132
+ with pytest.raises(ValueError):
133
+ validate_thing_name(name)
134
+
135
+
136
+ def test_two_users_may_hold_different_parts(store: Store) -> None:
137
+ store.claim(["box.ssh"], holder="alice", purpose="x", now=T0)
138
+ store.claim(["box.bmc"], holder="bob", purpose="y", now=T0)
139
+ assert {n: c.holder for n, c in store.list_claims().items()} == {
140
+ "box.ssh": "alice",
141
+ "box.bmc": "bob",
142
+ }
143
+
144
+
145
+ def test_the_whole_thing_conflicts_with_another_users_part(store: Store) -> None:
146
+ store.claim(["box.bmc"], holder="bob", purpose="y", now=T0)
147
+ with pytest.raises(ClaimConflict) as exc:
148
+ store.claim(["box"], holder="alice", purpose="x", now=T0)
149
+ assert exc.value.sentences == ["'bob' has claimed 'box.bmc' for 'y' expires in: 'never'"]
150
+
151
+
152
+ def test_a_part_conflicts_with_another_users_whole_thing(store: Store) -> None:
153
+ store.claim(["box"], holder="bob", purpose="y", now=T0)
154
+ with pytest.raises(ClaimConflict):
155
+ store.claim(["box.ssh"], holder="alice", purpose="x", now=T0)
156
+ # The same user holding the whole thing may also claim a part of it.
157
+ store.claim(["box.ssh"], holder="bob", purpose="z", now=T0)
158
+
159
+
160
+ def test_parts_of_other_things_do_not_conflict(store: Store) -> None:
161
+ store.claim(["box.bmc"], holder="bob", purpose="y", now=T0)
162
+ store.claim(["boxer", "boxer.bmc"], holder="alice", purpose="x", now=T0)
163
+
164
+
165
+ def test_forcing_the_whole_thing_takes_over_its_parts(store: Store) -> None:
166
+ store.claim(["box.bmc"], holder="bob", purpose="y", now=T0)
167
+ pairs = store.claim(["box"], holder="alice", purpose="x", force=True, now=T0)
168
+ taken = [(b.thing, b.holder, a.thing) for b, a in pairs if b is not None]
169
+ assert taken == [("box.bmc", "bob", "box")]
170
+ assert set(store.list_claims()) == {"box"}
171
+
172
+
173
+ def test_describe_reads_through_parts_and_wholes(store: Store) -> None:
174
+ from dibs._store import describe
175
+
176
+ store.claim(["box.bmc"], holder="bob", purpose="power", now=T0)
177
+ claims = store.list_claims()
178
+ assert (
179
+ describe("box", claims, T0) == "'bob' has claimed 'box.bmc' for 'power' expires in: 'never'"
180
+ )
181
+ assert describe("box.ssh", claims, T0) == "'box.ssh' is free"
182
+ store.release(["box.bmc"], holder="bob", now=T0)
183
+ store.claim(["box"], holder="alice", purpose="all", now=T0)
184
+ claims = store.list_claims()
185
+ assert (
186
+ describe("box.ssh", claims, T0) == "'alice' has claimed 'box' for 'all' expires in: 'never'"
187
+ )
File without changes
File without changes
File without changes