Skip to content

Commit 504ac73

Browse files
Patch session metadata atomically by stable ID (#142)
* feat: patch session metadata atomically * feat: make session metadata lifecycle-safe * fix: serialize GC and exec lifecycle mutations
1 parent d5fabc3 commit 504ac73

24 files changed

Lines changed: 1855 additions & 327 deletions

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,35 @@
2525
emit the previous mode's stale screen or queued output. Reconnects establish
2626
the same fresh `GEOMETRY``SCREEN``DATA`/`EXIT` baseline.
2727

28+
### Atomic exact-id metadata patching
29+
30+
- `pty metadata patch --id <stable-id>` reads one merge-style JSON object from
31+
stdin and atomically updates `displayName` and tags under one metadata lock.
32+
It never falls back to display-name lookup, preserves unrelated tags, returns
33+
`{ changed, metadata }`, and suppresses no-op writes and events.
34+
- `patchMetadataById(id, patch)` exposes the same exact-id operation from
35+
`@compoundingtech/pty/client`. Existing rename/tag APIs share the merge engine
36+
while retaining their documented specialized events for compatibility.
37+
- Metadata publication acquires the event lock before the metadata lock, so a
38+
busy event log fails before either file changes. Event appends and retention
39+
rewrites are serialized without a per-record byte-size assumption.
40+
- `pty exec` now carries an opaque generation owner token in session children
41+
and refuses stale same-id replacements. Sessions started by an older build
42+
must be restarted once before they can use `pty exec`.
43+
- The current display-name contract supersedes the permissive limits described
44+
in earlier release notes: values must be nonempty, already trimmed,
45+
single-line, free of Unicode control characters, and at most 160 Unicode
46+
scalar values. Slash and backslash remain valid metadata characters.
47+
48+
### Storage format
49+
50+
Effective atomic patches append one `metadata_change` event whose `previous`
51+
and `value` objects contain only changed `displayName` and tag keys. No-op
52+
patches append no event. Lock contention cannot publish only one side of an
53+
effective patch. Metadata remains the authoritative state: a process crash or
54+
underlying I/O failure between its write and event append can omit the
55+
notification because pty does not journal a cross-file transaction.
56+
2857
### Non-unique display names with unambiguous session resolution
2958

3059
- Display names are presentation metadata and no longer need to be unique.

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ pty rename my-label # inside a session: add/change its dis
6363
pty rename <ref> my-label # outside: set displayName on <ref>
6464
pty rename --show <ref> # show current displayName
6565
pty rename --clear [ref] # remove displayName
66+
pty metadata patch --id myserver < patch.json # atomically patch displayName/tags by exact id
6667

6768
pty list # show active sessions (tags shown by default)
6869
pty list --tags # include internal bookkeeping tags (ptyfile*, strategy, etc.)
@@ -127,6 +128,20 @@ exact stable id first, then a display name only when that label has one match.
127128
Ambiguous display names fail without acting and print the candidate stable ids.
128129
Use stable ids in scripts and automation.
129130

131+
For automation that must update presentation metadata without alias fallback,
132+
`pty metadata patch --id <stable-id>` reads one merge-style JSON object from
133+
stdin and returns `{ changed, metadata }` as JSON:
134+
135+
```sh
136+
printf '%s' '{"displayName":"Worker","tags":{"role":"worker","old":null}}' \
137+
| pty metadata patch --id a1b2c3d4
138+
```
139+
140+
`displayName` and individual tag values use strings to set and `null` to clear;
141+
omitted fields and tag keys remain unchanged. The operation holds the session's
142+
metadata lock across one read/merge/atomic-write cycle. It fails if the exact id
143+
is absent, even when a display name has the same text.
144+
130145
### Remote over fabric
131146

132147
`pty list --remote <peer>` lists another machine's sessions over [fabric](https://github.com/compoundingtech/fabric), which hands consumers a plain local Unix socket — pty never touches iroh. The remote machine serves a small control protocol that fabric exposes under the `pty-remote` ALPN. The recommended form is **on-demand**: fabric spawns the handler per dial, pipes the connection to its stdin/stdout, and owns persistence + roaming (no persistent pty daemon):
@@ -271,7 +286,7 @@ display_name = "My Web Server" # override the default `<prefix>-<sessionKey>`
271286
cwd = "packages/web" # working directory (default: the manifest's dir)
272287
```
273288

274-
`id` is validated like a `pty run --id` value (charset, sock-path length, uniqueness); omitted → pty generates a short random id at spawn time. `display_name` is permissive (≤ 500 chars, any printable text); omitted → defaults to `<prefix>-<sessionKey>` (or just `<sessionKey>` if no prefix). The two fields decouple the human label from the kernel-constrained filename — long prefixes that would have blown past `sockaddr_un.sun_path` (~104 bytes) now work because the actual sock filename is just the short id.
289+
`id` is validated like a `pty run --id` value (charset, sock-path length, uniqueness); omitted → pty generates a short random id at spawn time. `display_name` must be nonempty, already trimmed, single-line, free of Unicode control characters, and at most 160 Unicode scalar values; `/` and `\` are allowed because the value is metadata, not a path. Omitted → defaults to `<prefix>-<sessionKey>` (or just `<sessionKey>` if no prefix). The two fields decouple the human label from the kernel-constrained filename — long prefixes that would have blown past `sockaddr_un.sun_path` (~104 bytes) now work because the actual sock filename is just the short id.
275290

276291
`cwd` sets the session's working directory. An absolute path is used as-is; a relative path resolves against the manifest's directory. Omitted → the session runs in the manifest's directory (the default). This decouples where a session runs from where its `pty.toml` lives — so a manifest kept in a subdirectory (e.g. `.convoy/pty.toml`, to keep a repo root pristine) can still run its sessions in the repo root with `cwd = ".."`. The declared `cwd` is honored on the initial `pty up` and preserved across manual and `strategy=permanent` respawns.
277292

completions/pty.bash

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ _pty() {
55
COMPREPLY=()
66
cur="${COMP_WORDS[COMP_CWORD]}"
77
prev="${COMP_WORDS[COMP_CWORD-1]}"
8-
commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag tag-multi emit rename up down test remote-serve"
8+
commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag tag-multi emit rename metadata up down test remote-serve"
99

1010
if [[ ${COMP_CWORD} -eq 1 ]]; then
1111
if [[ "${cur}" == -* ]]; then
@@ -122,6 +122,9 @@ _pty() {
122122
COMPREPLY=($(compgen -W "${names}" -- "${cur}"))
123123
fi
124124
;;
125+
metadata)
126+
COMPREPLY=($(compgen -W "--id" -- "${cur}"))
127+
;;
125128
up)
126129
COMPREPLY=($(compgen -o dirnames -- "${cur}"))
127130
;;

completions/pty.fish

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ complete -c pty -n __pty_needs_command -a tag -d 'Read / write tags on one sessi
6060
complete -c pty -n __pty_needs_command -a tag-multi -d 'Bulk tag ops across sessions'
6161
complete -c pty -n __pty_needs_command -a emit -d 'Publish a user.* event'
6262
complete -c pty -n __pty_needs_command -a rename -d 'Set / show / clear displayName'
63+
complete -c pty -n __pty_needs_command -a metadata -d 'Atomically patch presentation metadata by stable id'
6364
complete -c pty -n __pty_needs_command -a up -d 'Start sessions from pty.toml'
6465
complete -c pty -n __pty_needs_command -a down -d 'Stop sessions from pty.toml'
6566
complete -c pty -n __pty_needs_command -a test -d 'Run the pty test suite (vitest)'
@@ -68,7 +69,7 @@ complete -c pty -n '__pty_using_command run' -l detach -s d -d 'Create in the ba
6869
complete -c pty -n '__pty_using_command run' -l attach -s a -d 'Create OR attach if id already exists'
6970
complete -c pty -n '__pty_using_command run' -l ephemeral -s e -d 'Ephemeral: auto-remove metadata on clean exit'
7071
complete -c pty -n '__pty_using_command run' -l id -d 'Pin on-disk id (charset-validated)'
71-
complete -c pty -n '__pty_using_command run' -l name -d 'Display label (any printable, ≤ 500 chars)'
72+
complete -c pty -n '__pty_using_command run' -l name -d 'Display label (trimmed, single-line, ≤ 160 Unicode scalars)'
7273
complete -c pty -n '__pty_using_command run' -l no-display-name -d 'Skip the auto-generated label'
7374
complete -c pty -n '__pty_using_command run' -l tag -d 'Tag session (k=v, repeatable)'
7475
complete -c pty -n '__pty_using_command run' -l env -d 'Overlay child environment (KEY=VALUE, repeatable)'
@@ -137,6 +138,8 @@ complete -c pty -n '__pty_using_command emit' -a '(__pty_sessions)' -d 'Session'
137138
complete -c pty -n '__pty_using_command rename' -l show -d 'Print current displayName'
138139
complete -c pty -n '__pty_using_command rename' -l clear -d 'Remove displayName'
139140
complete -c pty -n '__pty_using_command rename' -a '(__pty_sessions)' -d 'Session'
141+
complete -c pty -n '__pty_using_command metadata' -l id -d 'Exact stable session id'
142+
complete -c pty -n '__pty_using_command metadata' -x -a 'patch' -d 'Value'
140143
complete -c pty -n '__pty_using_command up' -F
141144
complete -c pty -n '__pty_using_command down' -F
142145
complete -c pty -n '__pty_using_command test' -l t -d 'Run matching tests'

completions/pty.zsh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ _pty() {
3333
'tag-multi:Bulk tag ops across sessions'
3434
'emit:Publish a user.* event'
3535
'rename:Set / show / clear displayName'
36+
'metadata:Atomically patch presentation metadata by stable id'
3637
'up:Start sessions from pty.toml'
3738
'down:Stop sessions from pty.toml'
3839
'test:Run the pty test suite (vitest)'
@@ -58,7 +59,7 @@ _pty() {
5859
'(a --attach){a,--attach}[Create OR attach if id already exists]' \
5960
'(e --ephemeral){e,--ephemeral}[Ephemeral: auto-remove metadata on clean exit]' \
6061
'--id[Pin on-disk id (charset-validated)]' \
61-
'--name[Display label (any printable, ≤ 500 chars)]' \
62+
'--name[Display label (trimmed, single-line, ≤ 160 Unicode scalars)]' \
6263
'--no-display-name[Skip the auto-generated label]' \
6364
'--tag[Tag session (k=v, repeatable)]' \
6465
'--env[Overlay child environment (KEY=VALUE, repeatable)]' \
@@ -173,6 +174,11 @@ _pty() {
173174
'--clear[Remove displayName]' \
174175
'1:session:_pty_sessions'
175176
;;
177+
metadata)
178+
_arguments \
179+
'--id[Exact stable session id]' \
180+
'1:mode:(patch)'
181+
;;
176182
up)
177183
_arguments \
178184
'1:directory:_directories'

docs/client.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,36 @@ matches. Resolve once, then pass `session.name` to socket-oriented APIs.
2727

2828
Throws if the name is invalid. Names must match `[a-zA-Z0-9._-]` and be at most 255 characters.
2929

30+
### `patchMetadataById(id: string, patch: MetadataPatch): Promise<MetadataPatchResult>`
31+
32+
Atomically merge presentation metadata for one exact stable id. This API never
33+
falls back to a matching display name. It holds the session metadata lock across
34+
one read, merge, validation, and atomic write; unrelated tags are preserved and
35+
a no-op returns `changed: false` without writing or emitting an event.
36+
37+
```typescript
38+
const result = await patchMetadataById("a1b2c3d4", {
39+
displayName: "Worker",
40+
tags: { role: "worker", temporary: null },
41+
});
42+
43+
interface MetadataPatch {
44+
displayName?: string | null;
45+
tags?: Record<string, string | null>;
46+
}
47+
48+
interface MetadataPatchResult {
49+
changed: boolean;
50+
metadata: SessionMetadata;
51+
}
52+
```
53+
54+
Strings set values, `null` clears them, and omitted fields or tag keys remain
55+
unchanged. A successful change emits one `metadata_change` event containing
56+
only effective changes as `previous` and `value` snapshots. The existing
57+
`setDisplayName` and `updateTags` APIs retain their specialized event types for
58+
compatibility.
59+
3060
### `getSessionDir(): string`
3161

3262
Returns the session directory path — `$PTY_ROOT` if set (the legacy `$PTY_SESSION_DIR` name is still honored), otherwise `~/.local/state/pty`.
@@ -97,7 +127,11 @@ Remove a session's `.sock` and `.pid` files.
97127

98128
### `cleanupAll(name: string): void`
99129

100-
Remove all files for a session (socket, pid, metadata, events, lock).
130+
Remove all files for a session (socket, pid, metadata, and events). Cleanup is
131+
serialized by acquiring the event lock before the metadata/creation lock. It
132+
throws when either lock has a live holder, changes no session files in that
133+
case, and removes only locks acquired by the cleanup call. Dead holders' stale
134+
locks are reclaimed.
101135

102136
### Types
103137

@@ -407,6 +441,10 @@ Each extends `EventBase { session: string; type: EventType; ts: string }`.
407441
`NotificationEvent` adds `title?`, `body?`, `source?: "osc9" | "osc99" | "osc777"`.
408442
`TitleChangeEvent` adds `value: string`.
409443

444+
`MetadataChangeEvent` has type `"metadata_change"` and carries `previous` and
445+
`value` objects. Only the changed `displayName` field and changed tag keys are
446+
present; `null` represents an absent or cleared value.
447+
410448
## Keys (also available via `@compoundingtech/pty/keys`)
411449

412450
These functions are also available as a standalone browser-safe import via `@compoundingtech/pty/keys` (zero dependencies).

docs/disk-layout.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ For non-Node tools that want to read pty's state without paying Node startup. Th
1515
| `<name>.sock` | daemon IPC socket (Unix) | 2 |
1616
| `<name>.pid` | daemon pid (decimal) | 2 |
1717
| `<name>.lock` | creation-race lock | 2 |
18+
| `<name>.events.lock` | event append/retention lock | 2 |
1819
| `theme` | last-selected TUI theme | 2 |
1920
| `gc.log` | stdout/stderr of `pty gc` when run by launchd/cron (only present after auto-running gc is installed) | 2 |
2021
| `<name>.json.tmp.<pid>.<rand>` | atomic-write tmp — readers MUST ignore | n/a |
@@ -100,14 +101,20 @@ Envelope: `{ session: string; type: string; ts: string; ...payload }`. Event typ
100101
| `session_flapping` | `counter, limit, window` — (`pty gc` flipped a permanent session to `strategy.status=flapping` after N consecutive fast-fail respawns; subsequent ticks skip it) |
101102
| `display_name_change` | `previous: string\|null, value: string\|null` |
102103
| `tags_change` | `previous, value` (full snapshots) |
104+
| `metadata_change` | `previous, value` containing only changed `displayName` and tag keys; absent tag values are `null` |
103105
| `user.<name>` | `data?, text?` — free-form, via `pty emit` |
104106

105-
A single line ≤ `PIPE_BUF` (~4 KB) is atomic per POSIX `O_APPEND`. Built-ins are well under. Keep large `user.*` payloads out of the event stream.
107+
All event writers and retention rewrites are serialized by the per-session
108+
event lock. A complete JSONL record is therefore published without relying on
109+
an operating-system write-size limit, and retention cannot discard an append
110+
that races its atomic rewrite. Async writers wait up to five seconds for a live
111+
holder; synchronous writers fail immediately. Lock files are removed on release,
112+
and a dead holder's stale lock is reclaimed by the next writer or cleanup.
106113

107114
## Reading from outside pty
108115

109116
```sh
110117
jq -r '.tags["role"] // empty' "$PTY_ROOT/myserver.json"
111118
```
112119

113-
For live updates, tail `<name>.events.jsonl` via `inotify` / `kqueue`. Subscribe instead of polling — `tags_change` / `display_name_change` / `session_*` fire on every mutation.
120+
For live updates, tail `<name>.events.jsonl` via `inotify` / `kqueue`. Subscribe instead of polling — `metadata_change` / `tags_change` / `display_name_change` / `session_*` fire on every mutation.

0 commit comments

Comments
 (0)