diff --git a/CHANGELOG.md b/CHANGELOG.md index b3341c3..cc7f5ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,35 @@ emit the previous mode's stale screen or queued output. Reconnects establish the same fresh `GEOMETRY` → `SCREEN` → `DATA`/`EXIT` baseline. +### Atomic exact-id metadata patching + +- `pty metadata patch --id ` reads one merge-style JSON object from + stdin and atomically updates `displayName` and tags under one metadata lock. + It never falls back to display-name lookup, preserves unrelated tags, returns + `{ changed, metadata }`, and suppresses no-op writes and events. +- `patchMetadataById(id, patch)` exposes the same exact-id operation from + `@compoundingtech/pty/client`. Existing rename/tag APIs share the merge engine + while retaining their documented specialized events for compatibility. +- Metadata publication acquires the event lock before the metadata lock, so a + busy event log fails before either file changes. Event appends and retention + rewrites are serialized without a per-record byte-size assumption. +- `pty exec` now carries an opaque generation owner token in session children + and refuses stale same-id replacements. Sessions started by an older build + must be restarted once before they can use `pty exec`. +- The current display-name contract supersedes the permissive limits described + in earlier release notes: values must be nonempty, already trimmed, + single-line, free of Unicode control characters, and at most 160 Unicode + scalar values. Slash and backslash remain valid metadata characters. + +### Storage format + +Effective atomic patches append one `metadata_change` event whose `previous` +and `value` objects contain only changed `displayName` and tag keys. No-op +patches append no event. Lock contention cannot publish only one side of an +effective patch. Metadata remains the authoritative state: a process crash or +underlying I/O failure between its write and event append can omit the +notification because pty does not journal a cross-file transaction. + ### Non-unique display names with unambiguous session resolution - Display names are presentation metadata and no longer need to be unique. diff --git a/README.md b/README.md index 802149d..7a50bdd 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ pty rename my-label # inside a session: add/change its dis pty rename my-label # outside: set displayName on pty rename --show # show current displayName pty rename --clear [ref] # remove displayName +pty metadata patch --id myserver < patch.json # atomically patch displayName/tags by exact id pty list # show active sessions (tags shown by default) 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. Ambiguous display names fail without acting and print the candidate stable ids. Use stable ids in scripts and automation. +For automation that must update presentation metadata without alias fallback, +`pty metadata patch --id ` reads one merge-style JSON object from +stdin and returns `{ changed, metadata }` as JSON: + +```sh +printf '%s' '{"displayName":"Worker","tags":{"role":"worker","old":null}}' \ + | pty metadata patch --id a1b2c3d4 +``` + +`displayName` and individual tag values use strings to set and `null` to clear; +omitted fields and tag keys remain unchanged. The operation holds the session's +metadata lock across one read/merge/atomic-write cycle. It fails if the exact id +is absent, even when a display name has the same text. + ### Remote over fabric `pty list --remote ` 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 `-` cwd = "packages/web" # working directory (default: the manifest's dir) ``` -`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 `-` (or just `` 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. +`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 `-` (or just `` 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. `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. diff --git a/completions/pty.bash b/completions/pty.bash index 24d134c..3c5817a 100644 --- a/completions/pty.bash +++ b/completions/pty.bash @@ -5,7 +5,7 @@ _pty() { COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - 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" + 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" if [[ ${COMP_CWORD} -eq 1 ]]; then if [[ "${cur}" == -* ]]; then @@ -122,6 +122,9 @@ _pty() { COMPREPLY=($(compgen -W "${names}" -- "${cur}")) fi ;; + metadata) + COMPREPLY=($(compgen -W "--id" -- "${cur}")) + ;; up) COMPREPLY=($(compgen -o dirnames -- "${cur}")) ;; diff --git a/completions/pty.fish b/completions/pty.fish index 6f77222..5bd8a67 100644 --- a/completions/pty.fish +++ b/completions/pty.fish @@ -60,6 +60,7 @@ complete -c pty -n __pty_needs_command -a tag -d 'Read / write tags on one sessi complete -c pty -n __pty_needs_command -a tag-multi -d 'Bulk tag ops across sessions' complete -c pty -n __pty_needs_command -a emit -d 'Publish a user.* event' complete -c pty -n __pty_needs_command -a rename -d 'Set / show / clear displayName' +complete -c pty -n __pty_needs_command -a metadata -d 'Atomically patch presentation metadata by stable id' complete -c pty -n __pty_needs_command -a up -d 'Start sessions from pty.toml' complete -c pty -n __pty_needs_command -a down -d 'Stop sessions from pty.toml' 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 complete -c pty -n '__pty_using_command run' -l attach -s a -d 'Create OR attach if id already exists' complete -c pty -n '__pty_using_command run' -l ephemeral -s e -d 'Ephemeral: auto-remove metadata on clean exit' complete -c pty -n '__pty_using_command run' -l id -d 'Pin on-disk id (charset-validated)' -complete -c pty -n '__pty_using_command run' -l name -d 'Display label (any printable, ≤ 500 chars)' +complete -c pty -n '__pty_using_command run' -l name -d 'Display label (trimmed, single-line, ≤ 160 Unicode scalars)' complete -c pty -n '__pty_using_command run' -l no-display-name -d 'Skip the auto-generated label' complete -c pty -n '__pty_using_command run' -l tag -d 'Tag session (k=v, repeatable)' 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' complete -c pty -n '__pty_using_command rename' -l show -d 'Print current displayName' complete -c pty -n '__pty_using_command rename' -l clear -d 'Remove displayName' complete -c pty -n '__pty_using_command rename' -a '(__pty_sessions)' -d 'Session' +complete -c pty -n '__pty_using_command metadata' -l id -d 'Exact stable session id' +complete -c pty -n '__pty_using_command metadata' -x -a 'patch' -d 'Value' complete -c pty -n '__pty_using_command up' -F complete -c pty -n '__pty_using_command down' -F complete -c pty -n '__pty_using_command test' -l t -d 'Run matching tests' diff --git a/completions/pty.zsh b/completions/pty.zsh index 025b5d2..3b842ad 100644 --- a/completions/pty.zsh +++ b/completions/pty.zsh @@ -33,6 +33,7 @@ _pty() { 'tag-multi:Bulk tag ops across sessions' 'emit:Publish a user.* event' 'rename:Set / show / clear displayName' + 'metadata:Atomically patch presentation metadata by stable id' 'up:Start sessions from pty.toml' 'down:Stop sessions from pty.toml' 'test:Run the pty test suite (vitest)' @@ -58,7 +59,7 @@ _pty() { '(a --attach){a,--attach}[Create OR attach if id already exists]' \ '(e --ephemeral){e,--ephemeral}[Ephemeral: auto-remove metadata on clean exit]' \ '--id[Pin on-disk id (charset-validated)]' \ - '--name[Display label (any printable, ≤ 500 chars)]' \ + '--name[Display label (trimmed, single-line, ≤ 160 Unicode scalars)]' \ '--no-display-name[Skip the auto-generated label]' \ '--tag[Tag session (k=v, repeatable)]' \ '--env[Overlay child environment (KEY=VALUE, repeatable)]' \ @@ -173,6 +174,11 @@ _pty() { '--clear[Remove displayName]' \ '1:session:_pty_sessions' ;; + metadata) + _arguments \ + '--id[Exact stable session id]' \ + '1:mode:(patch)' + ;; up) _arguments \ '1:directory:_directories' diff --git a/docs/client.md b/docs/client.md index cadd62a..420da0c 100644 --- a/docs/client.md +++ b/docs/client.md @@ -27,6 +27,36 @@ matches. Resolve once, then pass `session.name` to socket-oriented APIs. Throws if the name is invalid. Names must match `[a-zA-Z0-9._-]` and be at most 255 characters. +### `patchMetadataById(id: string, patch: MetadataPatch): Promise` + +Atomically merge presentation metadata for one exact stable id. This API never +falls back to a matching display name. It holds the session metadata lock across +one read, merge, validation, and atomic write; unrelated tags are preserved and +a no-op returns `changed: false` without writing or emitting an event. + +```typescript +const result = await patchMetadataById("a1b2c3d4", { + displayName: "Worker", + tags: { role: "worker", temporary: null }, +}); + +interface MetadataPatch { + displayName?: string | null; + tags?: Record; +} + +interface MetadataPatchResult { + changed: boolean; + metadata: SessionMetadata; +} +``` + +Strings set values, `null` clears them, and omitted fields or tag keys remain +unchanged. A successful change emits one `metadata_change` event containing +only effective changes as `previous` and `value` snapshots. The existing +`setDisplayName` and `updateTags` APIs retain their specialized event types for +compatibility. + ### `getSessionDir(): string` 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. ### `cleanupAll(name: string): void` -Remove all files for a session (socket, pid, metadata, events, lock). +Remove all files for a session (socket, pid, metadata, and events). Cleanup is +serialized by acquiring the event lock before the metadata/creation lock. It +throws when either lock has a live holder, changes no session files in that +case, and removes only locks acquired by the cleanup call. Dead holders' stale +locks are reclaimed. ### Types @@ -407,6 +441,10 @@ Each extends `EventBase { session: string; type: EventType; ts: string }`. `NotificationEvent` adds `title?`, `body?`, `source?: "osc9" | "osc99" | "osc777"`. `TitleChangeEvent` adds `value: string`. +`MetadataChangeEvent` has type `"metadata_change"` and carries `previous` and +`value` objects. Only the changed `displayName` field and changed tag keys are +present; `null` represents an absent or cleared value. + ## Keys (also available via `@compoundingtech/pty/keys`) These functions are also available as a standalone browser-safe import via `@compoundingtech/pty/keys` (zero dependencies). diff --git a/docs/disk-layout.md b/docs/disk-layout.md index 54814fa..932313f 100644 --- a/docs/disk-layout.md +++ b/docs/disk-layout.md @@ -15,6 +15,7 @@ For non-Node tools that want to read pty's state without paying Node startup. Th | `.sock` | daemon IPC socket (Unix) | 2 | | `.pid` | daemon pid (decimal) | 2 | | `.lock` | creation-race lock | 2 | +| `.events.lock` | event append/retention lock | 2 | | `theme` | last-selected TUI theme | 2 | | `gc.log` | stdout/stderr of `pty gc` when run by launchd/cron (only present after auto-running gc is installed) | 2 | | `.json.tmp..` | atomic-write tmp — readers MUST ignore | n/a | @@ -100,9 +101,15 @@ Envelope: `{ session: string; type: string; ts: string; ...payload }`. Event typ | `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) | | `display_name_change` | `previous: string\|null, value: string\|null` | | `tags_change` | `previous, value` (full snapshots) | +| `metadata_change` | `previous, value` containing only changed `displayName` and tag keys; absent tag values are `null` | | `user.` | `data?, text?` — free-form, via `pty emit` | -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. +All event writers and retention rewrites are serialized by the per-session +event lock. A complete JSONL record is therefore published without relying on +an operating-system write-size limit, and retention cannot discard an append +that races its atomic rewrite. Async writers wait up to five seconds for a live +holder; synchronous writers fail immediately. Lock files are removed on release, +and a dead holder's stale lock is reclaimed by the next writer or cleanup. ## Reading from outside pty @@ -110,4 +117,4 @@ A single line ≤ `PIPE_BUF` (~4 KB) is atomic per POSIX `O_APPEND`. Built-ins a jq -r '.tags["role"] // empty' "$PTY_ROOT/myserver.json" ``` -For live updates, tail `.events.jsonl` via `inotify` / `kqueue`. Subscribe instead of polling — `tags_change` / `display_name_change` / `session_*` fire on every mutation. +For live updates, tail `.events.jsonl` via `inotify` / `kqueue`. Subscribe instead of polling — `metadata_change` / `tags_change` / `display_name_change` / `session_*` fire on every mutation. diff --git a/src/cli.ts b/src/cli.ts index 6a5cefe..68df3f3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ import { pruneOrphanLayoutTags, isGone, cleanupAll, + cleanupAllWhileLocked, cleanupSocket, cleanupOwnedAll, waitForProcessExit, @@ -25,6 +26,8 @@ import { releaseLock, updateTags, setDisplayName, + patchMetadataById, + mutateMetadataUnderLock, allSessionNames, readMetadata, readSessionPid, @@ -37,7 +40,7 @@ import { } from "./sessions.ts"; import { spawnDaemon, resolveCommand } from "./spawn.ts"; import { - EventFollower, EventWriter, EventType, + acquireEventLock, appendEventSyncLocked, EventFollower, EventWriter, EventType, releaseEventLock, readRecentEvents, formatEvent, emitUserEvent, } from "./events.ts"; @@ -89,7 +92,7 @@ Create a session and attach to it (use -d to leave it running in the background) Flags: --id Pin the on-disk id (sock/json filename; charset-validated, ≤ 104-byte sock path) - --name