pixie-lab-dibs 0.2.0__tar.gz → 0.3.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.
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/CHANGELOG.md +29 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/PKG-INFO +28 -14
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/README.md +27 -13
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/src/dibs/__init__.py +1 -1
- pixie_lab_dibs-0.3.0/src/dibs/_skill.py +26 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/src/dibs/_store.py +85 -29
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/src/dibs/app.py +82 -5
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/src/dibs/cli.py +51 -5
- pixie_lab_dibs-0.3.0/src/dibs/skill/SKILL.md +86 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/tests/test_app.py +26 -4
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/tests/test_cli.py +52 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/tests/test_store.py +64 -9
- pixie_lab_dibs-0.2.0/src/dibs/skill/SKILL.md +0 -65
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/.github/workflows/format.yml +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/.github/workflows/publish.yml +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/.github/workflows/test.yml +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/.gitignore +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/.pre-commit-config.yaml +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/LICENSE +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/LICENSES/BSD-3-Clause.txt +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/Makefile +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/REUSE.toml +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/pyproject.toml +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/scripts/service-smoke.sh +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/src/dibs/_sources.py +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/src/dibs/_sshsig.py +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/src/dibs/_users.py +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/tests/__init__.py +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/tests/conftest.py +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/tests/test_sources.py +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/tests/test_users.py +0 -0
- {pixie_lab_dibs-0.2.0 → pixie_lab_dibs-0.3.0}/uv.lock +0 -0
|
@@ -10,6 +10,35 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.3.0] - 2026-10-27
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Agent sessions: `DIBS_SESSION` tags the claims of one agent session,
|
|
18
|
+
with a random tag from `dibs session`,
|
|
19
|
+
and claims read `... assisted by 'Claude Code:<model>' (session <tag>) ...`. `release --mine` releases only that session's claims.
|
|
20
|
+
- `dibs whoami` shows who claims are made as, and fails when nobody has
|
|
21
|
+
logged in, so an agent can tell setup is missing.
|
|
22
|
+
- `/agents` and the skill now say who does what: a person sets up once
|
|
23
|
+
per workstation, and every agent there uses that login, never
|
|
24
|
+
registering itself.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- A claim belongs to its actor, the user with their agent and session,
|
|
29
|
+
rather than to the user alone. Two agents of one person, or a person
|
|
30
|
+
and their agent, now conflict when claiming the same thing, instead of
|
|
31
|
+
the second silently taking over. A person claiming by hand can still
|
|
32
|
+
release any of their agents' claims without `--force`.
|
|
33
|
+
|
|
34
|
+
## [0.2.1] - 2026-10-27
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- `GET /agents` explains the server to whoever is pointed at it: setup
|
|
39
|
+
with this server's URL filled in, what is claimed right now, and the
|
|
40
|
+
agent skill's rules. The web page links to it.
|
|
41
|
+
|
|
13
42
|
## [0.2.0] - 2026-10-27
|
|
14
43
|
|
|
15
44
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pixie-lab-dibs
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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>
|
|
@@ -167,6 +167,12 @@ the thing (`bmc = "10.20.30.82"`) rather than a part.
|
|
|
167
167
|
|
|
168
168
|
### AI-agents
|
|
169
169
|
|
|
170
|
+
Every dibs server explains itself at `/agents`: how to set up for this
|
|
171
|
+
very server, with its URL filled in, what is claimed right now, and the
|
|
172
|
+
rules below. So the one thing to tell a person or an agent about a lab
|
|
173
|
+
is "before touching its machines, read `http://dibs:8090/agents`", in a
|
|
174
|
+
prompt, a project's `AGENTS.md`, or a message.
|
|
175
|
+
|
|
170
176
|
For Claude Code, install the skill that teaches an agent all of this,
|
|
171
177
|
and it applies whenever the agent is about to use a lab machine:
|
|
172
178
|
|
|
@@ -174,24 +180,31 @@ and it applies whenever the agent is about to use a lab machine:
|
|
|
174
180
|
dibs skill install
|
|
175
181
|
```
|
|
176
182
|
|
|
177
|
-
`dibs skill print` shows it, to adapt for another agent.
|
|
178
|
-
|
|
183
|
+
`dibs skill print` shows it, to adapt for another agent.
|
|
184
|
+
|
|
185
|
+
An agent never registers or logs in. It claims with its human's login,
|
|
186
|
+
names itself the way an `Assisted-by:` trailer does, and tags its
|
|
187
|
+
session with a tag from `dibs session`, so that two agents of one person
|
|
188
|
+
do not take or release each other's claims. The login is shared; the
|
|
189
|
+
tag is not, because each agent passes its own on its commands:
|
|
179
190
|
|
|
180
191
|
```
|
|
181
|
-
|
|
192
|
+
DIBS_ASSISTED_BY="Claude Code:claude-opus-5-5" DIBS_SESSION=s-3f2a9c41 dibs claim epyc-02 --for "ring test"
|
|
182
193
|
```
|
|
183
194
|
|
|
184
|
-
The claim then reads `'alice' assisted by 'Claude Code:claude-opus-5-5' has claimed ...`.
|
|
195
|
+
The claim then reads `'alice' assisted by 'Claude Code:claude-opus-5-5' (session s-3f2a9c41) has claimed ...`. Another session cannot claim
|
|
196
|
+
over it or release it without `--force`. The human, claiming by hand,
|
|
197
|
+
can release any of their agents' claims, but needs `--force` to claim
|
|
198
|
+
over one.
|
|
185
199
|
|
|
186
|
-
If you are an agent reading this:
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
reflashing or anything else through the BMC
|
|
190
|
-
never just a part. Never use a machine someone
|
|
191
|
-
never pass `--force`; ask your human instead.
|
|
192
|
-
with `dibs release --mine` when the work is done, and also when
|
|
193
|
-
or is abandoned. That releases only
|
|
194
|
-
own claims.
|
|
200
|
+
If you are an agent reading this: run `dibs whoami` first; if it fails,
|
|
201
|
+
stop and tell your human, never register yourself. Before you run
|
|
202
|
+
anything on a lab machine, run `dibs list` and claim the machine.
|
|
203
|
+
Power-cycling, the console, reflashing or anything else through the BMC
|
|
204
|
+
needs the whole machine, never just a part. Never use a machine someone
|
|
205
|
+
else has claimed, and never pass `--force`; ask your human instead.
|
|
206
|
+
Release with `dibs release --mine` when the work is done, and also when
|
|
207
|
+
it fails or is abandoned. That releases only your session's claims.
|
|
195
208
|
|
|
196
209
|
### Automation-runners
|
|
197
210
|
|
|
@@ -257,6 +270,7 @@ no login; claim and release take `Authorization: Bearer <token>`.
|
|
|
257
270
|
| `POST /auth/register` | `{"user": "...", "pubkey": "ssh-ed25519 ..."}` |
|
|
258
271
|
| `POST /auth/challenge` | `{"user": "..."}` gives a nonce and the message to sign |
|
|
259
272
|
| `POST /auth/login` | `{"user", "nonce", "signature"}` gives a token |
|
|
273
|
+
| `GET /agents` | how to use this server, for a person or an agent, as text |
|
|
260
274
|
| `GET /login.sh` | the login hook |
|
|
261
275
|
|
|
262
276
|
A claim or release blocked by someone else's claim answers 409 with
|
|
@@ -145,6 +145,12 @@ the thing (`bmc = "10.20.30.82"`) rather than a part.
|
|
|
145
145
|
|
|
146
146
|
### AI-agents
|
|
147
147
|
|
|
148
|
+
Every dibs server explains itself at `/agents`: how to set up for this
|
|
149
|
+
very server, with its URL filled in, what is claimed right now, and the
|
|
150
|
+
rules below. So the one thing to tell a person or an agent about a lab
|
|
151
|
+
is "before touching its machines, read `http://dibs:8090/agents`", in a
|
|
152
|
+
prompt, a project's `AGENTS.md`, or a message.
|
|
153
|
+
|
|
148
154
|
For Claude Code, install the skill that teaches an agent all of this,
|
|
149
155
|
and it applies whenever the agent is about to use a lab machine:
|
|
150
156
|
|
|
@@ -152,24 +158,31 @@ and it applies whenever the agent is about to use a lab machine:
|
|
|
152
158
|
dibs skill install
|
|
153
159
|
```
|
|
154
160
|
|
|
155
|
-
`dibs skill print` shows it, to adapt for another agent.
|
|
156
|
-
|
|
161
|
+
`dibs skill print` shows it, to adapt for another agent.
|
|
162
|
+
|
|
163
|
+
An agent never registers or logs in. It claims with its human's login,
|
|
164
|
+
names itself the way an `Assisted-by:` trailer does, and tags its
|
|
165
|
+
session with a tag from `dibs session`, so that two agents of one person
|
|
166
|
+
do not take or release each other's claims. The login is shared; the
|
|
167
|
+
tag is not, because each agent passes its own on its commands:
|
|
157
168
|
|
|
158
169
|
```
|
|
159
|
-
|
|
170
|
+
DIBS_ASSISTED_BY="Claude Code:claude-opus-5-5" DIBS_SESSION=s-3f2a9c41 dibs claim epyc-02 --for "ring test"
|
|
160
171
|
```
|
|
161
172
|
|
|
162
|
-
The claim then reads `'alice' assisted by 'Claude Code:claude-opus-5-5' has claimed ...`.
|
|
173
|
+
The claim then reads `'alice' assisted by 'Claude Code:claude-opus-5-5' (session s-3f2a9c41) has claimed ...`. Another session cannot claim
|
|
174
|
+
over it or release it without `--force`. The human, claiming by hand,
|
|
175
|
+
can release any of their agents' claims, but needs `--force` to claim
|
|
176
|
+
over one.
|
|
163
177
|
|
|
164
|
-
If you are an agent reading this:
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
reflashing or anything else through the BMC
|
|
168
|
-
never just a part. Never use a machine someone
|
|
169
|
-
never pass `--force`; ask your human instead.
|
|
170
|
-
with `dibs release --mine` when the work is done, and also when
|
|
171
|
-
or is abandoned. That releases only
|
|
172
|
-
own claims.
|
|
178
|
+
If you are an agent reading this: run `dibs whoami` first; if it fails,
|
|
179
|
+
stop and tell your human, never register yourself. Before you run
|
|
180
|
+
anything on a lab machine, run `dibs list` and claim the machine.
|
|
181
|
+
Power-cycling, the console, reflashing or anything else through the BMC
|
|
182
|
+
needs the whole machine, never just a part. Never use a machine someone
|
|
183
|
+
else has claimed, and never pass `--force`; ask your human instead.
|
|
184
|
+
Release with `dibs release --mine` when the work is done, and also when
|
|
185
|
+
it fails or is abandoned. That releases only your session's claims.
|
|
173
186
|
|
|
174
187
|
### Automation-runners
|
|
175
188
|
|
|
@@ -235,6 +248,7 @@ no login; claim and release take `Authorization: Bearer <token>`.
|
|
|
235
248
|
| `POST /auth/register` | `{"user": "...", "pubkey": "ssh-ed25519 ..."}` |
|
|
236
249
|
| `POST /auth/challenge` | `{"user": "..."}` gives a nonce and the message to sign |
|
|
237
250
|
| `POST /auth/login` | `{"user", "nonce", "signature"}` gives a token |
|
|
251
|
+
| `GET /agents` | how to use this server, for a person or an agent, as text |
|
|
238
252
|
| `GET /login.sh` | the login hook |
|
|
239
253
|
|
|
240
254
|
A claim or release blocked by someone else's claim answers 409 with
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
4
|
+
|
|
5
|
+
"""The agent skill, shared by ``dibs skill`` and the server's ``/agents``
|
|
6
|
+
page, so both say exactly the same thing."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
SKILL_PATH = Path(__file__).parent / "skill" / "SKILL.md"
|
|
14
|
+
|
|
15
|
+
# The YAML frontmatter Claude Code reads, then the REUSE comment.
|
|
16
|
+
_PREAMBLE = re.compile(r"\A---\n.*?\n---\n\s*<!--.*?-->\s*", re.DOTALL)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def skill_text() -> str:
|
|
20
|
+
"""The skill as Claude Code installs it."""
|
|
21
|
+
return SKILL_PATH.read_text()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def skill_body() -> str:
|
|
25
|
+
"""The protocol alone, for readers that are not Claude Code."""
|
|
26
|
+
return _PREAMBLE.sub("", skill_text(), count=1)
|
|
@@ -17,6 +17,13 @@ claim on a thing conflicts with another user's claim on any of its parts,
|
|
|
17
17
|
and a claim on a part conflicts with another user's claim on that part
|
|
18
18
|
or on the thing.
|
|
19
19
|
|
|
20
|
+
A claim is made by an **actor**: the user, and when an agent claims for
|
|
21
|
+
them, the agent (``assisted_by``) and its session. One actor cannot claim
|
|
22
|
+
over another's claim, even of the same user, so two agents of one person
|
|
23
|
+
do not collide unnoticed. Releasing is the same, except that the user
|
|
24
|
+
claiming by hand, without agent or session, may release any claim made
|
|
25
|
+
under their name: an agent answers to its human.
|
|
26
|
+
|
|
20
27
|
Every surface (``dibs``, the web page, the login hook on a machine)
|
|
21
28
|
prints a claim as the same sentence, from :func:`sentence`.
|
|
22
29
|
"""
|
|
@@ -47,6 +54,9 @@ THING_NAME_RE = re.compile(rf"^{_LABEL}(\.{_LABEL})?$")
|
|
|
47
54
|
PURPOSE_MAX = 128
|
|
48
55
|
ASSISTED_BY_MAX = 64
|
|
49
56
|
|
|
57
|
+
# Short, and plain, since it is typed into every command of a session.
|
|
58
|
+
SESSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$")
|
|
59
|
+
|
|
50
60
|
_SCHEMA = """
|
|
51
61
|
CREATE TABLE IF NOT EXISTS things (
|
|
52
62
|
name TEXT PRIMARY KEY,
|
|
@@ -61,7 +71,8 @@ CREATE TABLE IF NOT EXISTS claims (
|
|
|
61
71
|
assisted_by TEXT NOT NULL DEFAULT '',
|
|
62
72
|
purpose TEXT NOT NULL,
|
|
63
73
|
claimed_at TEXT NOT NULL,
|
|
64
|
-
expires_at TEXT NOT NULL DEFAULT ''
|
|
74
|
+
expires_at TEXT NOT NULL DEFAULT '',
|
|
75
|
+
session TEXT NOT NULL DEFAULT ''
|
|
65
76
|
);
|
|
66
77
|
"""
|
|
67
78
|
|
|
@@ -117,6 +128,14 @@ def validate_assisted_by(raw: str) -> str:
|
|
|
117
128
|
return _one_line("assisted_by", raw, ASSISTED_BY_MAX) if (raw or "").strip() else ""
|
|
118
129
|
|
|
119
130
|
|
|
131
|
+
def validate_session(raw: str) -> str:
|
|
132
|
+
"""Optional, so blank passes."""
|
|
133
|
+
value = (raw or "").strip()
|
|
134
|
+
if value and not SESSION_RE.match(value):
|
|
135
|
+
raise ValueError(f"session {value!r} must be letters, digits, . _ or -, at most 32 chars")
|
|
136
|
+
return value
|
|
137
|
+
|
|
138
|
+
|
|
120
139
|
@dataclass
|
|
121
140
|
class Thing:
|
|
122
141
|
name: str
|
|
@@ -139,6 +158,12 @@ class Claim:
|
|
|
139
158
|
an ``Assisted-by:`` trailer; blank for a claim made by hand."""
|
|
140
159
|
expires_at: str = ""
|
|
141
160
|
"""Blank for a claim that never expires."""
|
|
161
|
+
session: str = ""
|
|
162
|
+
"""Which of the holder's agent sessions made it; blank by hand."""
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def actor(self) -> tuple[str, str, str]:
|
|
166
|
+
return (self.holder, self.assisted_by, self.session)
|
|
142
167
|
|
|
143
168
|
def is_live(self, now: datetime) -> bool:
|
|
144
169
|
"""A claim past its expiry counts as no claim: the thing is free
|
|
@@ -153,13 +178,15 @@ class Claim:
|
|
|
153
178
|
}
|
|
154
179
|
if self.assisted_by:
|
|
155
180
|
out["assisted_by"] = self.assisted_by
|
|
181
|
+
if self.session:
|
|
182
|
+
out["session"] = self.session
|
|
156
183
|
if self.expires_at:
|
|
157
184
|
out["expires_at"] = self.expires_at
|
|
158
185
|
return out
|
|
159
186
|
|
|
160
187
|
|
|
161
188
|
class ClaimConflict(ValueError):
|
|
162
|
-
"""One or more things are claimed by another
|
|
189
|
+
"""One or more things are claimed by another actor."""
|
|
163
190
|
|
|
164
191
|
def __init__(self, blockers: Sequence[Claim], now: datetime) -> None:
|
|
165
192
|
self.blockers = list(blockers)
|
|
@@ -188,6 +215,8 @@ def sentence(name: str, claim: Claim | None, now: datetime) -> str:
|
|
|
188
215
|
who = f"'{claim.holder}'"
|
|
189
216
|
if claim.assisted_by:
|
|
190
217
|
who += f" assisted by '{claim.assisted_by}'"
|
|
218
|
+
if claim.session:
|
|
219
|
+
who += f" (session {claim.session})"
|
|
191
220
|
return (
|
|
192
221
|
f"{who} has claimed '{claim.thing}' for '{claim.purpose}' "
|
|
193
222
|
f"expires in: '{remaining(claim.expires_at, now)}'"
|
|
@@ -222,6 +251,9 @@ class Store:
|
|
|
222
251
|
self.db_path = Path(db_path)
|
|
223
252
|
with self._conn() as conn:
|
|
224
253
|
conn.executescript(_SCHEMA)
|
|
254
|
+
cols = {r[1] for r in conn.execute("PRAGMA table_info(claims)")}
|
|
255
|
+
if "session" not in cols: # a database from before sessions
|
|
256
|
+
conn.execute("ALTER TABLE claims ADD COLUMN session TEXT NOT NULL DEFAULT ''")
|
|
225
257
|
|
|
226
258
|
@contextlib.contextmanager
|
|
227
259
|
def _conn(self) -> Generator[sqlite3.Connection]:
|
|
@@ -283,23 +315,25 @@ class Store:
|
|
|
283
315
|
holder: str,
|
|
284
316
|
purpose: str,
|
|
285
317
|
assisted_by: str = "",
|
|
318
|
+
session: str = "",
|
|
286
319
|
expires_in: int | None = None,
|
|
287
320
|
force: bool = False,
|
|
288
321
|
now: datetime | None = None,
|
|
289
322
|
) -> builtins.list[tuple[Claim | None, Claim]]:
|
|
290
|
-
"""Claim every thing in ``names`` for ``holder
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
``
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
things exist."""
|
|
323
|
+
"""Claim every thing in ``names`` for the actor ``holder`` /
|
|
324
|
+
``assisted_by`` / ``session``, or none of them: if any holds a live
|
|
325
|
+
claim by another actor (and ``force`` is off) this raises
|
|
326
|
+
:class:`ClaimConflict` and changes nothing. Claiming again as the
|
|
327
|
+
same actor updates the purpose and expiry and keeps
|
|
328
|
+
``claimed_at``. ``expires_in`` is seconds from now; ``None`` never
|
|
329
|
+
expires. Returns ``(before, after)`` pairs so the caller can log
|
|
330
|
+
what a ``force`` took over; a forced claim on a thing also takes
|
|
331
|
+
over other actors' claims on its parts, and those appear as pairs
|
|
332
|
+
too. Callers check that the things exist."""
|
|
301
333
|
purpose = validate_purpose(purpose)
|
|
302
334
|
assisted_by = validate_assisted_by(assisted_by)
|
|
335
|
+
session = validate_session(session)
|
|
336
|
+
me = (holder, assisted_by, session)
|
|
303
337
|
if expires_in is not None and expires_in <= 0:
|
|
304
338
|
raise ValueError("expires_in must be a positive number of seconds")
|
|
305
339
|
at = now or datetime.now(UTC)
|
|
@@ -308,7 +342,7 @@ class Store:
|
|
|
308
342
|
with _WRITE_LOCK, self._conn() as conn:
|
|
309
343
|
before = {n: self._claim_row(conn, n) for n in unique}
|
|
310
344
|
live = {n: c for n, c in before.items() if c is not None and c.is_live(at)}
|
|
311
|
-
others = [c for c in self._live_rows(conn, at) if c.
|
|
345
|
+
others = [c for c in self._live_rows(conn, at) if c.actor != me]
|
|
312
346
|
blockers = [c for c in others if any(overlaps(c.thing, n) for n in unique)]
|
|
313
347
|
if blockers and not force:
|
|
314
348
|
raise ClaimConflict(blockers, at)
|
|
@@ -316,13 +350,21 @@ class Store:
|
|
|
316
350
|
afters: dict[str, Claim] = {}
|
|
317
351
|
for n in unique:
|
|
318
352
|
prior = live.get(n)
|
|
319
|
-
since = prior.claimed_at if prior and prior.
|
|
320
|
-
after = afters[n] = Claim(
|
|
353
|
+
since = prior.claimed_at if prior and prior.actor == me else ts(at)
|
|
354
|
+
after = afters[n] = Claim(
|
|
355
|
+
thing=n,
|
|
356
|
+
holder=holder,
|
|
357
|
+
purpose=purpose,
|
|
358
|
+
claimed_at=since,
|
|
359
|
+
assisted_by=assisted_by,
|
|
360
|
+
expires_at=expires_at,
|
|
361
|
+
session=session,
|
|
362
|
+
)
|
|
321
363
|
conn.execute(
|
|
322
364
|
"INSERT OR REPLACE INTO claims "
|
|
323
|
-
"(thing, holder, assisted_by, purpose, claimed_at, expires_at) "
|
|
324
|
-
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
325
|
-
(n, holder, assisted_by, purpose, since, expires_at),
|
|
365
|
+
"(thing, holder, assisted_by, purpose, claimed_at, expires_at, session) "
|
|
366
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
367
|
+
(n, holder, assisted_by, purpose, since, expires_at, session),
|
|
326
368
|
)
|
|
327
369
|
out.append((prior, after))
|
|
328
370
|
for b in blockers:
|
|
@@ -337,34 +379,48 @@ class Store:
|
|
|
337
379
|
names: Sequence[str],
|
|
338
380
|
*,
|
|
339
381
|
holder: str,
|
|
382
|
+
assisted_by: str = "",
|
|
383
|
+
session: str = "",
|
|
340
384
|
force: bool = False,
|
|
341
385
|
now: datetime | None = None,
|
|
342
386
|
) -> builtins.list[Claim]:
|
|
343
387
|
"""Release the claims on ``names``, all or nothing like
|
|
344
|
-
:meth:`claim
|
|
345
|
-
|
|
346
|
-
|
|
388
|
+
:meth:`claim`. Without ``force`` an actor may release its own
|
|
389
|
+
claims, and the user by hand (no agent, no session) may also
|
|
390
|
+
release its agents'. Expired claims are cleared whoever holds
|
|
391
|
+
them. Returns the live claims released."""
|
|
347
392
|
at = now or datetime.now(UTC)
|
|
348
393
|
unique = list(dict.fromkeys(names))
|
|
394
|
+
me = (holder, assisted_by.strip(), session.strip())
|
|
395
|
+
by_hand = not me[1] and not me[2]
|
|
396
|
+
|
|
397
|
+
def mine(c: Claim) -> bool:
|
|
398
|
+
return c.actor == me or (by_hand and c.holder == holder)
|
|
399
|
+
|
|
349
400
|
with _WRITE_LOCK, self._conn() as conn:
|
|
350
401
|
before = [c for c in (self._claim_row(conn, n) for n in unique) if c is not None]
|
|
351
|
-
blockers = [c for c in before if c.is_live(at) and c
|
|
402
|
+
blockers = [c for c in before if c.is_live(at) and not mine(c)]
|
|
352
403
|
if blockers and not force:
|
|
353
404
|
raise ClaimConflict(blockers, at)
|
|
354
405
|
conn.executemany("DELETE FROM claims WHERE thing = ?", [(c.thing,) for c in before])
|
|
355
406
|
return [c for c in before if c.is_live(at)]
|
|
356
407
|
|
|
357
408
|
def claimed_by(
|
|
358
|
-
self,
|
|
409
|
+
self,
|
|
410
|
+
holder: str,
|
|
411
|
+
assisted_by: str = "",
|
|
412
|
+
session: str = "",
|
|
413
|
+
*,
|
|
414
|
+
now: datetime | None = None,
|
|
359
415
|
) -> builtins.list[Claim]:
|
|
360
|
-
"""Live claims
|
|
361
|
-
|
|
362
|
-
its human's own claims alone."""
|
|
416
|
+
"""Live claims of exactly this actor, so an agent's ``release
|
|
417
|
+
--mine`` leaves its human's and its other sessions' claims alone."""
|
|
363
418
|
at = now or datetime.now(UTC)
|
|
364
419
|
with self._conn() as conn:
|
|
365
420
|
rows = conn.execute(
|
|
366
|
-
"SELECT * FROM claims WHERE holder = ? AND assisted_by = ?
|
|
367
|
-
|
|
421
|
+
"SELECT * FROM claims WHERE holder = ? AND assisted_by = ? AND session = ? "
|
|
422
|
+
"ORDER BY thing",
|
|
423
|
+
(holder, assisted_by.strip(), session.strip()),
|
|
368
424
|
).fetchall()
|
|
369
425
|
return [c for c in (Claim(**dict(r)) for r in rows) if c.is_live(at)]
|
|
370
426
|
|
|
@@ -30,6 +30,7 @@ from fastapi.responses import HTMLResponse, PlainTextResponse, Response
|
|
|
30
30
|
from pydantic import BaseModel, Field
|
|
31
31
|
|
|
32
32
|
import dibs
|
|
33
|
+
from dibs._skill import skill_body
|
|
33
34
|
from dibs._sources import Config, PixieSource, load_config, sync
|
|
34
35
|
from dibs._store import Claim, ClaimConflict, Store, Thing, covering, describe, sentence
|
|
35
36
|
from dibs._users import DuplicateUser, LoginFailed, NotApproved, UsersStore
|
|
@@ -41,6 +42,7 @@ class ClaimBody(BaseModel):
|
|
|
41
42
|
things: list[str] = Field(..., min_length=1)
|
|
42
43
|
purpose: str
|
|
43
44
|
assisted_by: str = Field(default="", description="Agent:model, as in an Assisted-by trailer")
|
|
45
|
+
session: str = Field(default="", description="Which agent session of the user is claiming")
|
|
44
46
|
expires_in: int | None = Field(default=None, description="Seconds; null never expires")
|
|
45
47
|
force: bool = False
|
|
46
48
|
|
|
@@ -48,7 +50,10 @@ class ClaimBody(BaseModel):
|
|
|
48
50
|
class ReleaseBody(BaseModel):
|
|
49
51
|
things: list[str] = Field(default_factory=list)
|
|
50
52
|
assisted_by: str = ""
|
|
51
|
-
|
|
53
|
+
session: str = ""
|
|
54
|
+
mine: bool = Field(
|
|
55
|
+
default=False, description="Every live claim of this user, assisted_by and session"
|
|
56
|
+
)
|
|
52
57
|
force: bool = False
|
|
53
58
|
|
|
54
59
|
|
|
@@ -76,7 +81,11 @@ def current_user(request: Request) -> str:
|
|
|
76
81
|
"""Dependency: the user a bearer token belongs to, else 401."""
|
|
77
82
|
user = request.app.state.users.user_for_token(_bearer(request))
|
|
78
83
|
if user is None:
|
|
79
|
-
raise HTTPException(
|
|
84
|
+
raise HTTPException(
|
|
85
|
+
status_code=401,
|
|
86
|
+
detail="not logged in; a person sets up with dibs register and dibs login "
|
|
87
|
+
"(agents use their person's login)",
|
|
88
|
+
)
|
|
80
89
|
return str(user)
|
|
81
90
|
|
|
82
91
|
|
|
@@ -206,6 +215,7 @@ def create_app(
|
|
|
206
215
|
holder=user,
|
|
207
216
|
purpose=body.purpose,
|
|
208
217
|
assisted_by=body.assisted_by,
|
|
218
|
+
session=body.session,
|
|
209
219
|
expires_in=body.expires_in,
|
|
210
220
|
force=body.force,
|
|
211
221
|
now=now,
|
|
@@ -229,11 +239,20 @@ def create_app(
|
|
|
229
239
|
if body.mine == bool(body.things):
|
|
230
240
|
raise HTTPException(status_code=422, detail="name things to release, or set mine")
|
|
231
241
|
if body.mine:
|
|
232
|
-
names = [
|
|
242
|
+
names = [
|
|
243
|
+
c.thing for c in store.claimed_by(user, body.assisted_by, body.session, now=now)
|
|
244
|
+
]
|
|
233
245
|
else:
|
|
234
246
|
names = [n.strip().lower() for n in body.things]
|
|
235
247
|
try:
|
|
236
|
-
released = store.release(
|
|
248
|
+
released = store.release(
|
|
249
|
+
names,
|
|
250
|
+
holder=user,
|
|
251
|
+
assisted_by=body.assisted_by,
|
|
252
|
+
session=body.session,
|
|
253
|
+
force=body.force,
|
|
254
|
+
now=now,
|
|
255
|
+
)
|
|
237
256
|
except ClaimConflict as exc:
|
|
238
257
|
raise _conflict(exc) from exc
|
|
239
258
|
things = {t.name: t for t in store.list_things()}
|
|
@@ -297,6 +316,16 @@ def create_app(
|
|
|
297
316
|
login. It points back at whichever dibs URL fetched it."""
|
|
298
317
|
return PlainTextResponse(LOGIN_HOOK.format(base=str(request.base_url)))
|
|
299
318
|
|
|
319
|
+
@app.get("/agents", response_class=PlainTextResponse)
|
|
320
|
+
def agents(request: Request) -> PlainTextResponse:
|
|
321
|
+
"""How to use dibs on this lab, for a person or an agent pointed at
|
|
322
|
+
it: the setup with this server's URL filled in, what is claimed
|
|
323
|
+
right now, then the protocol from the agent skill."""
|
|
324
|
+
base = str(request.base_url).rstrip("/")
|
|
325
|
+
now_lines = "\n".join(f"- {t['sentence']}" for t in list_things()["things"])
|
|
326
|
+
page = AGENTS_PAGE.format(base=base, now=now_lines or "- nothing to claim yet")
|
|
327
|
+
return PlainTextResponse(page + "\n" + skill_body(), media_type="text/plain; charset=utf-8")
|
|
328
|
+
|
|
300
329
|
@app.get("/", response_class=HTMLResponse)
|
|
301
330
|
def index() -> HTMLResponse:
|
|
302
331
|
rows = list_things()["things"]
|
|
@@ -337,6 +366,53 @@ case $- in *i*)
|
|
|
337
366
|
esac
|
|
338
367
|
"""
|
|
339
368
|
|
|
369
|
+
AGENTS_PAGE = """\
|
|
370
|
+
# dibs on this lab
|
|
371
|
+
|
|
372
|
+
The machines on this lab are shared by people, ai-agents and
|
|
373
|
+
automation-runners. Each is claimed with dibs, at {base}, before anyone
|
|
374
|
+
touches it. Read the part for you.
|
|
375
|
+
|
|
376
|
+
## If you are a person: set up once per workstation
|
|
377
|
+
|
|
378
|
+
```
|
|
379
|
+
uv tool install pixie-lab-dibs
|
|
380
|
+
echo 'export DIBS_URL={base}' >> ~/.bashrc
|
|
381
|
+
dibs register <name> # then ask the lab's admin to approve you
|
|
382
|
+
dibs login
|
|
383
|
+
dibs skill install # Claude Code agents then follow the rules below by themselves
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Every agent you run on this workstation uses this login. Agents never
|
|
387
|
+
register or log in themselves.
|
|
388
|
+
|
|
389
|
+
## If you are an agent: before anything else
|
|
390
|
+
|
|
391
|
+
1. Check that your human has set up this workstation:
|
|
392
|
+
|
|
393
|
+
```
|
|
394
|
+
DIBS_URL={base} dibs whoami
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
It prints your human's name. If it fails, stop, and give your human the
|
|
398
|
+
setup above. Do not register or log in yourself.
|
|
399
|
+
|
|
400
|
+
1. Get a session tag for this conversation, once, with `dibs session`, and
|
|
401
|
+
pass it, with who you are, on every dibs command:
|
|
402
|
+
|
|
403
|
+
```
|
|
404
|
+
DIBS_URL={base} DIBS_ASSISTED_BY="Claude Code:<model>" DIBS_SESSION=<tag> dibs ...
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Your claims then say which agent made them, and no other agent of the
|
|
408
|
+
same person can take or release them without `--force`.
|
|
409
|
+
|
|
410
|
+
## Right now
|
|
411
|
+
|
|
412
|
+
{now}
|
|
413
|
+
|
|
414
|
+
"""
|
|
415
|
+
|
|
340
416
|
PAGE = """<!doctype html>
|
|
341
417
|
<html lang="en"><head><meta charset="utf-8"><title>dibs</title>
|
|
342
418
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -347,7 +423,8 @@ td, th {{ text-align: left; padding: .4rem .6rem; border-bottom: 1px solid #ddd;
|
|
|
347
423
|
</style></head><body>
|
|
348
424
|
<h1>dibs</h1>
|
|
349
425
|
<p>Who is using which thing. Claim one with
|
|
350
|
-
<code>dibs claim <name> --for <purpose></code
|
|
426
|
+
<code>dibs claim <name> --for <purpose></code>.
|
|
427
|
+
New here, or an agent? Read <a href="/agents">/agents</a>.</p>
|
|
351
428
|
<table><thead><tr><th>Name</th><th>Kind</th><th>Claim</th></tr></thead>
|
|
352
429
|
<tbody>
|
|
353
430
|
{rows}
|
|
@@ -14,7 +14,9 @@ and ``dibs list``, ``info``, ``claim`` and ``release`` use it. The client
|
|
|
14
14
|
finds the server with ``--url`` or ``DIBS_URL``, keeps its token per URL
|
|
15
15
|
in ``$XDG_CONFIG_HOME/dibs/client.json`` (``DIBS_TOKEN`` overrides it, for
|
|
16
16
|
runners), and an agent sets ``DIBS_ASSISTED_BY`` (``Agent:model``, as in
|
|
17
|
-
an ``Assisted-by:`` trailer)
|
|
17
|
+
an ``Assisted-by:`` trailer) and ``DIBS_SESSION`` (a tag for this agent
|
|
18
|
+
session) so its claims say which agent made them and other agents of the
|
|
19
|
+
same person leave them alone.
|
|
18
20
|
|
|
19
21
|
Exit status: 0 done, 1 refused because a thing is claimed by someone
|
|
20
22
|
else, 2 anything else (usage, unknown thing, server unreachable).
|
|
@@ -137,6 +139,10 @@ def assisted_by() -> str:
|
|
|
137
139
|
return os.environ.get("DIBS_ASSISTED_BY", "").strip()
|
|
138
140
|
|
|
139
141
|
|
|
142
|
+
def session() -> str:
|
|
143
|
+
return os.environ.get("DIBS_SESSION", "").strip()
|
|
144
|
+
|
|
145
|
+
|
|
140
146
|
def default_key() -> Path:
|
|
141
147
|
for name in _DEFAULT_KEYS:
|
|
142
148
|
path = Path.home() / ".ssh" / name
|
|
@@ -312,6 +318,7 @@ def cmd_claim(args: argparse.Namespace) -> None:
|
|
|
312
318
|
"things": args.things,
|
|
313
319
|
"purpose": args.purpose,
|
|
314
320
|
"assisted_by": assisted_by(),
|
|
321
|
+
"session": session(),
|
|
315
322
|
"expires_in": args.expires,
|
|
316
323
|
"force": args.force,
|
|
317
324
|
}
|
|
@@ -325,6 +332,7 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
325
332
|
body = {
|
|
326
333
|
"things": args.things,
|
|
327
334
|
"assisted_by": assisted_by(),
|
|
335
|
+
"session": session(),
|
|
328
336
|
"mine": args.mine,
|
|
329
337
|
"force": args.force,
|
|
330
338
|
}
|
|
@@ -363,6 +371,37 @@ def cmd_login(args: argparse.Namespace) -> None:
|
|
|
363
371
|
print(f"Logged in as '{answer['user']}'.")
|
|
364
372
|
|
|
365
373
|
|
|
374
|
+
def cmd_whoami(args: argparse.Namespace) -> None:
|
|
375
|
+
"""Who this workstation's dibs claims as: the user from the login,
|
|
376
|
+
and the agent and session from the environment. Fails when nobody
|
|
377
|
+
has logged in, which is how an agent learns setup is missing."""
|
|
378
|
+
url = _url(args)
|
|
379
|
+
me: dict[str, str] = {"user": _request(url, "GET", "/auth/whoami")["user"]}
|
|
380
|
+
if assisted_by():
|
|
381
|
+
me["assisted_by"] = assisted_by()
|
|
382
|
+
if session():
|
|
383
|
+
me["session"] = session()
|
|
384
|
+
if args.format:
|
|
385
|
+
sys.stdout.write(dump(me, args.format))
|
|
386
|
+
return
|
|
387
|
+
line = f"'{me['user']}'"
|
|
388
|
+
if "assisted_by" in me:
|
|
389
|
+
line += f" assisted by '{me['assisted_by']}'"
|
|
390
|
+
if "session" in me:
|
|
391
|
+
line += f" (session {me['session']})"
|
|
392
|
+
print(line)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def cmd_session(args: argparse.Namespace) -> None:
|
|
396
|
+
"""A fresh session tag for an agent to pass as ``DIBS_SESSION`` for
|
|
397
|
+
the rest of its conversation. Random, so two agents of the same
|
|
398
|
+
person never pick the same one; nothing is stored, here or on the
|
|
399
|
+
server, until a claim carries it."""
|
|
400
|
+
import secrets
|
|
401
|
+
|
|
402
|
+
print(f"s-{secrets.token_hex(4)}")
|
|
403
|
+
|
|
404
|
+
|
|
366
405
|
def cmd_logout(args: argparse.Namespace) -> None:
|
|
367
406
|
url = _url(args)
|
|
368
407
|
if token(url):
|
|
@@ -550,13 +589,13 @@ def cmd_admin_revoke(args: argparse.Namespace) -> None:
|
|
|
550
589
|
|
|
551
590
|
# ---- agent skill -----------------------------------------------------------
|
|
552
591
|
|
|
553
|
-
SKILL = Path(__file__).parent / "skill" / "SKILL.md"
|
|
554
|
-
|
|
555
592
|
|
|
556
593
|
def cmd_skill(args: argparse.Namespace) -> None:
|
|
557
594
|
"""Install the Claude Code skill that teaches an agent to claim
|
|
558
595
|
machines before using them, or print it for another agent's setup."""
|
|
559
|
-
|
|
596
|
+
from dibs._skill import skill_text
|
|
597
|
+
|
|
598
|
+
text = skill_text()
|
|
560
599
|
if args.action == "print":
|
|
561
600
|
sys.stdout.write(text)
|
|
562
601
|
return
|
|
@@ -639,7 +678,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
639
678
|
epilog=(
|
|
640
679
|
"The client finds the server via --url or DIBS_URL. Claiming needs a login: "
|
|
641
680
|
"register, have the admin approve you, then login. Agents set "
|
|
642
|
-
"DIBS_ASSISTED_BY; runners may pass a token in DIBS_TOKEN.
|
|
681
|
+
"DIBS_ASSISTED_BY and DIBS_SESSION; runners may pass a token in DIBS_TOKEN. "
|
|
682
|
+
"Exit status: 0 done, "
|
|
643
683
|
"1 claimed by someone else, 2 other errors."
|
|
644
684
|
),
|
|
645
685
|
)
|
|
@@ -696,6 +736,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
696
736
|
p.add_argument("-i", "--key", help="SSH public key file (default: the one you registered with)")
|
|
697
737
|
p.set_defaults(func=cmd_login)
|
|
698
738
|
|
|
739
|
+
p = sub.add_parser("whoami", parents=[data], help="show who your claims are made as")
|
|
740
|
+
p.set_defaults(func=cmd_whoami)
|
|
741
|
+
|
|
742
|
+
p = sub.add_parser("session", help="print a fresh session tag for an agent")
|
|
743
|
+
p.set_defaults(func=cmd_session)
|
|
744
|
+
|
|
699
745
|
p = sub.add_parser("logout", help="drop your token for this server")
|
|
700
746
|
p.set_defaults(func=cmd_logout)
|
|
701
747
|
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
## Once per conversation
|
|
19
|
+
|
|
20
|
+
1. Check that your human has set up this workstation:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
dibs whoami
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
It prints your human's name. If it fails, or `dibs` or `DIBS_URL` is
|
|
27
|
+
missing, stop and tell your human to read `$DIBS_URL/agents`, or the
|
|
28
|
+
dibs README. Never run `dibs register` or `dibs login` yourself: you
|
|
29
|
+
claim with your human's login.
|
|
30
|
+
|
|
31
|
+
1. Get a session tag for this conversation, once, with `dibs session`.
|
|
32
|
+
It prints a random tag such as `s-3f2a9c41`. Do not make one up: two
|
|
33
|
+
agents could pick the same. Pass it, and who you are, on every dibs
|
|
34
|
+
command, inline, so every call agrees:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
DIBS_ASSISTED_BY="Claude Code:<your model id>" DIBS_SESSION=<tag> dibs ...
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Your claims then say which agent made them, and another agent of the
|
|
41
|
+
same person cannot take or release them without `--force`; nor can you
|
|
42
|
+
theirs.
|
|
43
|
+
|
|
44
|
+
## Before you touch a machine
|
|
45
|
+
|
|
46
|
+
1. Look: `dibs list --json`, and read the JSON rather than the text.
|
|
47
|
+
|
|
48
|
+
1. Claim it, saying what for:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
DIBS_ASSISTED_BY=... DIBS_SESSION=... dibs claim <machine> --for "<what you are doing>"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
1. Exit status 1 means someone else holds it, maybe another agent of
|
|
55
|
+
your human's. Do not use the machine and do not pass `--force`. Tell
|
|
56
|
+
your human who holds it (the error says) and ask what to do.
|
|
57
|
+
|
|
58
|
+
Anything that goes through the BMC or affects the whole box needs the
|
|
59
|
+
whole machine: power-cycling, the serial or graphical console, booting
|
|
60
|
+
or reflashing it, firmware, and kernel or driver changes. Claim
|
|
61
|
+
`<machine>` for those.
|
|
62
|
+
|
|
63
|
+
Some machines list parts in `dibs list`, named `<machine>.<part>`. Claim
|
|
64
|
+
a part only when your work stays inside it and cannot disturb the rest
|
|
65
|
+
of the machine; claiming the machine takes all of its parts. Claim
|
|
66
|
+
several things in one command; it is all or nothing.
|
|
67
|
+
|
|
68
|
+
## When you are done
|
|
69
|
+
|
|
70
|
+
Release what you claimed, and also when the work fails, is abandoned, or
|
|
71
|
+
waits on your human for long:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
DIBS_ASSISTED_BY=... DIBS_SESSION=... dibs release --mine
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This releases only this session's claims: not your human's, and not
|
|
78
|
+
other agents'.
|
|
79
|
+
|
|
80
|
+
## Never
|
|
81
|
+
|
|
82
|
+
- Use a machine claimed by someone else, or one you have not claimed.
|
|
83
|
+
- Pass `--force`, or release a claim this session did not make.
|
|
84
|
+
- Register or log in to dibs yourself.
|
|
85
|
+
- Power-cycle, reflash, reboot or use the BMC of a machine when you hold
|
|
86
|
+
only a part of it.
|
|
@@ -67,11 +67,15 @@ def test_conflict_unknown_and_invalid(client: TestClient, tokens) -> None:
|
|
|
67
67
|
assert _claim(client, tokens, "ryzen-01", purpose="").status_code == 422
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
def
|
|
70
|
+
def test_release_mine_is_scoped_to_the_session(client: TestClient, tokens) -> None:
|
|
71
71
|
_claim(client, tokens, "epyc-02")
|
|
72
|
-
r = _claim(client, tokens, "warp", assisted_by=AGENT)
|
|
73
|
-
assert r.json()["things"][0]["sentence"].startswith(
|
|
74
|
-
|
|
72
|
+
r = _claim(client, tokens, "warp", assisted_by=AGENT, session="s1")
|
|
73
|
+
assert r.json()["things"][0]["sentence"].startswith(
|
|
74
|
+
f"'alice' assisted by '{AGENT}' (session s1)"
|
|
75
|
+
)
|
|
76
|
+
assert r.json()["things"][0]["claim"]["session"] == "s1"
|
|
77
|
+
_claim(client, tokens, "ryzen-01", assisted_by=AGENT, session="s2")
|
|
78
|
+
released = _release(client, tokens, mine=True, assisted_by=AGENT, session="s1").json()["things"]
|
|
75
79
|
assert [t["name"] for t in released] == ["warp"]
|
|
76
80
|
assert client.get("/things/epyc-02").json()["claim"]["holder"] == "alice"
|
|
77
81
|
assert _release(client, tokens).status_code == 422
|
|
@@ -176,3 +180,21 @@ def test_parts_over_the_api(client: TestClient, tokens, tmp_path: Path) -> None:
|
|
|
176
180
|
# The login hook on ryzen-01 (by MAC) sees the part's claim.
|
|
177
181
|
hook = client.get("/claim", params={"mac": "aa:bb:cc:dd:ee:60"}).text
|
|
178
182
|
assert hook.startswith("'bob' has claimed 'ryzen-01.bmc'")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_agents_page_is_set_up_for_this_server(client: TestClient, tokens) -> None:
|
|
186
|
+
from dibs._skill import skill_body
|
|
187
|
+
|
|
188
|
+
_claim(client, tokens, "warp")
|
|
189
|
+
r = client.get("/agents")
|
|
190
|
+
assert r.headers["content-type"].startswith("text/plain")
|
|
191
|
+
page = r.text
|
|
192
|
+
assert "echo 'export DIBS_URL=http://testserver' >> ~/.bashrc" in page
|
|
193
|
+
assert "DIBS_URL=http://testserver dibs whoami" in page
|
|
194
|
+
assert "If you are a person" in page and "If you are an agent" in page
|
|
195
|
+
assert "Agents never\nregister" in page
|
|
196
|
+
assert "- 'alice' has claimed 'warp' for 'xnvme' expires in: 'never'" in page
|
|
197
|
+
assert page.endswith(skill_body())
|
|
198
|
+
# The skill's Claude Code frontmatter and licence comment are not for this reader.
|
|
199
|
+
assert "name: dibs" not in page and "SPDX" not in page
|
|
200
|
+
assert '<a href="/agents">' in client.get("/").text
|
|
@@ -285,3 +285,55 @@ def test_skill_ships_in_the_wheel(tmp_path: Path) -> None:
|
|
|
285
285
|
)
|
|
286
286
|
wheel = next(tmp_path.glob("*.whl"))
|
|
287
287
|
assert "dibs/skill/SKILL.md" in zipfile.ZipFile(wheel).namelist()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_skill_body_drops_only_the_preamble() -> None:
|
|
291
|
+
from dibs._skill import skill_body, skill_text
|
|
292
|
+
|
|
293
|
+
body = skill_body()
|
|
294
|
+
assert body.startswith("# Claim lab machines with dibs\n")
|
|
295
|
+
assert skill_text().endswith(body)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_whoami_shows_the_actor(run: Run, monkeypatch: pytest.MonkeyPatch, capsys) -> None:
|
|
299
|
+
assert run("whoami") == 0
|
|
300
|
+
assert capsys.readouterr().out == "'alice'\n"
|
|
301
|
+
monkeypatch.setenv("DIBS_ASSISTED_BY", "Claude Code:claude-opus-5-5")
|
|
302
|
+
monkeypatch.setenv("DIBS_SESSION", "s1")
|
|
303
|
+
assert run("whoami") == 0
|
|
304
|
+
assert (
|
|
305
|
+
capsys.readouterr().out
|
|
306
|
+
== "'alice' assisted by 'Claude Code:claude-opus-5-5' (session s1)\n"
|
|
307
|
+
)
|
|
308
|
+
assert run("whoami", "--json") == 0
|
|
309
|
+
assert json.loads(capsys.readouterr().out) == {
|
|
310
|
+
"user": "alice",
|
|
311
|
+
"assisted_by": "Claude Code:claude-opus-5-5",
|
|
312
|
+
"session": "s1",
|
|
313
|
+
}
|
|
314
|
+
# With nobody logged in it fails, which is how an agent finds out.
|
|
315
|
+
assert run("whoami", who=None) == cli.EXIT_ERROR
|
|
316
|
+
assert "agents use their person's login" in capsys.readouterr().err
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_claims_carry_the_session(run: Run, monkeypatch: pytest.MonkeyPatch, capsys) -> None:
|
|
320
|
+
monkeypatch.setenv("DIBS_ASSISTED_BY", "Claude Code:claude-opus-5-5")
|
|
321
|
+
monkeypatch.setenv("DIBS_SESSION", "s1")
|
|
322
|
+
assert run("claim", "warp", "--for", "x") == 0
|
|
323
|
+
assert "(session s1) has claimed 'warp'" in capsys.readouterr().out
|
|
324
|
+
monkeypatch.setenv("DIBS_SESSION", "s2")
|
|
325
|
+
assert run("claim", "warp", "--for", "y") == cli.EXIT_REFUSED
|
|
326
|
+
assert run("release", "--mine") == 0
|
|
327
|
+
assert capsys.readouterr().out.endswith("nothing to release\n")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_session_tags_are_fresh_and_valid(capsys) -> None:
|
|
331
|
+
from dibs._store import validate_session
|
|
332
|
+
|
|
333
|
+
tags = set()
|
|
334
|
+
for _ in range(20):
|
|
335
|
+
assert cli.main(["session"]) == 0
|
|
336
|
+
tag = capsys.readouterr().out.strip()
|
|
337
|
+
assert validate_session(tag) == tag
|
|
338
|
+
tags.add(tag)
|
|
339
|
+
assert len(tags) == 20
|
|
@@ -29,8 +29,12 @@ def test_sentence_free_claimed_and_assisted(store: Store) -> None:
|
|
|
29
29
|
assert sentence("node-01", c, T0) == (
|
|
30
30
|
"'alice' has claimed 'node-01' for 'xnvme' expires in: 'never'"
|
|
31
31
|
)
|
|
32
|
-
[(_, c)] = store.claim(
|
|
33
|
-
|
|
32
|
+
[(_, c)] = store.claim(
|
|
33
|
+
["node-02"], holder="alice", purpose="x", assisted_by=AGENT, session="s1", now=T0
|
|
34
|
+
)
|
|
35
|
+
assert sentence("node-02", c, T0).startswith(
|
|
36
|
+
f"'alice' assisted by '{AGENT}' (session s1) has claimed 'node-02'"
|
|
37
|
+
)
|
|
34
38
|
|
|
35
39
|
|
|
36
40
|
@pytest.mark.parametrize(
|
|
@@ -66,11 +70,61 @@ def test_claim_is_all_or_nothing(store: Store) -> None:
|
|
|
66
70
|
assert store.get_claim("a") is None
|
|
67
71
|
|
|
68
72
|
|
|
69
|
-
def
|
|
73
|
+
def test_two_agent_sessions_of_one_user_conflict(store: Store) -> None:
|
|
74
|
+
store.claim(["a"], holder="alice", purpose="x", assisted_by=AGENT, session="s1", now=T0)
|
|
75
|
+
with pytest.raises(ClaimConflict) as exc:
|
|
76
|
+
store.claim(["a"], holder="alice", purpose="y", assisted_by=AGENT, session="s2", now=T0)
|
|
77
|
+
assert "(session s1)" in exc.value.sentences[0]
|
|
78
|
+
with pytest.raises(ClaimConflict):
|
|
79
|
+
store.release(["a"], holder="alice", assisted_by=AGENT, session="s2", now=T0)
|
|
80
|
+
# The same session claiming again just updates its claim.
|
|
81
|
+
store.claim(["a"], holder="alice", purpose="z", assisted_by=AGENT, session="s1", now=T0)
|
|
82
|
+
assert store.get_claim("a").purpose == "z" # type: ignore[union-attr]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_an_agent_cannot_take_or_release_its_humans_claim(store: Store) -> None:
|
|
70
86
|
store.claim(["a"], holder="alice", purpose="x", now=T0)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
87
|
+
with pytest.raises(ClaimConflict):
|
|
88
|
+
store.claim(["a"], holder="alice", purpose="y", assisted_by=AGENT, session="s1", now=T0)
|
|
89
|
+
with pytest.raises(ClaimConflict):
|
|
90
|
+
store.release(["a"], holder="alice", assisted_by=AGENT, session="s1", now=T0)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_the_human_may_release_their_agents_claims(store: Store) -> None:
|
|
94
|
+
store.claim(["a"], holder="alice", purpose="x", assisted_by=AGENT, session="s1", now=T0)
|
|
95
|
+
# Claiming over it still needs force, so nothing is overwritten unnoticed...
|
|
96
|
+
with pytest.raises(ClaimConflict):
|
|
97
|
+
store.claim(["a"], holder="alice", purpose="y", now=T0)
|
|
98
|
+
# ...but cleaning up after one's own agent does not.
|
|
99
|
+
assert [c.thing for c in store.release(["a"], holder="alice", now=T0)] == ["a"]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_another_users_human_may_not_release_my_agents_claim(store: Store) -> None:
|
|
103
|
+
store.claim(["a"], holder="alice", purpose="x", assisted_by=AGENT, session="s1", now=T0)
|
|
104
|
+
with pytest.raises(ClaimConflict):
|
|
105
|
+
store.release(["a"], holder="bob", now=T0)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_old_database_gains_sessions(tmp_path: Path) -> None:
|
|
109
|
+
import sqlite3
|
|
110
|
+
|
|
111
|
+
db = tmp_path / "old.db"
|
|
112
|
+
conn = sqlite3.connect(str(db))
|
|
113
|
+
conn.executescript(
|
|
114
|
+
"CREATE TABLE claims (thing TEXT PRIMARY KEY, holder TEXT NOT NULL, "
|
|
115
|
+
"assisted_by TEXT NOT NULL DEFAULT '', purpose TEXT NOT NULL, claimed_at TEXT NOT NULL, "
|
|
116
|
+
"expires_at TEXT NOT NULL DEFAULT '');"
|
|
117
|
+
"INSERT INTO claims (thing, holder, purpose, claimed_at) VALUES ('a', 'alice', 'x', 'y');"
|
|
118
|
+
)
|
|
119
|
+
conn.commit()
|
|
120
|
+
conn.close()
|
|
121
|
+
assert Store(db).get_claim("a").session == "" # type: ignore[union-attr]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytest.mark.parametrize("raw", ["-x", "a b", "x" * 33, "a/b"])
|
|
125
|
+
def test_bad_sessions_are_refused(store: Store, raw: str) -> None:
|
|
126
|
+
with pytest.raises(ValueError):
|
|
127
|
+
store.claim(["a"], holder="alice", purpose="x", session=raw, now=T0)
|
|
74
128
|
|
|
75
129
|
|
|
76
130
|
def test_force_takeover_reports_previous_holder(store: Store) -> None:
|
|
@@ -99,12 +153,13 @@ def test_release_rules(store: Store) -> None:
|
|
|
99
153
|
assert store.get_claim("b") is None
|
|
100
154
|
|
|
101
155
|
|
|
102
|
-
def
|
|
156
|
+
def test_claimed_by_is_scoped_to_the_actor(store: Store) -> None:
|
|
103
157
|
store.claim(["a"], holder="alice", purpose="x", now=T0)
|
|
104
|
-
store.claim(["b"], holder="alice", purpose="y", assisted_by=AGENT, now=T0)
|
|
158
|
+
store.claim(["b"], holder="alice", purpose="y", assisted_by=AGENT, session="s1", now=T0)
|
|
159
|
+
store.claim(["d"], holder="alice", purpose="y", assisted_by=AGENT, session="s2", now=T0)
|
|
105
160
|
store.claim(["c"], holder="alice", purpose="z", expires_in=60, now=T0 - timedelta(hours=1))
|
|
106
161
|
assert [c.thing for c in store.claimed_by("alice", now=T0)] == ["a"]
|
|
107
|
-
assert [c.thing for c in store.claimed_by("alice", AGENT, now=T0)] == ["b"]
|
|
162
|
+
assert [c.thing for c in store.claimed_by("alice", AGENT, "s1", now=T0)] == ["b"]
|
|
108
163
|
|
|
109
164
|
|
|
110
165
|
@pytest.mark.parametrize(
|
|
@@ -1,65 +0,0 @@
|
|
|
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.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|