@vibelord/lanes 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +47 -23
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2753 -202
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +147 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1150 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1318 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +793 -16
  20. package/payload/lanes/docs/DESIGN.md +127 -46
  21. package/payload/lanes/docs/INSTALL.md +175 -18
  22. package/payload/lanes/docs/RUNBOOK.md +968 -76
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +967 -150
  25. package/payload/lanes/holders.lock +5 -0
  26. package/payload/lanes/install/enable-docs.sh +78 -0
  27. package/payload/lanes/install/enable-web.sh +2 -0
  28. package/payload/lanes/install/host-install.sh +197 -7
  29. package/payload/lanes/install/services.py +148 -18
  30. package/payload/lanes/install.sh +68 -7
  31. package/payload/lanes/libexec/agent_state.py +74 -1
  32. package/payload/lanes/libexec/cursor_sync.py +6 -0
  33. package/payload/lanes/libexec/device_sync.py +47 -10
  34. package/payload/lanes/libexec/docs_store.py +995 -0
  35. package/payload/lanes/libexec/docsd.py +2010 -0
  36. package/payload/lanes/libexec/economy_poll.py +1 -0
  37. package/payload/lanes/libexec/enrold.py +41 -22
  38. package/payload/lanes/libexec/keys.py +54 -23
  39. package/payload/lanes/libexec/lanes_lib.py +620 -22
  40. package/payload/lanes/libexec/mdlite.py +575 -0
  41. package/payload/lanes/libexec/members.py +21 -2
  42. package/payload/lanes/libexec/notify.py +169 -29
  43. package/payload/lanes/libexec/registrations.py +216 -18
  44. package/payload/lanes/libexec/session_actions.py +182 -14
  45. package/payload/lanes/libexec/sidebar +11 -3
  46. package/payload/lanes/libexec/sidebar.py +2232 -365
  47. package/payload/lanes/libexec/statusd.py +231 -12
  48. package/payload/lanes/libexec/studio_host.py +8 -1
  49. package/payload/lanes/libexec/tokens.py +148 -35
  50. package/payload/lanes/libexec/tokens.sh +86 -2
  51. package/payload/lanes/libexec/update.py +1044 -0
  52. package/payload/lanes/libexec/uploads.py +538 -0
  53. package/payload/lanes/libexec/users.py +614 -0
  54. package/payload/lanes/libexec/view.sh +34 -6
  55. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  56. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  57. package/payload/lanes/systemd/lanes-gc.service +1 -1
  58. package/payload/lanes/systemd/lanes-update.service +17 -0
  59. package/payload/lanes/systemd/lanes-update.timer +17 -0
  60. package/payload/lanes/web/Caddyfile +24 -1
  61. package/payload/lanes/web/README.md +165 -28
  62. package/payload/lanes/web/browser.js +4 -8
  63. package/payload/lanes/web/docs.css +52 -0
  64. package/payload/lanes/web/docs.js +36 -0
  65. package/payload/lanes/web/mobile.css +17 -17
  66. package/payload/lanes/web/mobile.js +45 -32
  67. package/payload/lanes/web/upload.css +17 -0
  68. package/payload/lanes/web/upload.js +84 -0
  69. package/payload/lanes/workspaces.example/README.md +3 -1
  70. package/payload/lanes/zellij/config.kdl +106 -56
  71. package/payload/manifest.json +6 -3
@@ -0,0 +1,614 @@
1
+ """lanes users — the per-user profile store users/<quote(login)>.json (cockpit v3 R1, LNS-74).
2
+
3
+ One JSON file per person under ~/.local/state/lanes/users/ (directory 0700, files 0600), shared by every device that
4
+ person owns (members.owner_for decides ownership, as devices/<device>.json records it):
5
+
6
+ {"version": 1, "login": "anna@example.com", "name": "Anna Berg",
7
+ "look": {"theme": "light", "color": "256", "glyphs": "ascii"},
8
+ "seen": {"bw-tools--lanes-zellij": 1791000000.25},
9
+ "workspaces": {"order": ["throttle", "bw-tools"], "hidden": []},
10
+ "lanes": {"pins": ["bw-tools--lanes-zellij"], "hidden": []},
11
+ "last_opened": {"lane": "bw-tools--lanes-zellij", "device": "anna-mbp", "at": 1791000000.25},
12
+ "notify": {"quiet_s": 120}, "updated_at": 1791000000.25, "updated_by_device": "anna-mbp"}
13
+
14
+ * The file name is urllib.parse.quote(login, safe='') + '.json' (a login carries '@', and may carry '/'); the
15
+ `login` inside the file is authoritative: a file whose login is not the one its name quotes is nobody's profile.
16
+ A login whose quoted name would not fit a file name (MAX_STEM) has no profile.
17
+ * Times are epoch seconds. `seen` is DM-LANES-CLIENT-SEEN's {lane id: when it was last opened} for the person (the
18
+ newest MAX_SEEN marks); `look` holds only the keys set, with bin/lanes LOOK_KEYS' values (`notify`/`dim` stay
19
+ per-device); `notify` holds `quiet_s` only when set; `last_opened` is null until a lane is opened. Lane ids are
20
+ L.IDENT (at most MAX_LANE_ID characters), workspace ids L.IDENT at most 64, devices members.device_name, `name`
21
+ one printable line (members._text: no control, format, surrogate, private-use or unassigned character).
22
+ * Readers are tolerant and never raise: an invalid entry is dropped on its own; unknown top-level keys (a newer
23
+ additive field) are kept and written back unchanged. A file that is not a JSON object, whose `version` is not 1
24
+ (absent = 1), or whose login does not match is not read (read() is None) and is never overwritten.
25
+ * Writers go through update(login, device, fn): under a flock on users/<stem>.lock waited for at most LOCK_S, the
26
+ stored profile is read, `fn` changes a copy, and the result is MERGED into the stored one: `seen` keeps the
27
+ later mark per lane, `last_opened` the later `at`, and every other field is last-writer-wins by `updated_at`
28
+ (stamped later than the stored one, so the writer's version wins them). A timeout, an unwritable store or an
29
+ unreadable file DROPS the write and returns False — it never raises into a sidebar; the next tick retries. An
30
+ update that changes nothing writes nothing (readers keyed on the file's mtime stay asleep).
31
+ * migrate(login) builds the profile from the person's devices: the look from the most recently attached (each look
32
+ key from the newest device that sets it; an existing profile's keys win), `seen` the union-max of their
33
+ clients/<device>.seen.json; then those devices' look keys leave devices/<device>.json so the profile wins. A key
34
+ a device file's `device_only` list names (`lanes attach --device-only`; device_only()) is that device's own and
35
+ is neither folded nor removed; the other look keys of a device file (unfolded()) are what migrates.
36
+ * Callers (cockpit v3 R2, LNS-75, bin/lanes): `lanes attach`'s theme/color/glyphs on a device with an owner
37
+ (login_for) go through update() and leave that device's file, unless `--device-only` keeps them there (and names
38
+ them in its `device_only` list); `lanes device claim` and `lanes keys adopt --login` / `lanes keys owner` migrate
39
+ the devices they name (with their windows) — a claim the member record agrees with included, which is how a
40
+ device owned since W8 folds the look it recorded before R2 (`lanes user show` names that look and the claim, R3
41
+ review fix DEF-1782); `lanes user list|show` read the store (device_logins, inspect) and write nothing. Since
42
+ cockpit v3 R3 (LNS-76) the sidebar of a device with an owner paints the profile's look beneath the device file's
43
+ `device_only` keys, re-reads the profile when its mtime moves (a stamp field), and writes `seen` + `last_opened`
44
+ through update() when a lane is opened there; `lanes next|prev` write them too. Since cockpit v3 R4 (LNS-77)
45
+ `workspaces{order, hidden}` arranges the view (arrange(): bin/lanes tab_specs for create_session and `lanes
46
+ upgrade-session`, which closes a hidden board's tab; the sidebar's phone owner board). Since R6 (LNS-79) `lanes
47
+ workspaces hide|show|order` and `lanes lane pin|unpin|hide|show` write `workspaces` and `lanes` through update(),
48
+ and `lanes here <device>` reads `last_opened`. Since R7 (LNS-80) the push notifier (notify.py) reads every profile
49
+ (load_all): one written by a desk device within the quiet window is presence, and `notify.quiet_s` is that
50
+ person's own window. `updated_by_device` names the device a change was made AT (a sidebar, `lanes attach`, `lanes
51
+ next|prev`, `lanes here`); the arranging verbs stamp `cli:<command>` and migrate() `cli:migration` instead
52
+ (writers that are no device, so an admin's verb over SSH is never somebody at a desk: W9 review F3).
53
+ """
54
+ import copy
55
+ import fcntl
56
+ import json
57
+ import math
58
+ import os
59
+ import re
60
+ import time
61
+ import urllib.parse
62
+ from pathlib import Path
63
+
64
+ import lanes_lib as L
65
+ import members
66
+
67
+ VERSION = 1
68
+ LOCK_S = 1.0 # seconds update() waits for users/<stem>.lock before it drops the write
69
+ FIELDS = ('version', 'login', 'name', 'look', 'seen', 'workspaces', 'lanes', 'last_opened', 'notify', 'updated_at',
70
+ 'updated_by_device')
71
+ # bin/lanes LOOK_KEYS' theme/color/glyphs, value for value (test_users pins the equality).
72
+ LOOK = {'theme': ('dark', 'light', 'auto'), 'color': ('truecolor', '256', '16', 'none'),
73
+ 'glyphs': ('unicode', 'ascii', 'emoji')}
74
+ MAX_STEM = 200 # '.' + stem + '.json.' + 8 (L.atomic_write's temp name) stays under NAME_MAX 255
75
+ MAX_LANE_ID = 256 # L.TRACE's cap: a longer lane id cannot be traced either
76
+ MAX_SEEN = 1024 # newest marks kept; an older mark only re-shows a Done lane until it is opened
77
+ MAX_LANES = 512 # entries per lanes.pins / lanes.hidden
78
+ MAX_WORKSPACES = 256 # entries per workspaces.order / workspaces.hidden
79
+ MAX_QUIET_S = 86400
80
+ SEEN_SUFFIX = '.seen.json' # clients/<device>.seen.json (DM-LANES-CLIENT-SEEN)
81
+ STAMPS = ('updated_at', 'updated_by_device')
82
+ # A profile writer that is no device (W9 review F3): `cli:<command>` — the CLI's arranging verbs (`cli:workspaces`,
83
+ # `cli:lane`) and migrate() (MIGRATION, for `lanes device claim` / `lanes keys adopt --login` / `lanes keys owner`).
84
+ # A device name (members.device_name, L.IDENT) cannot hold `:`, so a writer is never read as a device: the push
85
+ # notifier's desk presence (notify.presence_marks) counts only a write a device made (writer_device).
86
+ CLI_WRITER = re.compile(r'cli:[a-z][a-z-]{0,31}\Z')
87
+ MIGRATION = 'cli:migration'
88
+
89
+
90
+ def users_dir():
91
+ return L.STATE / 'users'
92
+
93
+
94
+ # ------------------------------------------------------------------------------------------------------ identities
95
+
96
+ def stem(login):
97
+ """The file stem of `login`'s profile: the login percent-quoted with nothing safe. Raises L.LanesError for a login
98
+ members.check_login refuses, or one whose stem is longer than MAX_STEM; never echoes the value."""
99
+ quoted = urllib.parse.quote(members.check_login(login), safe='')
100
+ if len(quoted) > MAX_STEM:
101
+ raise L.LanesError(f'login is too long for a profile file name (quoted, more than {MAX_STEM} characters)')
102
+ return quoted
103
+
104
+
105
+ def login_of(file_stem):
106
+ """The login a file stem names (the inverse of stem() for every stem it makes)."""
107
+ return urllib.parse.unquote(file_stem)
108
+
109
+
110
+ def path(login, directory=None):
111
+ return Path(directory or users_dir()) / f'{stem(login)}.json'
112
+
113
+
114
+ def lock_path(login, directory=None):
115
+ return Path(directory or users_dir()) / f'{stem(login)}.lock'
116
+
117
+
118
+ def check_lane(value):
119
+ """A lane id: L.IDENT, at most MAX_LANE_ID characters. Raises L.LanesError (never echoing the value)."""
120
+ if not isinstance(value, str) or len(value) > MAX_LANE_ID or not L.IDENT.match(value):
121
+ raise L.LanesError(f'a lane id is not an identifier of at most {MAX_LANE_ID} characters')
122
+ return value
123
+
124
+
125
+ def check_workspace(value):
126
+ """A workspace id: L.IDENT, at most 64 characters (members' rule for board ids)."""
127
+ return members._ident(value, 'a workspace id', members.MAX_ID)
128
+
129
+
130
+ def cli_writer(command):
131
+ """The `updated_by_device` a CLI command stamps on a profile it writes: `cli:<command>` (CLI_WRITER)."""
132
+ return check_writer('cli:' + command)
133
+
134
+
135
+ def check_writer(value):
136
+ """A valid `updated_by_device`: a device name (members.device_name) or a CLI writer (CLI_WRITER). Raises
137
+ L.LanesError, never echoing the value."""
138
+ if isinstance(value, str) and CLI_WRITER.match(value):
139
+ return value
140
+ try:
141
+ return members.device_name(value)
142
+ except L.LanesError:
143
+ raise L.LanesError('a profile writer is a device name or cli:<command>') from None
144
+
145
+
146
+ def writer_device(value):
147
+ """The device an `updated_by_device` value names — None for a CLI writer, None, or anything invalid: what the
148
+ push notifier may count as somebody at that device (W9 review F3)."""
149
+ return value if _valid(members.device_name, value) else None
150
+
151
+
152
+ def _valid(check, value):
153
+ try:
154
+ check(value)
155
+ except L.LanesError:
156
+ return False
157
+ return True
158
+
159
+
160
+ def _time(value):
161
+ """An epoch-seconds value a profile may hold: a finite, non-negative int or float (never a bool)."""
162
+ return (isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
163
+ and value >= 0)
164
+
165
+
166
+ # ---------------------------------------------------------------------------------------------------------- shape
167
+
168
+ def empty(login):
169
+ """A profile with nothing set (what a person without a file has), exactly the schema in its order."""
170
+ return {'version': VERSION, 'login': login, 'name': None, 'look': {}, 'seen': {},
171
+ 'workspaces': {'order': [], 'hidden': []}, 'lanes': {'pins': [], 'hidden': []}, 'last_opened': None,
172
+ 'notify': {}, 'updated_at': 0, 'updated_by_device': None}
173
+
174
+
175
+ def _ids(values, check, limit):
176
+ out, kept = [], set()
177
+ for value in values if isinstance(values, list) else ():
178
+ if isinstance(value, str) and value not in kept and _valid(check, value):
179
+ kept.add(value)
180
+ out.append(value)
181
+ return out[:limit]
182
+
183
+
184
+ def _bounded(seen):
185
+ """`seen` as stored: the newest MAX_SEEN marks, keys in order (stable bytes)."""
186
+ if len(seen) > MAX_SEEN:
187
+ seen = dict(sorted(seen.items(), key=lambda item: (-item[1], item[0]))[:MAX_SEEN])
188
+ return dict(sorted(seen.items()))
189
+
190
+
191
+ def _last_opened(value):
192
+ if not isinstance(value, dict):
193
+ return None
194
+ lane, device, at = value.get('lane'), value.get('device'), value.get('at')
195
+ if not (_valid(check_lane, lane) and _valid(members.device_name, device) and _time(at)):
196
+ return None
197
+ return {'lane': lane, 'device': device, 'at': at}
198
+
199
+
200
+ def normalize(document, login=None):
201
+ """`document` as a v1 profile: every valid field in schema order, each invalid entry dropped on its own, unknown
202
+ top-level keys kept after the schema. `login`, when given, is the profile's login whatever the document says.
203
+ None when `document` is not an object or the login is not valid. Never raises."""
204
+ if not isinstance(document, dict):
205
+ return None
206
+ if not _valid(members.check_login, document.get('login') if login is None else login):
207
+ return None
208
+ profile = empty(document.get('login') if login is None else login)
209
+ name = document.get('name')
210
+ if _valid(lambda value: members._text(value, 'name', members.MAX_NAME, blanks=True), name):
211
+ profile['name'] = name
212
+ look = document.get('look')
213
+ if isinstance(look, dict):
214
+ profile['look'] = {key: look[key] for key, allowed in LOOK.items() if isinstance(look.get(key), str)
215
+ and look[key] in allowed}
216
+ seen = document.get('seen')
217
+ if isinstance(seen, dict):
218
+ profile['seen'] = _bounded({lane: at for lane, at in seen.items() if _valid(check_lane, lane) and _time(at)})
219
+ for field, keys, check, limit in (('workspaces', ('order', 'hidden'), check_workspace, MAX_WORKSPACES),
220
+ ('lanes', ('pins', 'hidden'), check_lane, MAX_LANES)):
221
+ value = document.get(field)
222
+ if isinstance(value, dict):
223
+ profile[field] = {key: _ids(value.get(key), check, limit) for key in keys}
224
+ profile['last_opened'] = _last_opened(document.get('last_opened'))
225
+ notify = document.get('notify')
226
+ quiet = notify.get('quiet_s') if isinstance(notify, dict) else None
227
+ if isinstance(quiet, int) and not isinstance(quiet, bool) and 0 <= quiet <= MAX_QUIET_S:
228
+ profile['notify'] = {'quiet_s': quiet}
229
+ if _time(document.get('updated_at')):
230
+ profile['updated_at'] = document['updated_at']
231
+ if _valid(check_writer, document.get('updated_by_device')):
232
+ profile['updated_by_device'] = document['updated_by_device']
233
+ for key, value in document.items():
234
+ if isinstance(key, str) and key not in FIELDS:
235
+ profile[key] = copy.deepcopy(value)
236
+ return profile
237
+
238
+
239
+ def merge(current, incoming):
240
+ """Two copies of one profile as one: `seen` the later mark per lane, `last_opened` the one with the later `at`,
241
+ every other field (identity, look, lists, notify, unknown keys, the stamps) from the copy with the later
242
+ `updated_at` — `incoming` on a tie. Either may be None (the other is returned, normalized). Pure."""
243
+ older, newer = (normalize(current) if current is not None else None,
244
+ normalize(incoming) if incoming is not None else None)
245
+ if older is None or newer is None:
246
+ return newer if older is None else older
247
+ if older['updated_at'] > newer['updated_at']:
248
+ winner = older
249
+ else:
250
+ winner = newer
251
+ merged = copy.deepcopy(winner)
252
+ seen = dict(older['seen'])
253
+ for lane, at in newer['seen'].items():
254
+ seen[lane] = max(at, seen.get(lane, at))
255
+ merged['seen'] = _bounded(seen)
256
+ first, second = older['last_opened'], newer['last_opened']
257
+ merged['last_opened'] = copy.deepcopy(second if second and (not first or second['at'] >= first['at']) else first)
258
+ return merged
259
+
260
+
261
+ def _encode(profile):
262
+ try:
263
+ return (json.dumps(profile, indent=2, ensure_ascii=False) + '\n').encode('utf-8')
264
+ except UnicodeEncodeError: # a lone surrogate in a kept unknown key: escape it rather than fail
265
+ return (json.dumps(profile, indent=2) + '\n').encode('ascii')
266
+
267
+
268
+ def _content(profile):
269
+ return {key: value for key, value in profile.items() if key not in STAMPS}
270
+
271
+
272
+ # --------------------------------------------------------------------------------------------------------- reading
273
+
274
+ def _load(file, login):
275
+ """(profile, None), (None, None) when there is no file, or (None, reason) for a file that exists but is not
276
+ `login`'s v1 profile (the reason never echoes a value)."""
277
+ try:
278
+ data = Path(file).read_bytes()
279
+ except FileNotFoundError:
280
+ return None, None
281
+ except OSError as error:
282
+ return None, error.strerror or 'cannot be read'
283
+ try:
284
+ document = json.loads(data.decode('utf-8'))
285
+ except (UnicodeDecodeError, ValueError):
286
+ return None, 'not JSON'
287
+ if not isinstance(document, dict):
288
+ return None, 'not a JSON object'
289
+ version = document.get('version', VERSION)
290
+ if type(version) is not int or version != VERSION:
291
+ return None, f'not a version {VERSION} profile (another release of lanes wrote it)'
292
+ if document.get('login') != login:
293
+ return None, 'its login is not the one its file name quotes'
294
+ return normalize(document), None
295
+
296
+
297
+ def read(login, directory=None):
298
+ """`login`'s profile, normalized, or None (no file, an invalid login, or a file that is not its v1 profile).
299
+ Never raises."""
300
+ try:
301
+ file = path(login, directory)
302
+ except L.LanesError:
303
+ return None
304
+ return _load(file, login)[0]
305
+
306
+
307
+ def inspect(login, directory=None):
308
+ """(profile, problem) for `lanes user show`: the normalized profile or None, and why a file that exists is not
309
+ read (a reason that never echoes a value) or None. Raises L.LanesError for a login that can have no profile (the
310
+ message never echoes it)."""
311
+ return _load(path(login, directory), login)
312
+
313
+
314
+ def load_all(directory=None):
315
+ """([profiles sorted by file name], [(file name, reason)] for every other users/*.json). Never raises."""
316
+ profiles, problems = [], []
317
+ try:
318
+ files = sorted(Path(directory or users_dir()).glob('*.json'))
319
+ except OSError:
320
+ return profiles, problems
321
+ for file in files:
322
+ file_stem = file.name[:-len('.json')]
323
+ login = login_of(file_stem)
324
+ if not _valid(stem, login) or stem(login) != file_stem:
325
+ problems.append((file.name, 'its name is not a quoted login'))
326
+ continue
327
+ profile, problem = _load(file, login)
328
+ if problem:
329
+ problems.append((file.name, problem))
330
+ elif profile is not None:
331
+ profiles.append(profile)
332
+ return profiles, problems
333
+
334
+
335
+ # --------------------------------------------------------------------------------------------------------- writing
336
+
337
+ def _acquire(lock, timeout):
338
+ """An open descriptor holding an exclusive flock on `lock` (created 0600), or None after `timeout` seconds or on
339
+ any OS error."""
340
+ try:
341
+ fd = os.open(lock, os.O_RDWR | os.O_CREAT | os.O_CLOEXEC, 0o600)
342
+ except OSError:
343
+ return None
344
+ deadline = time.monotonic() + timeout
345
+ while True:
346
+ try:
347
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
348
+ return fd
349
+ except BlockingIOError:
350
+ left = deadline - time.monotonic()
351
+ if left <= 0:
352
+ break
353
+ time.sleep(min(0.02, left))
354
+ except OSError:
355
+ break
356
+ os.close(fd)
357
+ return None
358
+
359
+
360
+ def _stamp(now, current):
361
+ """The new `updated_at`: now, but always later than the stored one (a clock that stepped back included)."""
362
+ now = time.time() if now is None else now
363
+ return round(max(now, (current['updated_at'] if current else 0) + 0.001), 3)
364
+
365
+
366
+ def update(login, device, fn, now=None, directory=None, timeout=LOCK_S):
367
+ """Change `login`'s profile: `fn` gets a copy of the stored profile (an empty one when there is none) and changes
368
+ it in place or returns the new one; the result, stamped `updated_at` (now, later than the stored one) and
369
+ `updated_by_device` = `device` — the device the change was made AT (a sidebar, `lanes attach`, `lanes
370
+ next|prev`, `lanes here`), a CLI writer `cli:<command>` (cli_writer, MIGRATION: no device, W9 review F3), or None
371
+ for a caller that names none — is merged into the stored profile (merge()) and written atomically (0600) under the
372
+ lock. -> True when the profile holds the change (an update that changes nothing writes nothing), False when the
373
+ write was DROPPED: the lock was not free within `timeout` seconds (`fn` never ran), the login or the writer is not
374
+ valid (check_writer), the store cannot be written, or the file
375
+ exists but is not this login's v1 profile (never overwritten). Never raises for those; an exception `fn` raises
376
+ (the caller's own refusal) propagates and nothing is written."""
377
+ try:
378
+ file, lock = path(login, directory), lock_path(login, directory)
379
+ if device is not None:
380
+ check_writer(device)
381
+ except L.LanesError:
382
+ return False
383
+ try:
384
+ members._private_dir(file.parent)
385
+ except OSError:
386
+ return False
387
+ fd = _acquire(lock, timeout)
388
+ if fd is None:
389
+ return False
390
+ try:
391
+ current, problem = _load(file, login)
392
+ if problem:
393
+ return False
394
+ working = copy.deepcopy(current) if current is not None else empty(login)
395
+ result = fn(working)
396
+ incoming = normalize(working if result is None else result, login)
397
+ if incoming is None:
398
+ return False
399
+ incoming['updated_at'], incoming['updated_by_device'] = _stamp(now, current), device
400
+ merged = merge(current, incoming)
401
+ if current is not None and _content(merged) == _content(current):
402
+ return True
403
+ L.atomic_write(file, _encode(merged), mode=0o600)
404
+ return True
405
+ except OSError:
406
+ return False
407
+ finally:
408
+ fcntl.flock(fd, fcntl.LOCK_UN)
409
+ os.close(fd)
410
+
411
+
412
+ # ------------------------------------------------------------------------------------------------------- migration
413
+
414
+ def _names(directory, suffix):
415
+ try:
416
+ return {name[:-len(suffix)] for name in os.listdir(directory) if name.endswith(suffix)}
417
+ except OSError:
418
+ return set()
419
+
420
+
421
+ def _attached(clients, names):
422
+ """{device: the newest mtime of its clients/<device>.<client>.pid} — the attach marks `lanes attach` writes. A
423
+ device name may carry dots (L.IDENT), so each pid file belongs to the LONGEST of `names` it starts with."""
424
+ newest = {}
425
+ try:
426
+ entries = os.listdir(clients)
427
+ except OSError:
428
+ return newest
429
+ for entry in entries:
430
+ if not entry.endswith('.pid'):
431
+ continue
432
+ device = max((name for name in names if entry.startswith(name + '.') and len(entry) > len(name) + 5),
433
+ key=len, default=None)
434
+ if device is None:
435
+ continue
436
+ try:
437
+ mtime = os.stat(os.path.join(clients, entry)).st_mtime
438
+ except OSError:
439
+ continue
440
+ newest[device] = max(mtime, newest.get(device, mtime))
441
+ return newest
442
+
443
+
444
+ def owned_devices(login):
445
+ """[{device, meta, owner, attached}] for every device `login` owns — a devices/<device>.json view, or a device
446
+ with a clients/<device>.seen.json left — oldest attach first. The owner is members.owner_for over the device and
447
+ its recorded base (a window such as mac-2 is the mac's), as create_session derives it; `attached` is the newest
448
+ clients/<device>.*.pid mtime, else the view's created_at, else 0. Never raises."""
449
+ devices, clients = L.STATE / 'devices', L.STATE / 'clients'
450
+ names = {name for name in _names(devices, '.json') | _names(clients, SEEN_SUFFIX)
451
+ if _valid(members.device_name, name)}
452
+ attached = _attached(clients, names)
453
+ found = []
454
+ for name in names:
455
+ meta = L.read_json(devices / f'{name}.json')
456
+ meta = meta if isinstance(meta, dict) else {}
457
+ owner = members.owner_for((name, meta.get('base')), meta.get('owner'))
458
+ if owner and owner['login'] == login:
459
+ created = meta.get('created_at')
460
+ found.append({'device': name, 'meta': meta, 'owner': owner,
461
+ 'attached': attached.get(name, created if _time(created) else 0)})
462
+ return sorted(found, key=lambda found_device: (found_device['attached'], found_device['device']))
463
+
464
+
465
+ def owner_of(device, meta=None, members_dir=None):
466
+ """The normalized owner {login, name, email} `device` follows, or None: members.owner_for over the device and the
467
+ base its devices/<device>.json records, the recorded owner kept while no member record speaks
468
+ (DM-LANES-DEVICE-OWNER) — exactly bin/lanes device_owner. `meta` is that file's document when the caller has it;
469
+ `members_dir` the members/ directory to consult (default members.members_dir(): the sidebar names the one beside
470
+ its own state). Never raises."""
471
+ if not _valid(members.device_name, device):
472
+ return None
473
+ if meta is None:
474
+ meta = L.read_json(L.STATE / 'devices' / f'{device}.json')
475
+ meta = meta if isinstance(meta, dict) else {}
476
+ return members.owner_for((device, meta.get('base')), meta.get('owner'), directory=members_dir)
477
+
478
+
479
+ def login_for(device, meta=None, members_dir=None):
480
+ """The login whose profile `device` follows (cockpit v3 R2, LNS-75): owner_of's login, None for a device nobody
481
+ owns — which keeps its look and seen marks to itself. Callers: bin/lanes profile_for (`lanes attach`, `lanes
482
+ next|prev`) and the sidebar (cockpit v3 R3, LNS-76), so the profile a look is written to is the one painted. Never
483
+ raises."""
484
+ owner = owner_of(device, meta, members_dir)
485
+ return owner['login'] if owner else None
486
+
487
+
488
+ def device_logins():
489
+ """{device: login or None} for every device lanes knows of — a devices/<device>.json view, a members/<device>.json
490
+ record, a clients/<device>.seen.json — through login_for. What `lanes user list|show` group by. Never raises."""
491
+ names = (_names(L.STATE / 'devices', '.json') | _names(members.members_dir(), '.json')
492
+ | _names(L.STATE / 'clients', SEEN_SUFFIX))
493
+ return {name: login_for(name) for name in sorted(names) if _valid(members.device_name, name)}
494
+
495
+
496
+ # ------------------------------------------------------------------------------------- the person's arrangement
497
+
498
+ def arrange(boards, profile):
499
+ """The workspace boards a person sees, in their order (cockpit v3 R4, LNS-77): `boards` (registry.json's
500
+ `workspaces`, catalog order) without the ids the profile's `workspaces.hidden` names, ordered by its
501
+ `workspaces.order` — the ids it names first, in its order (an id no board has is ignored), every board it does not
502
+ name after them in catalog order. An empty visible set is refused: a hidden list that would leave no board hides
503
+ none (no view, sidebar or phone is ever left without a board; the order still applies). `profile` None, or one
504
+ without `workspaces`, is the catalog unchanged. A new list; `boards` is not modified. Pure; never raises.
505
+ Callers: bin/lanes tab_specs (the tabs a view has, `lanes upgrade-session` closing the hidden ones) and the
506
+ sidebar (the phone's owner board, the first one shown)."""
507
+ spaces = profile.get('workspaces') if isinstance(profile, dict) else None
508
+ spaces = spaces if isinstance(spaces, dict) else {}
509
+ order, hidden = (spaces.get(key) if isinstance(spaces.get(key), list) else [] for key in ('order', 'hidden'))
510
+ rank = {}
511
+ for ident in order:
512
+ if isinstance(ident, str):
513
+ rank.setdefault(ident, len(rank))
514
+ hidden = {ident for ident in hidden if isinstance(ident, str)}
515
+ shown = [board for board in boards if board.get('id') not in hidden] or list(boards)
516
+ keyed = sorted(enumerate(shown), key=lambda item: (0, rank[item[1].get('id')], item[0])
517
+ if item[1].get('id') in rank else (1, 0, item[0]))
518
+ return [board for _index, board in keyed]
519
+
520
+
521
+ # ------------------------------------------------------------------------------------------- a device file's own look
522
+
523
+ def device_only(meta):
524
+ """{key: value} of the look devices/<device>.json keeps for that device alone: each LOOK key its `device_only` list
525
+ names (`lanes attach --device-only` writes the list, cockpit v3 R3 review fix, DEF-1782) and holds a valid value
526
+ for, in LOOK order. Only these win over the owner's profile on that device (sidebar.layered_look). Anything that
527
+ is not such a list marks nothing. Pure; never raises."""
528
+ marked = meta.get('device_only') if isinstance(meta, dict) else None
529
+ if not isinstance(marked, list):
530
+ return {}
531
+ return {key: meta[key] for key, allowed in LOOK.items()
532
+ if key in marked and isinstance(meta.get(key), str) and meta[key] in allowed}
533
+
534
+
535
+ def unfolded(meta):
536
+ """{key: value} of the device file's OTHER valid LOOK keys: a look recorded on the device before it had an owner,
537
+ or by a release before R2 gave owners a profile (whose record_look wrote every flag to the device file and marked
538
+ none). On a device with an owner it is painted only where the profile sets none (sidebar.layered_look), `lanes user
539
+ show` names it, and it is what migrate folds into the profile (`lanes device claim`, also when the member record
540
+ already names that login; `keys adopt --login`; `keys owner`). Never folded behind anyone's back: a device handed
541
+ to another person still holds its previous owner's look. Pure; never raises."""
542
+ if not isinstance(meta, dict):
543
+ return {}
544
+ own = device_only(meta)
545
+ return {key: meta[key] for key, allowed in LOOK.items()
546
+ if key not in own and isinstance(meta.get(key), str) and meta[key] in allowed}
547
+
548
+
549
+ def mark_device_only(meta, keys):
550
+ """A copy of devices/<device>.json `meta` whose `device_only` list names `keys` (LOOK's keys among them, in LOOK
551
+ order), or without the list when none is left — so a device that never used --device-only keeps its bytes."""
552
+ marked = [key for key in LOOK if key in set(keys)]
553
+ updated = dict(meta)
554
+ if marked:
555
+ updated['device_only'] = marked
556
+ else:
557
+ updated.pop('device_only', None)
558
+ return updated
559
+
560
+
561
+ def _drop_look(device):
562
+ """Remove the unfolded LOOK keys from an existing devices/<device>.json (bin/lanes' own encoding) — never a
563
+ --device-only one or its mark (an invalid value goes, and its mark with it); never creates the file."""
564
+ file = L.STATE / 'devices' / f'{device}.json'
565
+ meta = L.read_json(file)
566
+ if not isinstance(meta, dict):
567
+ return
568
+ own = device_only(meta)
569
+ updated = {key: value for key, value in meta.items() if key not in LOOK or key in own}
570
+ if 'device_only' in meta:
571
+ updated = mark_device_only(updated, own)
572
+ if updated == meta:
573
+ return
574
+ try:
575
+ L.atomic_write(file, json.dumps(updated) + '\n')
576
+ except OSError:
577
+ pass
578
+
579
+
580
+ def migrate(login, devices=None, now=None, timeout=LOCK_S):
581
+ """Build `login`'s profile from the devices it owns (owned_devices; only those named in `devices` when given):
582
+ each look key from the most recently attached device that sets it, unless the profile already has that key;
583
+ `seen` the union-max of the devices' clients/<device>.seen.json and the profile's own; `name` from the member
584
+ record when the profile has none. Written through update() as MIGRATION (`cli:migration`: the fold is a CLI act —
585
+ a claim or an adoption, often over SSH — never somebody at one of those devices, W9 review F3); only after it
586
+ landed do those devices' look keys (theme, color, glyphs — never notify/dim) leave devices/<device>.json, so the
587
+ profile wins. A key the device file's `device_only` list marks (`lanes attach --device-only`) is the device's own
588
+ choice: it is neither folded nor removed (unfolded() is what moves). -> the stored profile (an empty one for a
589
+ login that owns no device), or None when the write was dropped (the lock, an unreadable profile, an invalid login:
590
+ no device file is touched then). Never raises."""
591
+ if not _valid(members.check_login, login):
592
+ return None
593
+ wanted = None if devices is None else {devices} if isinstance(devices, str) else set(devices)
594
+ owned = [found for found in owned_devices(login) if wanted is None or found['device'] in wanted]
595
+ look, seen = {}, {}
596
+ for found in owned: # oldest attach first: the newest device's value wins per key
597
+ look.update(unfolded(found['meta']))
598
+ marks = L.clean_seen(L.read_json(L.STATE / 'clients' / f"{found['device']}{SEEN_SUFFIX}"))
599
+ for lane, at in marks.items():
600
+ seen[lane] = max(at, seen.get(lane, at))
601
+ name = next((found['owner']['name'] for found in reversed(owned) if found['owner'].get('name')), None)
602
+
603
+ def fill(profile):
604
+ profile['look'] = {**look, **profile['look']}
605
+ for lane, at in seen.items():
606
+ profile['seen'][lane] = max(at, profile['seen'].get(lane, at))
607
+ if profile['name'] is None and name:
608
+ profile['name'] = name
609
+
610
+ if not update(login, MIGRATION, fill, now=now, timeout=timeout):
611
+ return None
612
+ for found in owned:
613
+ _drop_look(found['device'])
614
+ return read(login)