diff --git a/CHANGELOG.md b/CHANGELOG.md index c325c37..402748d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 0.12.0 — reboot cutover + +Reboot-moment release: pty relocates permanent-session respawn and the fast-fail crash-loop cap out to convoy. `pty gc` becomes clean-only; `pty-daemon` is spawned by the host rather than pty; `pty up` / `pty down` move to `convoy up` / `convoy down`. See `notes/lean-pty-core-supervision-spec.md` for the full contract. + +### BREAKING — CLI surface + +- **Removed `pty up` and `pty down`.** Manifest processing is now `convoy up` / `convoy down`. `pty.toml` stays as the manifest file name; convoy reads it verbatim. `readPtyFile` + `commandWithEnvExports` remain exported on `@myobie/pty/client` so convoy shares the parser without vendoring. +- **Removed `pty gc --fast-fail-window` and `--fast-fail-limit`.** The fast-fail respawn cap moved to convoy's reconcile loop. Per-session `strategy.fast-fail-window` / `strategy.fast-fail-limit` tags still work but are now read by convoy, not pty. +- **`pty gc` is clean-only.** The reconciliation pass keeps STEP 1 (orphan-kill on `parent=` tag), STEP 1.5 (abandoned-reap: cwd-gone; opt-in idle via `--idle-days` or `strategy.idle-days` tag), and STEP 3 (sweep exited non-permanent metadata). STEP 2 (permanent respawn) is gone. Permanent sessions that are gone stay in-place on disk for convoy's next reconcile tick to notice. Never spawns anything. + +### BREAKING — API surface + +- **`GcResult` trimmed.** `respawned`, `respawnFailed`, `flapped`, and `flappingSkipped` fields removed. The surviving shape is `{ removed, killedOrphanChildren, abandoned }`. +- **`sessions.ts:respawnPermanent` deleted.** The helper's role (respawn a `strategy=permanent` session, re-read pty.toml, thread bookkeeping tags) is now convoy's. Convoy either `execFile`'s `pty run -d` (recommended, per Nathan's Q2) or imports `spawnDaemon` from `@myobie/pty/client` directly. +- **`sessions.ts:classifyFlapping` deleted.** The classifier's role (fast-fail counter + command-hash divergence reset + flapping-status flip) is now convoy's. `commandFingerprint` stays exported so convoy computes byte-identical hashes; see spec §8.1 wire-format freeze. +- **`[flapping]` badge removed from `strategyMarker` / `pty list`.** Convoy renders its own list view if desired; pty stays neutral on the status. +- **`SessionFlappingEvent` interface + `EventType.SESSION_FLAPPING` stay exported.** Convoy imports both from `@myobie/pty/client` and emits the event via `appendEventSync`. The event payload shape is frozen (spec §8.1): `{ session, type: "session_flapping", ts, counter, limit, window }`. Any change requires a joint pty ⇄ convoy version bump. + +### Non-breaking additions (from the pre-cutover branch) + +- **Exported from `@myobie/pty/client`**: `commandFingerprint`, `DEFAULT_FAST_FAIL_WINDOW_SEC`, `DEFAULT_FAST_FAIL_LIMIT` (wire-format primitives convoy needs); `readPtyFile`, `commandWithEnvExports` (parser convoy shares). + +### Tests + +- Deleted: `tests/gc-flapping.test.ts`, `tests/gc-permanent.test.ts`, `tests/up-down.test.ts`, `tests/up-name-decouple.test.ts` — all exercised behavior that moved out of pty. +- Extracted `tests/pty-root-length-backstop.test.ts` from the previous `tests/gc-flap-clear-badge-root-len.test.ts`, keeping only the PTY_ROOT length backstop cases. +- `tests/gc-abandoned.test.ts` and `tests/gc-parent-child.test.ts` updated to assert the post-reboot behavior (exited permanents are left in-place; orphan-kill still removes children including permanent ones). + +### Migration + +- Reboot-only. This release is not incremental — it changes ownership of respawn. Bringing 0.11.0 pty online alongside a convoy that expects 0.12.0 pty (or vice versa) will leave permanent sessions with no respawn owner. Sequencing on Nathan's machine (or Johannes's): quiesce network → install pty 0.12.0 + convoy → run `convoy up`. + ## 0.11.0 ### `@myobie/pty/tui` — `text()` accepts an object form `{ fg, bold, ... }` diff --git a/README.md b/README.md index d91f0ab..326ac43 100644 --- a/README.md +++ b/README.md @@ -104,22 +104,19 @@ pty emit myserver user.note --text "checkpoint reached" # with a text payloa pty restart myserver # restart an exited session pty kill myserver # terminate a running session pty rm myserver # remove an exited session's metadata -pty gc # reconcile sessions: kill orphan children, respawn permanents, sweep exited +pty gc # clean-only reconcile: orphan-kill, abandoned-reap, sweep exited pty gc --dry-run # preview what gc would do without changing anything +pty gc --idle-days N # also reap permanent sessions with no attach in N days pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.myobie.pty.gc.plist # install macOS auto-gc pty tag myserver role=web env=prod # set one or more tags on a session pty tag myserver --rm role --rm env # remove one or more tags pty tag-multi --filter-tag role=web env=prod # bulk write across matching sessions pty tag-multi --all --json # bulk read tags across every session pty tag-multi --all --yes audit=today # write to every session (--yes required) - -pty up # start all sessions from ./pty.toml -pty up ./backend # start sessions from ./backend/pty.toml -pty up claude dev # start specific sessions from ./pty.toml -pty down # stop all sessions from ./pty.toml -pty down claude # stop specific sessions ``` +Manifest-processing (`pty up` / `pty down`) has moved to convoy. `pty.toml` is now read by `convoy up`; pty's public API keeps `readPtyFile` + `commandWithEnvExports` exported on `@myobie/pty/client` so convoy shares the parser without vendoring. See `notes/lean-pty-core-supervision-spec.md`. + ### Nesting Prevention If you run `pty run` inside an existing pty session, pty detects the nesting via the `PTY_SESSION` environment variable and runs the command directly instead of creating a session-inside-a-session. @@ -186,7 +183,7 @@ command = "bin/serve" tags = { role = "server" } ``` -Run `pty up` in the project directory (or `pty up /path/to/project`) to start all sessions. Run `pty down` to stop them. You can also start specific sessions: `pty up dev serve`. +`pty.toml` manifest processing (start / stop / reconcile) is owned by convoy — run `convoy up` in the project directory to start the declared sessions. Convoy reads the same file verbatim; pty's public API exposes `readPtyFile` and `commandWithEnvExports` on `@myobie/pty/client` so convoy shares the parser. For ad-hoc single sessions, `pty run -d -- ` still creates a background session directly. Each session also supports two optional fields: @@ -210,20 +207,18 @@ PORT = "8080" LOG_LEVEL = "debug" ``` -The values are exported into the session's shell before the command runs — `pty up` wraps every toml-managed session in `/bin/sh -c` so the `export K='V'; …` prefix is honored. They take effect on the next `pty up` after the session has stopped — restarting a still-running session via `pty restart` reuses the existing spawn args, so `pty kill ` followed by `pty up` is the way to pick up a changed env block on an already-running session. +The values are exported into the session's shell before the command runs — convoy's `up` wraps every toml-managed session in `/bin/sh -c` so the `export K='V'; …` prefix is honored. They take effect on the next `convoy up` after the session has stopped. Restarting a still-running session via `pty restart` reuses the existing spawn args, so `pty kill ` (convoy respawns on its next reconcile tick) is the way to pick up a changed env block on an already-running session. ### Permanent sessions -Tag a session with `strategy=permanent` and `pty gc` will respawn it whenever its daemon exits or vanishes: +Tag a session with `strategy=permanent` and convoy's reconcile loop will respawn it whenever its daemon exits or vanishes. `pty gc` is clean-only: it kills orphan children (`parent=` tag), reaps abandoned permanents (cwd-gone; opt-in idle), and sweeps exited non-permanent metadata — never respawns. ```sh pty tag myserver strategy=permanent -# After myserver exits — manually or by crash — the next `pty gc` run -# brings it back. No backoff, no retry budget; the cron interval below -# is the rate limit. Sessions managed by pty.toml re-read the toml on -# respawn so command/env edits take effect immediately. -pty gc +# After myserver exits — manually or by crash — convoy's next reconcile +# tick respawns it (default 30 s). Sessions managed by pty.toml re-read +# the toml on respawn so command/env edits take effect immediately. ``` From `pty.toml`: @@ -234,26 +229,9 @@ command = "bin/serve" tags = { strategy = "permanent" } ``` -Restart is stateless — every `pty gc` invocation re-derives intent from on-disk metadata. There's no in-memory restart counter, no `[failed]` state, no persisted bookkeeping. If a session's binary isn't reachable (volume not mounted, broken symlink), `pty gc` reports `Respawn failed:` and the next tick tries again. - -**Fast-fail cap** — a permanent session whose leaf exits within `strategy.fast-fail-window` seconds of its previous `pty gc` respawn counts as a fast fail. After `strategy.fast-fail-limit` consecutive fast fails, `pty gc` writes `strategy.status=flapping` on the session, emits a `session_flapping` event, and stops respawning it. Subsequent gc ticks print `Skipped (flapping): ` and take no action. Defaults: 60 s window, 3 consecutive fast fails. - -A flagged session shows `[flapping]` (red) in `pty list` in place of `[permanent]` — the operator's expectation has changed, so the badge reflects that. - -Reset a flagged session with one of: - -- `pty restart ` or `pty up` — the manual respawn drops all fast-fail bookkeeping (`strategy.status`, `strategy.consecutive-fast-fails`, `strategy.last-respawn-at`, `strategy.command-hash`), treating restart as an operator "please try again" signal. -- `pty tag --rm strategy.status` — surgical reset that clears only the mark, leaving the counter intact for observability. -- Edit the session's `pty.toml` command — the classifier notices the SHA-256 fingerprint change and auto-resets the counter and mark on the next gc tick. - -Per-session overrides tune the cap without editing gc's globals: - -```sh -pty tag myserver strategy.fast-fail-window=120 # allow 2min of runtime before "fast" -pty tag myserver strategy.fast-fail-limit=5 # tolerate 5 fast fails before flapping -``` +The reboot moment relocated respawn + the fast-fail crash-loop cap from `pty gc` to convoy. Convoy owns the classifier (fast-fail window + counter + flapping mark + command-hash reset); pty owns the wire-format for the tags convoy writes (`strategy.consecutive-fast-fails`, `strategy.last-respawn-at`, `strategy.command-hash`, `strategy.status`). See `notes/lean-pty-core-supervision-spec.md` §5 + §8.1 for the full contract. -CLI globals mirror the per-session tags (`--fast-fail-window=N`, `--fast-fail-limit=N`); the per-session tag wins when both are set. +The **operator-restart path** post-reboot: `pty kill ` → convoy's reconcile tick sees the session gone → respawns via `pty run -d`. A manual kill on a long-lived agent doesn't count as a fast fail (it lived past the window). `pty restart` still works for a one-verb "kill and respawn now" that also strips convoy's bookkeeping tags to give the restart a clean slate. ### Parent-child sessions @@ -265,7 +243,7 @@ pty run -d --name webserver-tail --tag parent=webserver -- tail -f log/web.log # If `webserver` dies, the next `pty gc` SIGTERMs `webserver-tail`. ``` -What triggers the kill: the parent's metadata file is gone OR the parent's pid file is gone OR the parent's process isn't alive. What doesn't: the parent's exit code, the parent's `exitedAt` timestamp. Combinator with `strategy=permanent` is well-defined — orphan-kill wins (the child is removed, not respawned). +What triggers the kill: the parent's metadata file is gone OR the parent's pid file is gone OR the parent's process isn't alive. What doesn't: the parent's exit code, the parent's `exitedAt` timestamp. Combinator with `strategy=permanent` is well-defined — orphan-kill wins (child removed by `pty gc`; convoy has nothing to respawn on the next tick). Cycles (A→B, B→A) resolve deterministically by name-sorted iteration: whichever name sorts first dies first on the tick where both parents are gone; the loser dies the same tick because its parent (the just-killed winner) is also dead. No cycle detection needed. diff --git a/completions/pty.bash b/completions/pty.bash index 6bde166..f72d2b7 100644 --- a/completions/pty.bash +++ b/completions/pty.bash @@ -7,7 +7,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 help" + commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag tag-multi emit rename test help" # Complete subcommand (first positional). if [[ ${COMP_CWORD} -eq 1 ]]; then @@ -86,7 +86,7 @@ _pty() { ;; gc) if [[ "${cur}" == -* ]]; then - COMPREPLY=($(compgen -W "-n --dry-run --idle-days --fast-fail-window --fast-fail-limit --print-launchd-plist --interval" -- "${cur}")) + COMPREPLY=($(compgen -W "-n --dry-run --idle-days --print-launchd-plist --interval" -- "${cur}")) fi ;; tag) @@ -110,11 +110,6 @@ _pty() { COMPREPLY=($(compgen -W "${names}" -- "${cur}")) fi ;; - up|down) - # Complete directories (containing pty.toml) or session names from an - # already-loaded toml. Directories are more common; keep it simple. - COMPREPLY=($(compgen -o dirnames -- "${cur}")) - ;; exec) # After --, fall through to default (command + args) completion. COMPREPLY=($(compgen -o default -- "${cur}")) diff --git a/completions/pty.fish b/completions/pty.fish index b957ce9..a269523 100644 --- a/completions/pty.fish +++ b/completions/pty.fish @@ -62,8 +62,6 @@ complete -c pty -n __pty_needs_command -a tag -d 'Read / write tags on one 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 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)' complete -c pty -n __pty_needs_command -a help -d 'Show usage' @@ -132,11 +130,9 @@ complete -c pty -n '__pty_using_command kill' -a '(__pty_sessions)' -d 'Session' complete -c pty -n '__pty_using_command rm' -a '(__pty_sessions)' -d 'Session' complete -c pty -n '__pty_using_command remove' -a '(__pty_sessions)' -d 'Session' -# ── gc ───────────────────────────────────────────────────────────────── +# ── gc (clean-only post-reboot; convoy owns respawn) ─────────────────── complete -c pty -n '__pty_using_command gc' -s n -l dry-run -d 'Preview without changing anything' complete -c pty -n '__pty_using_command gc' -l idle-days -x -d 'Reap permanents with no attach in N days' -complete -c pty -n '__pty_using_command gc' -l fast-fail-window -x -d 'Fast-fail window (seconds; default 60)' -complete -c pty -n '__pty_using_command gc' -l fast-fail-limit -x -d 'Consecutive fast fails before flapping (default 3)' complete -c pty -n '__pty_using_command gc' -l print-launchd-plist -d 'Emit a launchd plist that runs pty gc' complete -c pty -n '__pty_using_command gc' -l interval -x -d 'Plist StartInterval seconds (default 30)' @@ -161,11 +157,6 @@ complete -c pty -n '__pty_using_command rename' -a '(__pty_sessions)' -d 'Sessio 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' -# ── up / down ────────────────────────────────────────────────────────── -for verb in up down - complete -c pty -n "__pty_using_command $verb" -a '(__fish_complete_directories)' -d 'Directory containing pty.toml' -end - # ── test ─────────────────────────────────────────────────────────────── complete -c pty -n '__pty_using_command test' -a 'watch' -d 'Watch mode' complete -c pty -n '__pty_using_command test' -s t -x -d 'Run matching tests' diff --git a/completions/pty.zsh b/completions/pty.zsh index c9ee29c..874b0cf 100644 --- a/completions/pty.zsh +++ b/completions/pty.zsh @@ -35,8 +35,6 @@ _pty() { 'tag-multi:Bulk tag ops across sessions' 'emit:Publish a user.* event' 'rename:Set / show / clear displayName' - 'up:Start sessions from pty.toml' - 'down:Stop sessions from pty.toml' 'test:Run the pty test suite (vitest)' 'help:Show usage' ) @@ -113,8 +111,6 @@ _pty() { _arguments \ '(-n --dry-run)'{-n,--dry-run}'[Preview without changing anything]' \ '--idle-days[Reap permanents with no attach in N days]:days:' \ - '--fast-fail-window[Fast-fail window in seconds (default 60)]:seconds:' \ - '--fast-fail-limit[Consecutive fast fails before flapping (default 3)]:count:' \ '--print-launchd-plist[Emit a launchd plist that runs pty gc]' \ '--interval[Plist StartInterval seconds (default 30)]:seconds:' ;; @@ -143,9 +139,6 @@ _pty() { '--clear[Remove displayName]' \ '1:session:_pty_sessions' ;; - up|down) - _arguments '1:directory:_directories' - ;; run) # After --, fall back to normal (command + file) completion local -i i diff --git a/docs/disk-layout.md b/docs/disk-layout.md index b3a5386..a43ea7c 100644 --- a/docs/disk-layout.md +++ b/docs/disk-layout.md @@ -74,7 +74,7 @@ Envelope: `{ session: string; type: string; ts: string; ...payload }`. Event typ | `session_exec` | `previousCommand, command` | | `session_respawn` | — (`pty gc` respawned a `strategy=permanent` session) | | `session_abandoned` | `reason: "cwd-gone" \| "idle", idleDays?` — (`pty gc` reaped a live permanent session detected as abandoned) | -| `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) | +| `session_flapping` | `counter, limit, window` — emitted by convoy's reconcile loop when it flips a permanent session to `strategy.status=flapping` after N consecutive fast-fail respawns; subsequent ticks skip it. Interface + `EventType.SESSION_FLAPPING` still exported from `@myobie/pty/client` so convoy shares the wire-format. | | `display_name_change` | `previous: string\|null, value: string\|null` | | `tags_change` | `previous, value` (full snapshots) | | `user.` | `data?, text?` — free-form, via `pty emit` | diff --git a/flake.nix b/flake.nix index 74e7f98..bf5c88d 100644 --- a/flake.nix +++ b/flake.nix @@ -29,7 +29,7 @@ # Generated from package-lock.json. # Regenerate with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json - npmDepsHash = "sha256-mer8rhDyD/j+htWDU8F1EH7MrnuS8pS57WdQgGH8cnQ="; + npmDepsHash = "sha256-yzWiYJ7sbxG1ulvmqA4X4ybs0Qtp7xacwy8PA0GCYBA="; # node-pty has native code that needs these at build time nativeBuildInputs = with pkgs; [ python3 pkg-config ]; diff --git a/package-lock.json b/package-lock.json index 565029f..f86cd79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@myobie/pty", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@myobie/pty", - "version": "0.11.0", + "version": "0.12.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index fbac913..8d0e2e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@myobie/pty", - "version": "0.11.0", + "version": "0.12.0", "description": "Persistent terminal sessions with detach/attach, plus a Playwright-style testing library for TUI apps", "type": "module", "license": "MIT", diff --git a/src/cli.ts b/src/cli.ts index 2542ef8..6a48e1d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -34,7 +34,9 @@ import { readRecentEvents, formatEvent, emitUserEvent, } from "./events.ts"; -import { readPtyFile, commandWithEnvExports, type PtySessionDef } from "./ptyfile.ts"; +// pty.toml parsing moved to convoy post-reboot; `readPtyFile` + +// `commandWithEnvExports` stay exported from `@myobie/pty/client` so +// convoy shares the parser without vendoring. import { extractFilterTags as extractFilterTagsImpl, matchesAllTags, isReservedTagKey } from "./tags.ts"; import { parseDuration, formatDuration } from "./duration.ts"; @@ -138,27 +140,18 @@ Lifecycle: pty restart SIGTERM + respawn using stored metadata (prompts if running) pty restart -y Same, no prompt pty kill SIGTERM a running session's daemon + (convoy's reconcile respawns strategy=permanent sessions) pty rm Remove an exited session's metadata (alias: pty remove) - pty gc Reconciliation pass: orphan-kill, abandoned-reap, - permanent-respawn, exited-sweep + pty gc Clean-only reconciliation: orphan-kill (parent= tag), + abandoned-reap (cwd-gone; opt-in idle), sweep exited + non-permanent metadata. Never respawns — convoy owns + respawn post-reboot. pty gc --dry-run Preview without changing anything (alias: -n) - pty gc --idle-days N Also reap permanents with no attach in N days - pty gc --fast-fail-window=N Fast-fail window (seconds) for the respawn cap - (default 60; per-session strategy.fast-fail-window wins) - pty gc --fast-fail-limit=N Consecutive fast fails before a permanent is flagged - flapping (default 3; per-session tag wins) + pty gc --idle-days N Also reap permanent sessions with no attach in N days pty gc --print-launchd-plist [--interval=N] Print a launchd plist that runs 'pty gc' every N seconds (default 30); Label + logPath derived from PTY_ROOT -Multi (pty.toml): - pty up Start every session in ./pty.toml - pty up Start sessions in /pty.toml - pty up [...] Start specific sessions from ./pty.toml - pty down Stop every session in ./pty.toml - pty down Stop sessions in /pty.toml - pty down [...] Stop specific sessions - Global: pty --root [...] Pin the state registry for this call (== PTY_ROOT env) pty help | pty --help | pty -h Show this usage @@ -827,8 +820,6 @@ async function main(): Promise { const printPlist = gcArgs.includes("--print-launchd-plist"); let interval = 30; let idleDays: number | undefined; - let fastFailWindowSec: number | undefined; - let fastFailLimit: number | undefined; const parsePositive = (flag: string, raw: string): number => { const v = parseInt(raw, 10); if (!Number.isFinite(v) || v <= 0) { @@ -847,21 +838,13 @@ async function main(): Promise { idleDays = parsePositive("--idle-days", gcArgs[++i]); } else if (a.startsWith("--idle-days=")) { idleDays = parsePositive("--idle-days", a.slice("--idle-days=".length)); - } else if (a === "--fast-fail-window" && i + 1 < gcArgs.length) { - fastFailWindowSec = parsePositive("--fast-fail-window", gcArgs[++i]); - } else if (a.startsWith("--fast-fail-window=")) { - fastFailWindowSec = parsePositive("--fast-fail-window", a.slice("--fast-fail-window=".length)); - } else if (a === "--fast-fail-limit" && i + 1 < gcArgs.length) { - fastFailLimit = parsePositive("--fast-fail-limit", gcArgs[++i]); - } else if (a.startsWith("--fast-fail-limit=")) { - fastFailLimit = parsePositive("--fast-fail-limit", a.slice("--fast-fail-limit=".length)); } } if (printPlist) { printLaunchdPlist(interval); break; } - await cmdGc(dryRun, idleDays, fastFailWindowSec, fastFailLimit); + await cmdGc(dryRun, idleDays); break; } @@ -941,7 +924,7 @@ async function main(): Promise { if (ptyfilePath) { console.error(`\nWarning: this session is managed by ${ptyfilePath}`); - console.error("Running 'pty up' will sync tags from the toml and may overwrite this change."); + console.error("Running 'convoy up' will sync tags from the toml and may overwrite this change."); console.error("To make it permanent, edit the pty.toml file directly."); } } catch (e: any) { @@ -961,51 +944,6 @@ async function main(): Promise { break; } - case "up": { - if (args[1] === "-h" || args[1] === "--help") { - console.log("Usage: pty up [dir] [name...]\n\nStart sessions defined in pty.toml."); - break; - } - // pty up [dir] [name...] - const upArgs = args.slice(1); - let dir: string | undefined; - const names: string[] = []; - - for (const arg of upArgs) { - if (arg.startsWith("-")) break; - if (!dir && names.length === 0 && hasPtyFile(arg)) { - dir = arg; - } else { - names.push(arg); - } - } - - await cmdUp(dir, names); - break; - } - - case "down": { - if (args[1] === "-h" || args[1] === "--help") { - console.log("Usage: pty down [dir] [name...]\n\nStop sessions defined in pty.toml."); - break; - } - // pty down [dir] [name...] - const downArgs = args.slice(1); - let dir: string | undefined; - const names: string[] = []; - - for (const arg of downArgs) { - if (arg.startsWith("-")) break; - if (!dir && names.length === 0 && hasPtyFile(arg)) { - dir = arg; - } else { - names.push(arg); - } - } - - await cmdDown(dir, names); - break; - } case "rename": { await cmdRename(args.slice(1)); @@ -1969,19 +1907,12 @@ async function cmdRm(name: string): Promise { console.log(`Session "${name}" removed.`); } -async function cmdGc( - dryRun: boolean, - idleDays?: number, - fastFailWindowSec?: number, - fastFailLimit?: number, -): Promise { - const result = await gc({ dryRun, idleDays, fastFailWindowSec, fastFailLimit }); +async function cmdGc(dryRun: boolean, idleDays?: number): Promise { + const result = await gc({ dryRun, idleDays }); const prunedTags = await pruneOrphanLayoutTags({ dryRun }); const killedVerb = dryRun ? "Would kill orphan child" : "Killed orphan child"; const abandonVerb = dryRun ? "Would abandon" : "Abandoned"; - const respawnVerb = dryRun ? "Would respawn" : "Respawned"; - const flapVerb = dryRun ? "Would flap" : "Flapping"; const removeVerb = dryRun ? "Would remove" : "Removed"; const prunedVerb = dryRun ? "Would prune" : "Pruned"; @@ -1994,23 +1925,6 @@ async function cmdGc( : a.reason; console.log(`${abandonVerb}: ${a.name} (${detail})`); } - for (const r of result.respawned) { - const note = r.ptyfileReread ? " (pty.toml re-read)" : ""; - console.log(`${respawnVerb}: ${r.name}${note}`); - } - for (const f of result.respawnFailed) { - console.log(`Respawn failed: ${f.name} — ${f.error}`); - } - for (const fl of result.flapped) { - console.log( - `${flapVerb}: ${fl.name} (${fl.counter} fast-fails in ${fl.window}s, limit ${fl.limit})`, - ); - } - for (const name of result.flappingSkipped) { - console.log( - `Skipped (flapping): ${name} — remove strategy.status tag to retry`, - ); - } for (const name of result.removed) { console.log(`${removeVerb}: ${name}`); } @@ -2024,10 +1938,6 @@ async function cmdGc( const totalActions = result.killedOrphanChildren.length + result.abandoned.length + - result.respawned.length + - result.respawnFailed.length + - result.flapped.length + - result.flappingSkipped.length + result.removed.length + totalTags; @@ -2043,18 +1953,6 @@ async function cmdGc( if (result.abandoned.length > 0) { parts.push(`${result.abandoned.length} abandoned`); } - if (result.respawned.length > 0) { - parts.push(`${result.respawned.length} respawn${result.respawned.length === 1 ? "" : "s"}`); - } - if (result.respawnFailed.length > 0) { - parts.push(`${result.respawnFailed.length} respawn failure${result.respawnFailed.length === 1 ? "" : "s"}`); - } - if (result.flapped.length > 0) { - parts.push(`${result.flapped.length} flapping`); - } - if (result.flappingSkipped.length > 0) { - parts.push(`${result.flappingSkipped.length} skipped-flapping`); - } if (result.removed.length > 0) { parts.push(`${result.removed.length} stale session${result.removed.length === 1 ? "" : "s"}`); } @@ -2461,254 +2359,6 @@ Examples: // that isn't part of the session-primitive contract.) -function hasPtyFile(dir: string): boolean { - try { - return fs.statSync(path.join(path.resolve(dir), "pty.toml")).isFile(); - } catch { - return false; - } -} - -async function cmdUp(dir: string | undefined, names: string[]): Promise { - let ptyFile; - try { - ptyFile = readPtyFile(dir); - } catch (e: any) { - console.error(e.message); - process.exit(1); - } - - let sessions = ptyFile.sessions; - if (names.length > 0) { - const nameSet = new Set(names); - const matchesName = (s: PtySessionDef) => nameSet.has(s.displayName) || nameSet.has(s.shortName); - const unknown = names.filter((n) => !sessions.some((s) => s.displayName === n || s.shortName === n)); - if (unknown.length > 0) { - console.error(`Unknown session${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}`); - console.error(`Available: ${sessions.map((s) => s.shortName).join(", ")}`); - process.exit(1); - } - sessions = sessions.filter(matchesName); - } - - const tomlPath = path.join(ptyFile.dir, "pty.toml"); - const existing = await listSessions(); - /** Find an existing session that came from this same (ptyfile, ptyfile.session) - * pair. The toml-derived displayName is just a label; identity is the tag - * pair, so renaming a session or hitting a long-name collision doesn't lose - * the binding. */ - const findByTags = (shortName: string) => existing.find((s) => - s.metadata?.tags?.ptyfile === tomlPath && - s.metadata?.tags?.["ptyfile.session"] === shortName - ); - const allRefSet = await allRefs(); - - let started = 0; - let skipped = 0; - - for (const sess of sessions) { - const userTomlKeys = Object.keys(sess.tags ?? {}).sort(); - const ptyfileTagsValue = userTomlKeys.join(","); - const tomlTags: Record = { - ...sess.tags, - ptyfile: tomlPath, - "ptyfile.session": sess.shortName, - "ptyfile.tags": ptyfileTagsValue, - }; - - const bound = findByTags(sess.shortName); - - if (bound && bound.status === "running") { - // Sync tags from toml to the running session (including ptyfile metadata). - // Track which tag keys came from the toml via "ptyfile.tags" so that - // removing a tag from the toml causes it to be removed here (but - // manually-added tags — those not in "ptyfile.tags" — are preserved). - const currentTags = bound.metadata?.tags ?? {}; - - const updates: Record = {}; - for (const [k, v] of Object.entries(tomlTags)) { - if (currentTags[k] !== v) updates[k] = v; - } - - const prevTomlKeys = (currentTags["ptyfile.tags"] ?? "") - .split(",") - .map((k) => k.trim()) - .filter((k) => k.length > 0); - const newKeySet = new Set(userTomlKeys); - const removals = prevTomlKeys.filter((k) => !newKeySet.has(k)); - - // Manual `pty up` is an operator "reset" signal, same shape as - // `pty restart` in cmdRestart above. Drop any `pty gc` flapping - // bookkeeping the session may have accumulated so a re-`pty up` - // gives the session a clean slate. These keys are gc-owned, never - // toml-declared, so they aren't in `prevTomlKeys`. - for (const k of [ - "strategy.status", - "strategy.consecutive-fast-fails", - "strategy.last-respawn-at", - "strategy.command-hash", - ]) { - if (currentTags[k] !== undefined && !removals.includes(k)) removals.push(k); - } - - const label = bound.metadata?.displayName ?? bound.name; - if (Object.keys(updates).length > 0 || removals.length > 0) { - try { - updateTags(bound.name, updates, removals); - const changedTagUpdates = Object.entries(updates) - .filter(([k]) => k !== "ptyfile" && k !== "ptyfile.session" && k !== "ptyfile.tags") - .map(([k, v]) => `${k}=${v}`); - const changedRemovals = removals.map((k) => `-${k}`); - const changed = [...changedTagUpdates, ...changedRemovals].join(", "); - if (changed) { - console.log(` ● ${label} (already running, updated tags: ${changed})`); - } else { - console.log(` ● ${label} (already running)`); - } - } catch { - console.log(` ● ${label} (already running)`); - } - } else { - console.log(` ● ${label} (already running)`); - } - skipped++; - continue; - } - - // Clean up an exited bound session so its slot can be reused. - if (bound && isGone(bound.status)) { - cleanupAll(bound.name); - } - - // Pick the on-disk id: honor the pty.toml's `id = "..."` if set, - // otherwise generate a random one. Either way validate before spawn so - // long pinned ids fail with a clear up-front error. - let name: string; - if (sess.id) { - try { - validateName(sess.id); - } catch (e: any) { - console.error(` ✗ ${sess.displayName}: ${e.message}`); - continue; - } - if (allRefSet.has(sess.id)) { - console.error(` ✗ ${sess.displayName}: id "${sess.id}" is already in use (as a name or displayName).`); - continue; - } - name = sess.id; - allRefSet.add(sess.id); - } else { - let candidate: string | null = null; - for (let attempt = 0; attempt < 8; attempt++) { - const c = randomSessionName(); - if (!allRefSet.has(c)) { candidate = c; allRefSet.add(c); break; } - } - if (!candidate) { - console.error(` ✗ ${sess.displayName}: could not generate a unique session id after 8 attempts.`); - continue; - } - name = candidate; - } - - // Validate the toml-derived displayName once. Default `-` - // is always safe; an explicit `display_name` field could be anything. - try { - validateDisplayName(sess.displayName); - } catch (e: any) { - console.error(` ✗ ${sess.displayName}: ${e.message}`); - continue; - } - - try { - await spawnDaemon({ - name, - command: "/bin/sh", - args: ["-c", commandWithEnvExports(sess)], - displayCommand: sess.command, - cwd: ptyFile.dir, - tags: tomlTags, - displayName: sess.displayName, - }); - console.log(` ● ${sess.displayName} (started)`); - started++; - } catch (e: any) { - console.error(` ✗ ${sess.displayName}: ${e.message}`); - } - } - - if (started === 0 && skipped === sessions.length) { - console.log("All sessions already running."); - } else if (started > 0) { - console.log(`Started ${started} session${started === 1 ? "" : "s"}.`); - } -} - -async function cmdDown(dir: string | undefined, names: string[]): Promise { - let ptyFile; - try { - ptyFile = readPtyFile(dir); - } catch (e: any) { - console.error(e.message); - process.exit(1); - } - - let sessions = ptyFile.sessions; - if (names.length > 0) { - const nameSet = new Set(names); - sessions = sessions.filter((s) => nameSet.has(s.displayName) || nameSet.has(s.shortName)); - } - - const tomlPath = path.join(ptyFile.dir, "pty.toml"); - const existing = await listSessions(); - const findByTags = (shortName: string) => existing.find((s) => - s.metadata?.tags?.ptyfile === tomlPath && - s.metadata?.tags?.["ptyfile.session"] === shortName - ); - let stopped = 0; - - for (const sess of sessions) { - const existingSession = findByTags(sess.shortName); - if (!existingSession) continue; - - const label = existingSession.metadata?.displayName ?? existingSession.name; - - // Strip the `strategy` tag so `pty gc` doesn't respawn the session - // on its next tick. The `supervisor.status` tag is no longer a thing. - const wasPermanent = existingSession.metadata?.tags?.strategy === "permanent"; - if (wasPermanent) { - try { - updateTags(existingSession.name, {}, ["strategy"]); - } catch {} - } - - if (existingSession.status === "running" && existingSession.pid) { - try { - process.kill(existingSession.pid, "SIGTERM"); - console.log(` ○ ${label} (stopped${wasPermanent ? ", removed from supervision" : ""})`); - stopped++; - } catch { - console.error(` ✗ ${label}: failed to stop`); - } - cleanupSocket(existingSession.name); - } else if (isGone(existingSession.status)) { - cleanupAll(existingSession.name); - console.log(` ○ ${label} (cleaned up)`); - stopped++; - } - } - - if (stopped === 0) { - console.log("No sessions to stop."); - } else { - console.log(`Stopped ${stopped} session${stopped === 1 ? "" : "s"}.`); - } - - // Warn if any stopped sessions are toml-managed - const anyTomlManaged = sessions.some((sess) => findByTags(sess.shortName)?.metadata?.tags?.ptyfile); - if (anyTomlManaged && stopped > 0) { - console.error("\nNote: strategy tags will be restored on the next 'pty up'."); - } -} async function cmdRestart( name: string, @@ -2880,13 +2530,13 @@ function ask(prompt: string): Promise { }); } -/** Strip `pty gc`'s flapping bookkeeping from a tag map before a manual - * restart / respawn. `strategy.status`, `strategy.consecutive-fast-fails`, +/** Strip convoy's flapping bookkeeping from a tag map before a manual + * restart. `strategy.status`, `strategy.consecutive-fast-fails`, * `strategy.last-respawn-at`, and `strategy.command-hash` are all - * gc-owned — they exist to track auto-respawn state, and an operator - * action ("please try again") is a signal to reset them. Returns - * `undefined` when the input is missing/empty so downstream defaults - * (spawnDaemon skips the `tags` field entirely) apply. */ + * convoy-owned post-reboot — they track auto-respawn state, and an + * operator running `pty restart` is a "please try again with fresh + * state" signal. Returns `undefined` when the input is missing/empty + * so downstream defaults apply. */ function clearFlappingBookkeeping( tags: Record | undefined, ): Record | undefined { @@ -2904,12 +2554,6 @@ function clearFlappingBookkeeping( function strategyMarker(tags?: Record): string { if (!tags) return ""; - // Flapping supersedes permanent visually because it's what changed the - // operator's expectation ("gc stopped respawning this on purpose"). - // Rendered red so it stands out from the yellow [permanent]. - if (tags["strategy.status"] === "flapping") { - return " \x1b[31m[flapping]\x1b[0m"; - } if (tags.strategy === "permanent") return " \x1b[33m[permanent]\x1b[0m"; return ""; } diff --git a/src/client-api.ts b/src/client-api.ts index 33d8191..c032d42 100644 --- a/src/client-api.ts +++ b/src/client-api.ts @@ -42,8 +42,13 @@ export { type FollowerOptions, } from "./events.ts"; -// Project files -export { readPtyFile, type PtyFile, type PtySessionDef } from "./ptyfile.ts"; +// Project files — pty.toml reader shared with convoy's manifest processing +// (convoy reads pty.toml verbatim per notes/lean-pty-core-supervision-spec.md §4). +export { readPtyFile, commandWithEnvExports, type PtyFile, type PtySessionDef } from "./ptyfile.ts"; + +// Reboot-cutover: shared classifier primitives that convoy's respawn loop +// mirrors verbatim (spec §5 + §8.1 wire-format freeze). +export { commandFingerprint, DEFAULT_FAST_FAIL_WINDOW_SEC, DEFAULT_FAST_FAIL_LIMIT } from "./sessions.ts"; // Tag filter helpers (used by --filter-tag; shared with pty-relay) export { extractFilterTags, matchesAllTags, isReservedTagKey } from "./tags.ts"; diff --git a/src/sessions.ts b/src/sessions.ts index d99ee4e..48af05d 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -405,10 +405,11 @@ export async function allRefs(): Promise> { return refs; } -/** Result of a `gc()` reconciliation pass. Five buckets correspond to the - * reconciliation steps: orphan-kill (step 1), abandoned-reap (step 1.5), - * permanent respawn success / failure (step 2), and the sweep of exited - * non-permanent sessions (step 3 — the historic `gc()` behavior). */ +/** Result of a `gc()` reconciliation pass. Three buckets correspond to + * the reconciliation steps: orphan-kill (step 1), abandoned-reap + * (step 1.5), and the sweep of exited non-permanent sessions (step 3 + * — the historic `gc()` behavior). Permanent respawn is convoy's job + * post-reboot; `pty gc` is clean-only. */ export interface GcResult { /** Names of exited/vanished non-permanent sessions whose metadata was * removed. Empty under `dryRun: true` callers should treat the same @@ -422,26 +423,6 @@ export interface GcResult { * `idleDays` threshold is set (via CLI flag or per-session tag) * and `lastAttachAt` is older than that threshold. */ abandoned: { name: string; reason: "cwd-gone" | "idle"; idleDays?: number }[]; - /** Permanent sessions respawned this pass. `ptyfileReread` indicates - * whether the spawn used a fresh `pty.toml` read (when the session - * carries `ptyfile` + `ptyfile.session` tags) or its stored metadata. */ - respawned: { name: string; ptyfileReread: boolean }[]; - /** Permanent sessions where respawn was attempted but failed (e.g. the - * binary is on an unmounted volume). Cron interval is the rate limit; - * next tick tries again. */ - respawnFailed: { name: string; error: string }[]; - /** Permanent sessions the fast-fail cap flipped to `flapping` on this - * tick. Each entry records the counter at the moment of flip plus the - * effective `limit`/`window` in play. Sessions already flagged before - * this tick are silently skipped from the respawn loop and do NOT - * appear here — this bucket is transitions only. */ - flapped: { name: string; counter: number; limit: number; window: number }[]; - /** Permanent sessions skipped this tick because they are already - * `strategy.status=flapping`. Distinct from `flapped` (transitions), - * `respawnFailed` (attempted + failed), and `respawned` (attempted + - * succeeded). Consumers can render "N flapping" without having to - * read tags themselves. */ - flappingSkipped: string[]; } /** Default fast-fail respawn cap window (seconds). A permanent session @@ -460,8 +441,13 @@ export const DEFAULT_FAST_FAIL_LIMIT = 3; /** SHA-256 of a session's respawn command line, used to auto-reset the * fast-fail counter when the operator edits the pty.toml (or otherwise * changes the stored command). Kept short — the tag surface is user- - * facing, not a cryptographic identifier. */ -function commandFingerprint(command: string, args: string[]): string { + * facing, not a cryptographic identifier. + * + * Exported on `@myobie/pty/client` so convoy's reconcile loop computes + * identical hashes (see `notes/lean-pty-core-supervision-spec.md` §8.1 + * — wire-format freeze). Any change to the hash shape requires a joint + * version bump across pty and convoy. */ +export function commandFingerprint(command: string, args: string[]): string { const h = createHash("sha256"); h.update(command); h.update("\0"); @@ -469,44 +455,31 @@ function commandFingerprint(command: string, args: string[]): string { return h.digest("hex").slice(0, 16); } -/** Reconciliation pass driven by `pty gc`. Stateless: every invocation - * re-derives intent from on-disk metadata. Four steps run in order: +/** Reconciliation pass driven by `pty gc`. Stateless + clean-only: + * every invocation re-derives intent from on-disk metadata and never + * spawns anything. Three steps run in order (permanent respawn moved + * to convoy post-reboot; see notes/lean-pty-core-supervision-spec.md). * * 1. Orphan-kill: children with a `parent=` tag whose parent's * metadata is gone OR whose parent's pid isn't alive get SIGTERM'd - * and `cleanupAll`'d. Runs first so a permanent child whose parent - * has died isn't immediately respawned by step 2. + * and `cleanupAll`'d. * 1.5. Abandoned-reap: live `strategy=permanent` sessions whose recorded * cwd is gone from disk are SIGTERM'd + `cleanupAll`'d + get a * `session_abandoned` event. When `opts.idleDays` is set OR the * session carries a `strategy.idle-days=N` tag, sessions whose * `lastAttachAt` is older than that threshold are also reaped - * with reason `idle`. Runs before step 2 so a session reaped for - * abandonment isn't immediately respawned by permanent-restart. - * 2. Permanent respawn: every `strategy=permanent` session that's - * exited/vanished is respawned via `spawnDaemon` (lazy-imported to - * avoid the `sessions ↔ spawn` cycle). Sessions with `ptyfile` + - * `ptyfile.session` tags re-read the toml to pick up any edits. - * A fast-fail cap prevents a crash-looping leaf from being - * respawned forever: `strategy.fast-fail-limit` consecutive - * respawns whose leaf exited within `strategy.fast-fail-window` - * seconds flip the session to `strategy.status=flapping` and - * skip it on subsequent ticks. Auto-reset when the stored command - * changes; manual reset via `pty tag --rm strategy.status`. - * 3. Existing sweep: the historic behavior — exited/vanished sessions - * that aren't permanent get `cleanupAll`'d. */ + * with reason `idle`. + * 3. Existing sweep: exited/vanished non-permanent sessions get + * `cleanupAll`'d. Permanent sessions that are gone are left + * in-place for convoy's reconcile loop to notice + respawn. */ export async function gc( opts: { dryRun?: boolean; idleDays?: number; - fastFailWindowSec?: number; - fastFailLimit?: number; } = {}, ): Promise { const dryRun = !!opts.dryRun; const globalIdleDays = opts.idleDays; - const globalFastFailWindow = opts.fastFailWindowSec; - const globalFastFailLimit = opts.fastFailLimit; // First call to `listSessions` is intentionally throwaway — it has a // side effect (`cleanupSocket`) on sessions whose daemon SIGKILL'd // without writing an exit record, and those sessions are then *missing* @@ -594,102 +567,10 @@ export async function gc( }); } - // STEP 2: permanent respawn. Re-list since steps 1 and 1.5 may have - // removed some metadata. In dryRun mode we filter out anything step - // 1.5 would have reaped so the preview reflects the same intent. - const afterStep15 = dryRun - ? initial.filter((s) => !abandoned.some((a) => a.name === s.name)) - : await listSessions(); - const respawned: GcResult["respawned"] = []; - const respawnFailed: GcResult["respawnFailed"] = []; - const flapped: GcResult["flapped"] = []; - const flappingSkipped: GcResult["flappingSkipped"] = []; - for (const s of afterStep15) { - if (s.metadata?.tags?.strategy !== "permanent") continue; - if (!isGone(s.status)) continue; - const ptyfileReread = !!s.metadata?.tags?.ptyfile; - - // Fast-fail classifier: was the previous respawn a fast crash? What's - // the running counter? Should we flip to flapping? Runs before any - // spawn so a session at the limit boundary flaps this tick instead - // of respawning one more time. - const decision = classifyFlapping( - s, - new Date(), - globalFastFailWindow, - globalFastFailLimit, - ); - - if (decision.action === "skip-flapping") { - flappingSkipped.push(s.name); - continue; - } - - if (dryRun) { - if (decision.action === "flap-now") { - flapped.push({ - name: s.name, - counter: decision.counter, - limit: decision.effectiveLimit, - window: decision.effectiveWindow, - }); - continue; - } - respawned.push({ name: s.name, ptyfileReread }); - continue; - } - - if (decision.action === "flap-now") { - // Persist the flapping mark to on-disk metadata so subsequent - // ticks see it. We update the metadata file directly instead of - // going through updateTags — the session's daemon is gone, there's - // no live connection to notify, and cleanupAll ordering constraints - // in respawnPermanent don't apply here (we're NOT respawning). - try { - const meta = readMetadata(s.name); - if (meta) { - const merged: Record = { - ...(meta.tags ?? {}), - ...decision.newBookkeeping, - }; - writeMetadata(s.name, { ...meta, tags: merged }); - } - } catch { - // Best-effort — if we can't persist the flag now, the next tick - // will recompute the same decision and try again. - } - try { - appendEventSync(s.name, { - session: s.name, - type: "session_flapping", - ts: new Date().toISOString(), - counter: decision.counter, - limit: decision.effectiveLimit, - window: decision.effectiveWindow, - }); - } catch {} - flapped.push({ - name: s.name, - counter: decision.counter, - limit: decision.effectiveLimit, - window: decision.effectiveWindow, - }); - continue; - } - - try { - await respawnPermanent(s.name, s.metadata!, decision.newBookkeeping); - respawned.push({ name: s.name, ptyfileReread }); - } catch (err: any) { - respawnFailed.push({ name: s.name, error: err?.message ?? String(err) }); - } - } - // STEP 3: historic sweep. Exited/vanished non-permanent sessions get - // their metadata removed. Permanent sessions are handled by step 2 — - // if their respawn succeeded they're back to `running` and skipped; - // if it failed we leave the metadata around so the next tick can try - // again. + // their metadata removed. Permanent sessions are left in-place — convoy + // owns the respawn decision and reads their tags to compute its + // classifier state. const finalList = dryRun ? initial : await listSessions(); const removed: string[] = []; for (const s of finalList) { @@ -703,10 +584,6 @@ export async function gc( removed, killedOrphanChildren, abandoned, - respawned, - respawnFailed, - flapped, - flappingSkipped, }; } @@ -755,217 +632,6 @@ function classifyAbandoned( return { reason: "idle", idleDays: ageDays }; } -/** Decide whether a `strategy=permanent` session that's exited/vanished - * should be respawned, marked flapping, or silently skipped because - * it's already flapping. Reads three bookkeeping tags from the session: - * - `strategy.last-respawn-at` (ISO ts): when gc last respawned it - * - `strategy.consecutive-fast-fails` (int): running fast-fail counter - * - `strategy.command-hash` (16-char hex): command fingerprint at last - * respawn. If the current fingerprint differs, the operator edited - * the pty.toml (or otherwise changed the command); reset the - * counter and clear any stale `strategy.status=flapping`. - * - * Effective window/limit resolution: - * per-session tag (strategy.fast-fail-window / -limit) - * → global opt (CLI --fast-fail-window / --fast-fail-limit) - * → DEFAULT_FAST_FAIL_WINDOW_SEC / DEFAULT_FAST_FAIL_LIMIT. - * - * Returned `newBookkeeping` MUST be merged onto the session's tags map - * before/instead of respawn. The `flap-now` action never respawns; the - * `respawn` action does; the `skip-flapping` action skips entirely. */ -interface FlappingDecision { - action: "respawn" | "flap-now" | "skip-flapping"; - effectiveWindow: number; - effectiveLimit: number; - /** Fast-fail counter after this tick's classification. Only meaningful - * for `respawn` (stamped on the session) and `flap-now` (the counter - * that crossed the threshold, surfaced in the event payload). */ - counter: number; - /** Tag deltas to persist. Empty for `skip-flapping`. For `respawn`, - * carries the fresh timestamp, counter, and command hash. For - * `flap-now`, adds `strategy.status=flapping` on top. */ - newBookkeeping: Record; -} - -function classifyFlapping( - s: SessionInfo, - now: Date, - globalWindowSec: number | undefined, - globalLimit: number | undefined, -): FlappingDecision { - const tags = s.metadata?.tags ?? {}; - - const tagWindow = parseInt(tags["strategy.fast-fail-window"] ?? "", 10); - const effectiveWindow = Number.isFinite(tagWindow) && tagWindow > 0 - ? tagWindow - : (globalWindowSec !== undefined && globalWindowSec > 0 - ? globalWindowSec - : DEFAULT_FAST_FAIL_WINDOW_SEC); - - const tagLimit = parseInt(tags["strategy.fast-fail-limit"] ?? "", 10); - const effectiveLimit = Number.isFinite(tagLimit) && tagLimit > 0 - ? tagLimit - : (globalLimit !== undefined && globalLimit > 0 - ? globalLimit - : DEFAULT_FAST_FAIL_LIMIT); - - const command = s.metadata?.command ?? ""; - const args = s.metadata?.args ?? []; - const currentHash = commandFingerprint(command, args); - const storedHash = tags["strategy.command-hash"]; - const commandChanged = storedHash !== undefined && storedHash !== currentHash; - - // Command change wins over an existing flapping mark: the operator has - // edited the pty.toml (or manually mutated the command), so give it a - // fresh chance. `strategy.status` clears; counter resets to 0. - if (tags["strategy.status"] === "flapping" && !commandChanged) { - return { - action: "skip-flapping", - effectiveWindow, - effectiveLimit, - counter: parseInt(tags["strategy.consecutive-fast-fails"] ?? "0", 10) || 0, - newBookkeeping: {}, - }; - } - - // Was the previous respawn a fast fail? Compare the exit timestamp - // against the last-respawn stamp; anything under `window` seconds is - // fast. If no prior stamp exists (never respawned by gc) or the exit - // is missing (vanished session), treat as slow — the counter resets. - const lastRespawnAt = tags["strategy.last-respawn-at"]; - const exitedAt = s.metadata?.exitedAt; - let liveMs: number | null = null; - if (lastRespawnAt && exitedAt) { - const lr = Date.parse(lastRespawnAt); - const ex = Date.parse(exitedAt); - if (Number.isFinite(lr) && Number.isFinite(ex)) liveMs = ex - lr; - } - const wasFastFail = liveMs !== null && liveMs >= 0 && liveMs < effectiveWindow * 1000; - - const prevCounter = parseInt(tags["strategy.consecutive-fast-fails"] ?? "0", 10) || 0; - const nextCounter = commandChanged ? 0 : (wasFastFail ? prevCounter + 1 : 0); - - if (nextCounter >= effectiveLimit) { - // Threshold crossed. Mark flapping, don't respawn. The counter goes - // into the tags at its final value so subsequent listers can see how - // deep the streak went. - const bookkeeping: Record = { - "strategy.status": "flapping", - "strategy.consecutive-fast-fails": String(nextCounter), - "strategy.command-hash": currentHash, - }; - if (lastRespawnAt) bookkeeping["strategy.last-respawn-at"] = lastRespawnAt; - return { - action: "flap-now", - effectiveWindow, - effectiveLimit, - counter: nextCounter, - newBookkeeping: bookkeeping, - }; - } - - // Respawn. Stamp fresh bookkeeping. If we're clearing a stale flap - // mark from a command change, drop `strategy.status` explicitly by - // storing an empty string — updateTags treats that as a remove. - const bookkeeping: Record = { - "strategy.last-respawn-at": now.toISOString(), - "strategy.consecutive-fast-fails": String(nextCounter), - "strategy.command-hash": currentHash, - }; - return { - action: "respawn", - effectiveWindow, - effectiveLimit, - counter: nextCounter, - newBookkeeping: bookkeeping, - }; -} - -/** Restart a `strategy=permanent` session whose daemon is gone. If the - * session was toml-managed (`ptyfile` + `ptyfile.session` tags), re-read - * the pty.toml so the new daemon picks up command/env edits since the - * last spawn. On any read error fall back to the stored metadata - * verbatim (last-known-good) so a temporarily-missing toml doesn't - * prevent restart. - * - * `bookkeepingOverlay` (optional) carries pty-internal tags that must - * survive the pty.toml re-read: gc backoff state (`strategy.last-*`, - * `strategy.command-hash`, `strategy.consecutive-fast-fails`). Passed - * by `gc()` STEP-2; ignored by other callers. - * - * Lazy-imports `spawn.ts` so the `sessions.ts ↔ spawn.ts` cycle doesn't - * bite at module-init time. After spawn, appends a `session_respawn` - * event to the session's event log so consumers see the restart. */ -async function respawnPermanent( - name: string, - metadata: SessionMetadata, - bookkeepingOverlay: Record = {}, -): Promise { - let command = metadata.command; - let args = metadata.args; - let displayCommand = metadata.displayCommand; - let cwd = metadata.cwd; - let tags: Record | undefined = metadata.tags; - const displayName = metadata.displayName; - - const ptyfilePath = metadata.tags?.ptyfile; - const ptyfileSession = metadata.tags?.["ptyfile.session"]; - if (ptyfilePath && ptyfileSession) { - try { - const { readPtyFile, commandWithEnvExports } = await import("./ptyfile.ts"); - const dir = path.dirname(ptyfilePath); - const ptyFile = readPtyFile(dir); - const sessDef = ptyFile.sessions.find((s) => s.shortName === ptyfileSession); - if (sessDef) { - command = "/bin/sh"; - args = ["-c", commandWithEnvExports(sessDef)]; - displayCommand = sessDef.command; - cwd = ptyFile.dir; - tags = { - ...sessDef.tags, - ptyfile: ptyfilePath, - "ptyfile.session": ptyfileSession, - }; - } - } catch { - // pty.toml unreadable (volume not mounted yet, file deleted, parse - // error). Fall back to stored metadata — better to respawn with - // last-known-good than to give up. - } - } - - // Merge gc's backoff bookkeeping last so it survives the pty.toml - // overlay above. Callers pass an empty overlay when they aren't gc. - // If a command change is clearing a stale flap mark, the caller - // omits `strategy.status` from the overlay; we also clear any - // existing flag on the merged map so a rebuilt tags dict doesn't - // silently carry it forward from the previous metadata. - tags = { ...(tags ?? {}), ...bookkeepingOverlay }; - if (bookkeepingOverlay["strategy.status"] === undefined) { - delete tags["strategy.status"]; - } - - // Wipe stale socket/pid/events before respawn so spawnDaemon doesn't - // trip over leftovers from the dead daemon. Metadata is recreated by - // spawnDaemon. - cleanupAll(name); - - const { spawnDaemon } = await import("./spawn.ts"); - await spawnDaemon({ - name, command, args, displayCommand, cwd, tags, - ...(displayName ? { displayName } : {}), - }); - - // Best-effort event; respawn already succeeded if we got here. - try { - appendEventSync(name, { - session: name, - type: "session_respawn", - ts: new Date().toISOString(), - }); - } catch {} -} - /** * Layout tool tag keys follow `:l-` where the PID is the * pty-layout process that owns the view. When that process dies the diff --git a/tests/gc-abandoned.test.ts b/tests/gc-abandoned.test.ts index a5da1a0..808c2a5 100644 --- a/tests/gc-abandoned.test.ts +++ b/tests/gc-abandoned.test.ts @@ -309,18 +309,19 @@ describe("pty gc — abandoned-reap step 1.5", () => { }); describe("pty gc — abandoned reap does not disrupt other buckets", () => { - it("still respawns a normal exited permanent session in the same pass", async () => { - // Two sessions: one abandoned (cwd-gone, live), one exited (should - // respawn normally). Both permanent. Same gc pass handles both. + it("reaps a cwd-gone permanent alongside a normal exited permanent (which is left in-place for convoy)", async () => { + // Two sessions: one abandoned (cwd-gone, live), one exited-permanent + // (post-reboot: pty gc leaves it alone; convoy's reconcile loop owns + // respawn). Same gc pass handles both. const dir = makeSessionDir(); const abandonName = uniqueName(); const abandonCwd = makeCwd(); await startDaemon(dir, abandonName, abandonCwd, "sleep", ["60"], { strategy: "permanent" }); - const respawnName = uniqueName(); - const respawnCwd = makeCwd(); - await startDaemon(dir, respawnName, respawnCwd, "true", [], { strategy: "permanent" }); + const leftAloneName = uniqueName(); + const leftAloneCwd = makeCwd(); + await startDaemon(dir, leftAloneName, leftAloneCwd, "true", [], { strategy: "permanent" }); await new Promise((r) => setTimeout(r, 800)); // let `true` exit fs.rmSync(abandonCwd, { recursive: true, force: true }); @@ -328,12 +329,9 @@ describe("pty gc — abandoned reap does not disrupt other buckets", () => { const result = runCli(dir, "gc"); expect(result.status).toBe(0); expect(result.stdout).toContain(`Abandoned: ${abandonName} (cwd-gone)`); - expect(result.stdout).toContain(`Respawned: ${respawnName}`); - - // Track any respawn pid for cleanup. - try { - const pid = parseInt(fs.readFileSync(path.join(dir, `${respawnName}.pid`), "utf-8").trim(), 10); - if (Number.isFinite(pid)) bgPids.push(pid); - } catch {} + // Post-reboot: pty gc no longer respawns; exited permanent stays + // in-place with its metadata for convoy's reconcile loop to notice. + expect(result.stdout).not.toContain(`Respawned: ${leftAloneName}`); + expect(result.stdout).not.toContain(`Removed: ${leftAloneName}`); }, 25000); }); diff --git a/tests/gc-flap-clear-badge-root-len.test.ts b/tests/gc-flap-clear-badge-root-len.test.ts deleted file mode 100644 index 22d567f..0000000 --- a/tests/gc-flap-clear-badge-root-len.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -// Follow-ups to #56: -// 1. `pty restart` clears strategy.status=flapping + bookkeeping. -// 2. `pty up` clears the same on the "already running, tag-sync" path. -// 3. `pty list` renders `[flapping]` badge (mutually exclusive with [permanent]). -// 4. Fail-loud startup backstop when PTY_ROOT is too long for the socket-path -// kernel limit — errors before any subcommand runs. - -import { describe, it, expect, afterEach, afterAll } from "vitest"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; -import { spawnSync } from "node:child_process"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const nodeBin = process.execPath; -const cliPath = path.join(__dirname, "..", "dist", "cli.js"); - -const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-follow-")); -afterAll(() => { - fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); -}); - -let sessionDirs: string[] = []; -function makeSessionDir(): string { - const dir = fs.mkdtempSync(path.join(testRoot, "d-")); - sessionDirs.push(dir); - return dir; -} - -let nameCounter = 0; -function uniqueName(): string { - return `fc${++nameCounter}${Math.random().toString(36).slice(2, 5)}`; -} - -function runCli(sessionDir: string, ...args: string[]) { - return spawnSync(nodeBin, [cliPath, ...args], { - env: { ...process.env, PTY_SESSION_DIR: sessionDir }, - encoding: "utf-8", - timeout: 15000, - }); -} - -function readMeta(sessionDir: string, name: string): any { - return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); -} - -/** Write metadata simulating an exited session that gc previously marked - * flapping. Includes all four bookkeeping tags. */ -function writeFlappingExited( - sessionDir: string, - name: string, - extraTags: Record = {}, -): void { - fs.writeFileSync(path.join(sessionDir, `${name}.json`), JSON.stringify({ - command: "sh", args: ["-c", "exit 1"], displayCommand: "sh -c 'exit 1'", - cwd: os.tmpdir(), - createdAt: new Date(Date.now() - 10 * 60_000).toISOString(), - exitedAt: new Date().toISOString(), - exitCode: 1, - tags: { - strategy: "permanent", - "strategy.status": "flapping", - "strategy.consecutive-fast-fails": "3", - "strategy.last-respawn-at": new Date(Date.now() - 5000).toISOString(), - "strategy.command-hash": "0123456789abcdef", - ...extraTags, - }, - })); - const evPath = path.join(sessionDir, `${name}.events.jsonl`); - if (!fs.existsSync(evPath)) fs.writeFileSync(evPath, ""); -} - -afterEach(() => { - for (const dir of sessionDirs) { - try { - for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } - } catch {} - } - sessionDirs = []; -}); - -describe("pty restart clears strategy.status=flapping + bookkeeping", () => { - it("restart of a flapping-exited session drops all four gc bookkeeping tags", async () => { - const dir = makeSessionDir(); - const name = uniqueName(); - writeFlappingExited(dir, name, { role: "test" }); - - // Restart it. -y skips the prompt; command is "sh -c 'exit 1'" which - // will exit almost immediately — that's fine, we care about the tag - // snapshot right after spawn. - const r = runCli(dir, "restart", "-y", name); - expect(r.status).toBe(0); - - // Give the daemon a moment to write its metadata. - await new Promise((res) => setTimeout(res, 300)); - - const meta = readMeta(dir, name); - expect(meta.tags.strategy).toBe("permanent"); - expect(meta.tags.role).toBe("test"); - // Bookkeeping tags are gone. - expect(meta.tags["strategy.status"]).toBeUndefined(); - expect(meta.tags["strategy.consecutive-fast-fails"]).toBeUndefined(); - expect(meta.tags["strategy.last-respawn-at"]).toBeUndefined(); - expect(meta.tags["strategy.command-hash"]).toBeUndefined(); - }); -}); - -describe("pty list renders [flapping] badge", () => { - it("running session with strategy.status=flapping shows [flapping] in text output", () => { - const dir = makeSessionDir(); - const name = uniqueName(); - // Simulate a running session (synthetic pid + no exitedAt) with a - // flapping mark. `pty list` classifies status by (pid alive check + - // exited fields); we cheat by using our own pid so isProcessAlive - // returns true — the row renders as running. - fs.writeFileSync(path.join(dir, `${name}.json`), JSON.stringify({ - command: "sh", args: [], displayCommand: "sh", - cwd: os.tmpdir(), - createdAt: new Date().toISOString(), - tags: { - strategy: "permanent", - "strategy.status": "flapping", - }, - })); - fs.writeFileSync(path.join(dir, `${name}.pid`), String(process.pid)); - - const r = runCli(dir, "list"); - expect(r.status).toBe(0); - // ANSI color-code stripped for the assertion; check the literal marker. - // `[flapping]` supersedes `[permanent]` when both would apply. - expect(r.stdout).toContain("[flapping]"); - expect(r.stdout).not.toContain("[permanent]"); - }); - - it("session without strategy.status=flapping still shows [permanent]", () => { - const dir = makeSessionDir(); - const name = uniqueName(); - fs.writeFileSync(path.join(dir, `${name}.json`), JSON.stringify({ - command: "sh", args: [], displayCommand: "sh", - cwd: os.tmpdir(), - createdAt: new Date().toISOString(), - tags: { strategy: "permanent" }, - })); - fs.writeFileSync(path.join(dir, `${name}.pid`), String(process.pid)); - - const r = runCli(dir, "list"); - expect(r.status).toBe(0); - expect(r.stdout).toContain("[permanent]"); - expect(r.stdout).not.toContain("[flapping]"); - }); -}); - -describe("PTY_ROOT length backstop", () => { - it("errors at startup when PTY_ROOT is too deep to fit the sockaddr_un limit", () => { - // Build a 95-byte path — well past the 90-byte usable threshold - // (104 − 14 for `/xxxxxxxx.sock`). Doesn't need to actually exist; - // the check is on byte length, not existence. - const tooLong = "/tmp/" + "a".repeat(95); - expect(Buffer.byteLength(tooLong, "utf-8")).toBeGreaterThan(90); - - const r = spawnSync(nodeBin, [cliPath, "list"], { - env: { ...process.env, PTY_ROOT: tooLong, PTY_ROOT_LEGACY_SILENT: "1" }, - encoding: "utf-8", - timeout: 5000, - }); - expect(r.status).not.toBe(0); - expect(r.stderr).toMatch(/PTY_ROOT is too long/); - expect(r.stderr).toMatch(/104-byte kernel limit/); - // Points the finger at the root, not the name. - expect(r.stderr).toMatch(/Shorten the root/); - }); - - it("errors before any subcommand-specific parsing runs", () => { - // Backstop should fire even on a bogus subcommand — the too-long - // root is caught before dispatch. - const tooLong = "/tmp/" + "b".repeat(100); - const r = spawnSync(nodeBin, [cliPath, "definitely-not-a-real-subcommand"], { - env: { ...process.env, PTY_ROOT: tooLong, PTY_ROOT_LEGACY_SILENT: "1" }, - encoding: "utf-8", - timeout: 5000, - }); - expect(r.status).not.toBe(0); - expect(r.stderr).toMatch(/PTY_ROOT is too long/); - // The "unknown command" path is NOT hit; the root check errors first. - expect(r.stderr).not.toMatch(/Unknown command/); - }); - - it("allows a root right at the usable threshold", () => { - // Build a root at exactly 90 bytes — the maximum that leaves room - // for `/xxxxxxxx.sock` in 104. This should succeed (empty list). - const usable = 104 - ("/".length + 8 + ".sock".length); - const okRoot = "/tmp/" + "c".repeat(usable - "/tmp/".length); - expect(Buffer.byteLength(okRoot, "utf-8")).toBe(usable); - fs.mkdirSync(okRoot, { recursive: true }); - try { - const r = spawnSync(nodeBin, [cliPath, "list", "--json"], { - env: { ...process.env, PTY_ROOT: okRoot, PTY_ROOT_LEGACY_SILENT: "1" }, - encoding: "utf-8", - timeout: 5000, - }); - expect(r.status).toBe(0); - expect(JSON.parse(r.stdout)).toEqual([]); - } finally { - try { fs.rmSync(okRoot, { recursive: true, force: true }); } catch {} - } - }); - - it("--root overrides an env that would otherwise fail", () => { - // Env is too long; --root override is fine. The startup check reads - // process.env.PTY_ROOT *after* --root parsing has set it, so the - // override wins. - const tooLongEnv = "/tmp/" + "d".repeat(95); - const shortFlag = fs.mkdtempSync(path.join(testRoot, "shorter-")); - const r = spawnSync(nodeBin, [cliPath, "--root", shortFlag, "list", "--json"], { - env: { ...process.env, PTY_ROOT: tooLongEnv, PTY_ROOT_LEGACY_SILENT: "1" }, - encoding: "utf-8", - timeout: 5000, - }); - expect(r.status).toBe(0); - expect(JSON.parse(r.stdout)).toEqual([]); - }); -}); diff --git a/tests/gc-flapping.test.ts b/tests/gc-flapping.test.ts deleted file mode 100644 index 890c92b..0000000 --- a/tests/gc-flapping.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -// #54: fast-fail respawn cap. A crash-looping permanent session gets -// flagged flapping and stopped after `strategy.fast-fail-limit` -// consecutive fast failures within `strategy.fast-fail-window` seconds. -// Auto-reset on command change; manual reset via `pty tag --rm`. - -import { describe, it, expect, afterEach, afterAll } from "vitest"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; -import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const nodeBin = process.execPath; -const cliPath = path.join(__dirname, "..", "dist", "cli.js"); - -const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-flap-")); -afterAll(() => { - fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); -}); - -let sessionDirs: string[] = []; - -function makeSessionDir(): string { - const dir = fs.mkdtempSync(path.join(testRoot, "d-")); - sessionDirs.push(dir); - return dir; -} - -let nameCounter = 0; -function uniqueName(): string { - return `fl${++nameCounter}${Math.random().toString(36).slice(2, 5)}`; -} - -function runCli(sessionDir: string, ...args: string[]) { - return spawnSync(nodeBin, [cliPath, ...args], { - env: { ...process.env, PTY_SESSION_DIR: sessionDir }, - encoding: "utf-8", - timeout: 15000, - }); -} - -function readMeta(sessionDir: string, name: string): any { - return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); -} - -function readEvents(sessionDir: string, name: string): any[] { - const filePath = path.join(sessionDir, `${name}.events.jsonl`); - try { - return fs.readFileSync(filePath, "utf-8") - .trimEnd().split("\n").filter((l) => l.length > 0).map((l) => JSON.parse(l)); - } catch { - return []; - } -} - -/** Write a synthetic exited-permanent metadata file. `respawnAt` seeds - * `strategy.last-respawn-at`, `exitedAt` seeds `metadata.exitedAt` — - * their delta drives the fast-fail classifier. */ -function writeExitedPermanent( - sessionDir: string, - name: string, - opts: { - command?: string; - args?: string[]; - tags?: Record; - lastRespawnAt?: string; - exitedAt?: string; - counter?: number; - commandHash?: string; - status?: string; - }, -): void { - const command = opts.command ?? "sh"; - const args = opts.args ?? ["-c", "exit 1"]; - const tags: Record = { - strategy: "permanent", - ...(opts.tags ?? {}), - }; - if (opts.lastRespawnAt !== undefined) tags["strategy.last-respawn-at"] = opts.lastRespawnAt; - if (opts.counter !== undefined) tags["strategy.consecutive-fast-fails"] = String(opts.counter); - if (opts.commandHash !== undefined) tags["strategy.command-hash"] = opts.commandHash; - if (opts.status !== undefined) tags["strategy.status"] = opts.status; - - fs.writeFileSync(path.join(sessionDir, `${name}.json`), JSON.stringify({ - command, args, displayCommand: command, - cwd: os.tmpdir(), - createdAt: new Date(Date.now() - 10 * 60_000).toISOString(), - exitedAt: opts.exitedAt ?? new Date().toISOString(), - exitCode: 1, - tags, - })); - // Also write a stub events file so appendEventSync doesn't create - // orphaned JSONL. Some listSessions paths key off it. - const evPath = path.join(sessionDir, `${name}.events.jsonl`); - if (!fs.existsSync(evPath)) fs.writeFileSync(evPath, ""); -} - -function commandHash(command: string, args: string[]): string { - const h = createHash("sha256"); - h.update(command); - h.update("\0"); - h.update(args.join("\0")); - return h.digest("hex").slice(0, 16); -} - -afterEach(() => { - for (const dir of sessionDirs) { - try { - for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } - } catch {} - } - sessionDirs = []; -}); - -describe("gc fast-fail respawn cap", () => { - it("dry-run: below-limit fast fails preview as respawn (no state mutation)", () => { - const dir = makeSessionDir(); - const name = uniqueName(); - // Session was respawned 5s ago, exited 1s later → fast fail (1s < 60s). - // Prior counter 1 → this tick would make it 2, still below limit 3. - const last = new Date(Date.now() - 5000).toISOString(); - const exit = new Date(Date.now() - 4000).toISOString(); - writeExitedPermanent(dir, name, { - lastRespawnAt: last, exitedAt: exit, counter: 1, - commandHash: commandHash("sh", ["-c", "exit 1"]), - }); - - const r = runCli(dir, "gc", "--dry-run"); - expect(r.status).toBe(0); - expect(r.stdout).toMatch(new RegExp(`Would respawn: ${name}`)); - expect(r.stdout).not.toContain("Would flap"); - // Dry-run must not mutate tags on disk. - const meta = readMeta(dir, name); - expect(meta.tags["strategy.consecutive-fast-fails"]).toBe("1"); - expect(meta.tags["strategy.status"]).toBeUndefined(); - }); - - it("dry-run: at-limit tick previews Would flap and no respawn", () => { - const dir = makeSessionDir(); - const name = uniqueName(); - const last = new Date(Date.now() - 5000).toISOString(); - const exit = new Date(Date.now() - 4000).toISOString(); - // Prior counter 2 → this tick makes it 3, hits default limit → flap. - writeExitedPermanent(dir, name, { - lastRespawnAt: last, exitedAt: exit, counter: 2, - commandHash: commandHash("sh", ["-c", "exit 1"]), - }); - - const r = runCli(dir, "gc", "--dry-run"); - expect(r.status).toBe(0); - expect(r.stdout).toMatch(new RegExp(`Would flap: ${name} \\(3 fast-fails in 60s, limit 3\\)`)); - expect(r.stdout).not.toMatch(new RegExp(`Would respawn: ${name}`)); - - // No mutation on dry-run. - const meta = readMeta(dir, name); - expect(meta.tags["strategy.status"]).toBeUndefined(); - expect(meta.tags["strategy.consecutive-fast-fails"]).toBe("2"); - }); - - it("at-limit tick persists strategy.status=flapping and emits session_flapping", () => { - const dir = makeSessionDir(); - const name = uniqueName(); - const last = new Date(Date.now() - 5000).toISOString(); - const exit = new Date(Date.now() - 4000).toISOString(); - writeExitedPermanent(dir, name, { - lastRespawnAt: last, exitedAt: exit, counter: 2, - commandHash: commandHash("sh", ["-c", "exit 1"]), - }); - - const r = runCli(dir, "gc"); - expect(r.status).toBe(0); - expect(r.stdout).toContain(`Flapping: ${name} (3 fast-fails in 60s, limit 3)`); - - const meta = readMeta(dir, name); - expect(meta.tags["strategy.status"]).toBe("flapping"); - expect(meta.tags["strategy.consecutive-fast-fails"]).toBe("3"); - - const events = readEvents(dir, name); - const flap = events.find((e) => e.type === "session_flapping"); - expect(flap).toBeDefined(); - expect(flap.counter).toBe(3); - expect(flap.limit).toBe(3); - expect(flap.window).toBe(60); - }); - - it("already-flapping session is silently skipped on subsequent tick", () => { - const dir = makeSessionDir(); - const name = uniqueName(); - writeExitedPermanent(dir, name, { - status: "flapping", - counter: 3, - lastRespawnAt: new Date(Date.now() - 60_000).toISOString(), - exitedAt: new Date(Date.now() - 55_000).toISOString(), - commandHash: commandHash("sh", ["-c", "exit 1"]), - }); - - const r = runCli(dir, "gc"); - expect(r.status).toBe(0); - expect(r.stdout).toContain(`Skipped (flapping): ${name}`); - expect(r.stdout).not.toContain(`Respawned: ${name}`); - - // No new events beyond what was already there. - const events = readEvents(dir, name); - expect(events.filter((e) => e.type === "session_flapping").length).toBe(0); - }); - - it("slow-fail (past window) resets the counter to 0", () => { - const dir = makeSessionDir(); - const name = uniqueName(); - // Last respawn 10 minutes ago, exited 9 minutes ago → 60s live, past - // the default 60s window (using an exit ~60m after respawn). - const last = new Date(Date.now() - 10 * 60_000).toISOString(); - const exit = new Date(Date.now() - 5 * 60_000).toISOString(); - writeExitedPermanent(dir, name, { - lastRespawnAt: last, exitedAt: exit, counter: 2, - commandHash: commandHash("sh", ["-c", "exit 1"]), - }); - - const r = runCli(dir, "gc", "--dry-run"); - expect(r.status).toBe(0); - expect(r.stdout).toMatch(new RegExp(`Would respawn: ${name}`)); - expect(r.stdout).not.toContain("Would flap"); - // (Counter reset is verified via the "no flap at same prior counter" - // outcome — a fast fail at prior=2 would have flapped; slow-fail - // continues to respawn.) - }); - - it("command-hash change auto-resets counter and clears flapping mark", () => { - const dir = makeSessionDir(); - const name = uniqueName(); - // Session is currently flagged flapping under an old command hash. - // Metadata now reports a DIFFERENT command (operator edited pty.toml). - // The classifier should notice the divergence and both reset the - // counter and clear the flapping mark, letting gc respawn. - const oldHash = commandHash("sh", ["-c", "old-command"]); - writeExitedPermanent(dir, name, { - command: "sh", args: ["-c", "exit 1"], // new command - status: "flapping", counter: 3, - lastRespawnAt: new Date(Date.now() - 5000).toISOString(), - exitedAt: new Date(Date.now() - 4000).toISOString(), - commandHash: oldHash, // stale hash - }); - - const r = runCli(dir, "gc", "--dry-run"); - expect(r.status).toBe(0); - expect(r.stdout).toMatch(new RegExp(`Would respawn: ${name}`)); - expect(r.stdout).not.toContain("Skipped (flapping)"); - expect(r.stdout).not.toContain("Would flap"); - }); - - it("per-session strategy.fast-fail-limit overrides the default", () => { - const dir = makeSessionDir(); - const name = uniqueName(); - // Prior counter 1 + fast fail → this tick makes it 2. Default limit - // 3 would let it respawn; per-session limit 2 flaps instead. - writeExitedPermanent(dir, name, { - lastRespawnAt: new Date(Date.now() - 5000).toISOString(), - exitedAt: new Date(Date.now() - 4000).toISOString(), - counter: 1, - commandHash: commandHash("sh", ["-c", "exit 1"]), - tags: { "strategy.fast-fail-limit": "2" }, - }); - - const r = runCli(dir, "gc", "--dry-run"); - expect(r.status).toBe(0); - expect(r.stdout).toMatch(new RegExp(`Would flap: ${name} \\(2 fast-fails in 60s, limit 2\\)`)); - }); - - it("--fast-fail-limit CLI flag applies to sessions without a per-session tag", () => { - const dir = makeSessionDir(); - const name = uniqueName(); - // Prior counter 4 + fast fail → 5. CLI flag lifts limit to 10 → still respawn. - writeExitedPermanent(dir, name, { - lastRespawnAt: new Date(Date.now() - 5000).toISOString(), - exitedAt: new Date(Date.now() - 4000).toISOString(), - counter: 4, - commandHash: commandHash("sh", ["-c", "exit 1"]), - }); - - const r = runCli(dir, "gc", "--dry-run", "--fast-fail-limit=10"); - expect(r.status).toBe(0); - expect(r.stdout).toMatch(new RegExp(`Would respawn: ${name}`)); - expect(r.stdout).not.toContain("Would flap"); - }); - - it("per-session strategy.fast-fail-window overrides the default", () => { - const dir = makeSessionDir(); - const name = uniqueName(); - // Live time = 30s. Default window 60s → fast fail. Per-session window - // 10s → slow fail (30 > 10) → counter resets → respawn, no flap. - writeExitedPermanent(dir, name, { - lastRespawnAt: new Date(Date.now() - 30_000).toISOString(), - exitedAt: new Date(Date.now() - 0).toISOString(), - counter: 2, - commandHash: commandHash("sh", ["-c", "exit 1"]), - tags: { "strategy.fast-fail-window": "10" }, - }); - - const r = runCli(dir, "gc", "--dry-run"); - expect(r.status).toBe(0); - expect(r.stdout).toMatch(new RegExp(`Would respawn: ${name}`)); - expect(r.stdout).not.toContain("Would flap"); - }); - - it("no prior last-respawn-at (first respawn ever) doesn't count as fast fail", () => { - const dir = makeSessionDir(); - const name = uniqueName(); - // Session exited fresh — no last-respawn-at tag yet. Counter absent. - writeExitedPermanent(dir, name, { - exitedAt: new Date().toISOString(), - }); - - const r = runCli(dir, "gc", "--dry-run"); - expect(r.status).toBe(0); - expect(r.stdout).toMatch(new RegExp(`Would respawn: ${name}`)); - expect(r.stdout).not.toContain("Would flap"); - }); -}); diff --git a/tests/gc-parent-child.test.ts b/tests/gc-parent-child.test.ts index 5085c66..888c6c1 100644 --- a/tests/gc-parent-child.test.ts +++ b/tests/gc-parent-child.test.ts @@ -158,10 +158,11 @@ describe("pty gc — parent-child orphan-kill", () => { expect(result.stdout).toContain(`Killed orphan child: ${b}`); }, 15000); - it("combined parent= AND strategy=permanent: child is killed, not respawned", async () => { - // Orphan-kill (step 1) runs BEFORE permanent respawn (step 2), so a - // child with both tags whose parent has died should be removed — - // not respawned as a permanent. + it("combined parent= AND strategy=permanent: child is killed (orphan-kill), metadata removed", async () => { + // Orphan-kill (step 1) removes a child whose parent has died. Post- + // reboot pty gc doesn't respawn permanents anyway (convoy owns + // respawn), so the child is simply gone — its metadata is removed + // by cleanupAll and convoy sees no session to respawn. const dir = makeSessionDir(); const parent = uniqueName("par"); const child = uniqueName("ch"); @@ -175,7 +176,6 @@ describe("pty gc — parent-child orphan-kill", () => { const result = runCli(dir, "gc"); expect(result.status).toBe(0); expect(result.stdout).toContain(`Killed orphan child: ${child}`); - expect(result.stdout).not.toContain(`Respawned: ${child}`); expect(fs.existsSync(path.join(dir, `${child}.json`))).toBe(false); }, 15000); diff --git a/tests/gc-permanent.test.ts b/tests/gc-permanent.test.ts deleted file mode 100644 index 8cff5d8..0000000 --- a/tests/gc-permanent.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { describe, it, expect, afterEach, afterAll } from "vitest"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; -import { spawn, spawnSync } from "node:child_process"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const nodeBin = process.execPath; -const cliPath = path.join(__dirname, "..", "dist", "cli.js"); -const serverModule = path.join(__dirname, "..", "dist", "server.js"); - -const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-gcp-")); -afterAll(() => { - fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); -}); - -let bgPids: number[] = []; -let sessionDirs: string[] = []; - -function makeSessionDir(): string { - const dir = fs.mkdtempSync(path.join(testRoot, "d-")); - sessionDirs.push(dir); - return dir; -} - -let nameCounter = 0; -function uniqueName(): string { - // Short — socket paths must fit under SUN_PATH_MAX (104 bytes). - return `gp${++nameCounter}${Math.random().toString(36).slice(2, 5)}`; -} - -async function startDaemon( - sessionDir: string, - name: string, - command: string, - args: string[] = [], - tags?: Record, -): Promise { - const config = JSON.stringify({ - name, command, args, displayCommand: command, - cwd: os.tmpdir(), rows: 24, cols: 80, tags, - }); - const child = spawn(nodeBin, [serverModule], { - detached: true, - stdio: ["ignore", "ignore", "pipe"], - env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, - }); - let stderr = ""; - child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); - let exitCode: number | null = null; - child.on("exit", (code) => { exitCode = code; }); - (child.stderr as any)?.unref?.(); - child.unref(); - - const socketPath = path.join(sessionDir, `${name}.sock`); - const start = Date.now(); - while (Date.now() - start < 5000) { - if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); - try { - fs.statSync(socketPath); - await new Promise((r) => setTimeout(r, 100)); - bgPids.push(child.pid!); - return child.pid!; - } catch {} - await new Promise((r) => setTimeout(r, 50)); - } - throw new Error("Timeout waiting for daemon"); -} - -function runCli(sessionDir: string, ...args: string[]) { - return spawnSync(nodeBin, [cliPath, ...args], { - env: { ...process.env, PTY_SESSION_DIR: sessionDir }, - encoding: "utf-8", - timeout: 15000, - }); -} - -function readMeta(sessionDir: string, name: string) { - return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); -} - -function readEvents(sessionDir: string, name: string): any[] { - const filePath = path.join(sessionDir, `${name}.events.jsonl`); - try { - const content = fs.readFileSync(filePath, "utf-8"); - return content.trimEnd().split("\n").filter((l) => l.length > 0).map((l) => JSON.parse(l)); - } catch { - return []; - } -} - -afterEach(() => { - for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } - bgPids = []; - for (const dir of sessionDirs) { - try { - for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } - } catch {} - } - sessionDirs = []; -}); - -describe("pty gc — strategy=permanent respawn", () => { - it("respawns an exited permanent session", async () => { - const dir = makeSessionDir(); - const name = uniqueName(); - // First daemon runs `true` and exits immediately — gives us the - // exited-permanent setup gc should react to. - const firstPid = await startDaemon(dir, name, "true", [], { strategy: "permanent" }); - await new Promise((r) => setTimeout(r, 800)); - - const before = readMeta(dir, name); - expect(before.exitedAt).toBeTruthy(); - - const result = runCli(dir, "gc"); - expect(result.status).toBe(0); - expect(result.stdout).toContain(`Respawned: ${name}`); - - // session_respawn event was written before the respawned daemon's - // own session_start, so the event log contains both. Verify the - // respawn event regardless of whether the new `true` daemon has - // also already exited by the time we read. - await new Promise((r) => setTimeout(r, 200)); - const events = readEvents(dir, name); - expect(events.some((e) => e.type === "session_respawn")).toBe(true); - - // The new daemon has a different pid than the first one (proves a - // real respawn happened, not just a no-op). - try { - const pidStr = fs.readFileSync(path.join(dir, `${name}.pid`), "utf-8").trim(); - const pid = parseInt(pidStr, 10); - if (Number.isFinite(pid)) { - bgPids.push(pid); - expect(pid).not.toBe(firstPid); - } - } catch { - // pid file may already be cleaned by the new daemon exiting — that - // just confirms a fresh daemon ran. - } - }, 20000); - - it("does NOT respawn an exited session without strategy=permanent", async () => { - const dir = makeSessionDir(); - const name = uniqueName(); - await startDaemon(dir, name, "true"); - await new Promise((r) => setTimeout(r, 800)); - - const result = runCli(dir, "gc"); - expect(result.status).toBe(0); - expect(result.stdout).not.toContain(`Respawned: ${name}`); - expect(result.stdout).toContain(`Removed: ${name}`); - // Metadata gone (was reaped by step 3). - expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(false); - }, 15000); - - it("--dry-run previews respawn without spawning anything", async () => { - const dir = makeSessionDir(); - const name = uniqueName(); - await startDaemon(dir, name, "true", [], { strategy: "permanent" }); - await new Promise((r) => setTimeout(r, 800)); - - const dry = runCli(dir, "gc", "--dry-run"); - expect(dry.status).toBe(0); - expect(dry.stdout).toContain(`Would respawn: ${name}`); - expect(dry.stdout).toContain("Dry run"); - - // Still exited — no actual respawn happened. Metadata's exitedAt - // should still be present. - const meta = readMeta(dir, name); - expect(meta.exitedAt).toBeTruthy(); - expect(fs.existsSync(path.join(dir, `${name}.pid`))).toBe(false); - }, 15000); - - it("respawn does not loop within one invocation", async () => { - // If a permanent session's command exits immediately (`true`), one - // gc invocation should respawn it exactly once — not loop until the - // process settles or stack-overflow on a sub-second exit. - const dir = makeSessionDir(); - const name = uniqueName(); - await startDaemon(dir, name, "true", [], { strategy: "permanent" }); - await new Promise((r) => setTimeout(r, 800)); - - const result = runCli(dir, "gc"); - expect(result.status).toBe(0); - // Exactly one "Respawned:" line, regardless of whether the new - // daemon has also exited by the time gc returns. - const respawnLines = (result.stdout.match(/^Respawned: /gm) || []).length; - expect(respawnLines).toBe(1); - - // Track whatever pid we ended up with so afterEach can clean up. - try { - const pid = parseInt(fs.readFileSync(path.join(dir, `${name}.pid`), "utf-8").trim(), 10); - if (Number.isFinite(pid)) bgPids.push(pid); - } catch {} - }, 15000); - - it("pty.toml respawn re-reads the toml so command edits take effect", async () => { - // Seed a pty.toml in a tmp dir and use `pty up` to spawn a permanent - // session referencing it. - const dir = makeSessionDir(); - const projectDir = fs.mkdtempSync(path.join(testRoot, "proj-")); - sessionDirs.push(projectDir); - const tomlPath = path.join(projectDir, "pty.toml"); - const v1Marker = `/tmp/pty-gc-permv1-${Date.now()}.flag`; - const v2Marker = `/tmp/pty-gc-permv2-${Date.now()}.flag`; - // v1: write v1Marker and exit. - fs.writeFileSync(tomlPath, `[sessions.perm] -command = "touch ${v1Marker}" -tags = { strategy = "permanent" } -`); - - const projectShort = path.basename(projectDir); - void projectShort; - - const up = runCli(dir, "up", projectDir); - expect(up.status).toBe(0); - // Under the decoupled name/displayName model, the session's on-disk - // name is a random id; the toml-derived "perm" is the displayName. - // Resolve the actual on-disk name via `pty list --json`. - const listOut = runCli(dir, "list", "--json").stdout; - const sessions = JSON.parse(listOut); - const found = sessions.find((s: any) => s.displayName === "perm"); - expect(found).toBeDefined(); - const sessionName: string = found.name; - - // Wait for first run to complete (it touches v1Marker and exits). - const start = Date.now(); - while (Date.now() - start < 5000) { - if (fs.existsSync(v1Marker)) break; - await new Promise((r) => setTimeout(r, 100)); - } - expect(fs.existsSync(v1Marker)).toBe(true); - fs.unlinkSync(v1Marker); - - // Now wait for the daemon to write its exit record. - await new Promise((r) => setTimeout(r, 800)); - const meta = readMeta(dir, sessionName); - expect(meta.exitedAt).toBeTruthy(); - expect(meta.tags?.ptyfile).toBe(tomlPath); - - // Edit the toml to write v2Marker instead. - fs.writeFileSync(tomlPath, `[sessions.perm] -command = "touch ${v2Marker}" -tags = { strategy = "permanent" } -`); - - const result = runCli(dir, "gc"); - expect(result.status).toBe(0); - expect(result.stdout).toContain(`Respawned: ${sessionName} (pty.toml re-read)`); - - // The respawned session should write v2Marker. - const start2 = Date.now(); - while (Date.now() - start2 < 5000) { - if (fs.existsSync(v2Marker)) break; - await new Promise((r) => setTimeout(r, 100)); - } - expect(fs.existsSync(v2Marker)).toBe(true); - try { fs.unlinkSync(v2Marker); } catch {} - - // Track the new pid for teardown. - try { - const pid = parseInt(fs.readFileSync(path.join(dir, `${sessionName}.pid`), "utf-8").trim(), 10); - if (Number.isFinite(pid)) bgPids.push(pid); - } catch {} - }, 25000); -}); diff --git a/tests/pty-root-length-backstop.test.ts b/tests/pty-root-length-backstop.test.ts new file mode 100644 index 0000000..82ed8c9 --- /dev/null +++ b/tests/pty-root-length-backstop.test.ts @@ -0,0 +1,89 @@ +// Fail-loud startup backstop when PTY_ROOT is too long for the socket-path +// kernel limit — errors before any subcommand runs. + +import { describe, it, expect, afterAll } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const nodeBin = process.execPath; +const cliPath = path.join(__dirname, "..", "dist", "cli.js"); + +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-root-len-")); +afterAll(() => { + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); +}); + +describe("PTY_ROOT length backstop", () => { + it("errors at startup when PTY_ROOT is too deep to fit the sockaddr_un limit", () => { + // Build a 95-byte path — well past the 90-byte usable threshold + // (104 − 14 for `/xxxxxxxx.sock`). Doesn't need to actually exist; + // the check is on byte length, not existence. + const tooLong = "/tmp/" + "a".repeat(95); + expect(Buffer.byteLength(tooLong, "utf-8")).toBeGreaterThan(90); + + const r = spawnSync(nodeBin, [cliPath, "list"], { + env: { ...process.env, PTY_ROOT: tooLong, PTY_ROOT_LEGACY_SILENT: "1" }, + encoding: "utf-8", + timeout: 5000, + }); + expect(r.status).not.toBe(0); + expect(r.stderr).toMatch(/PTY_ROOT is too long/); + expect(r.stderr).toMatch(/104-byte kernel limit/); + // Points the finger at the root, not the name. + expect(r.stderr).toMatch(/Shorten the root/); + }); + + it("errors before any subcommand-specific parsing runs", () => { + // Backstop should fire even on a bogus subcommand — the too-long + // root is caught before dispatch. + const tooLong = "/tmp/" + "b".repeat(100); + const r = spawnSync(nodeBin, [cliPath, "definitely-not-a-real-subcommand"], { + env: { ...process.env, PTY_ROOT: tooLong, PTY_ROOT_LEGACY_SILENT: "1" }, + encoding: "utf-8", + timeout: 5000, + }); + expect(r.status).not.toBe(0); + expect(r.stderr).toMatch(/PTY_ROOT is too long/); + // The "unknown command" path is NOT hit; the root check errors first. + expect(r.stderr).not.toMatch(/Unknown command/); + }); + + it("allows a root right at the usable threshold", () => { + // Build a root at exactly 90 bytes — the maximum that leaves room + // for `/xxxxxxxx.sock` in 104. This should succeed (empty list). + const usable = 104 - ("/".length + 8 + ".sock".length); + const okRoot = "/tmp/" + "c".repeat(usable - "/tmp/".length); + expect(Buffer.byteLength(okRoot, "utf-8")).toBe(usable); + fs.mkdirSync(okRoot, { recursive: true }); + try { + const r = spawnSync(nodeBin, [cliPath, "list", "--json"], { + env: { ...process.env, PTY_ROOT: okRoot, PTY_ROOT_LEGACY_SILENT: "1" }, + encoding: "utf-8", + timeout: 5000, + }); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual([]); + } finally { + try { fs.rmSync(okRoot, { recursive: true, force: true }); } catch {} + } + }); + + it("--root overrides an env that would otherwise fail", () => { + // Env is too long; --root override is fine. The startup check reads + // process.env.PTY_ROOT *after* --root parsing has set it, so the + // override wins. + const tooLongEnv = "/tmp/" + "d".repeat(95); + const shortFlag = fs.mkdtempSync(path.join(testRoot, "shorter-")); + const r = spawnSync(nodeBin, [cliPath, "--root", shortFlag, "list", "--json"], { + env: { ...process.env, PTY_ROOT: tooLongEnv, PTY_ROOT_LEGACY_SILENT: "1" }, + encoding: "utf-8", + timeout: 5000, + }); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout)).toEqual([]); + }); +}); diff --git a/tests/up-down.test.ts b/tests/up-down.test.ts deleted file mode 100644 index b7a4d4d..0000000 --- a/tests/up-down.test.ts +++ /dev/null @@ -1,557 +0,0 @@ -import { describe, it, expect, afterEach, afterAll } from "vitest"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; -import { spawnSync } from "node:child_process"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const nodeBin = process.execPath; -const cliPath = path.join(__dirname, "..", "dist", "cli.js"); - -const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-updown-")); -afterAll(() => { - fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); -}); - -let sessionDirs: string[] = []; - -function makeProjectDir(): string { - const dir = fs.mkdtempSync(path.join(testRoot, "proj-")); - sessionDirs.push(dir); - return dir; -} - -function makeSessionDir(): string { - const dir = fs.mkdtempSync(path.join(testRoot, "sd-")); - sessionDirs.push(dir); - return dir; -} - -function writePtyToml(dir: string, content: string): void { - fs.writeFileSync(path.join(dir, "pty.toml"), content); -} - -function runCli(sessionDir: string, ...args: string[]): { status: number | null; stdout: string; stderr: string } { - const result = spawnSync(nodeBin, [cliPath, ...args], { - cwd: os.tmpdir(), - env: { ...process.env, PTY_SESSION_DIR: sessionDir }, - encoding: "utf-8", - timeout: 15000, - }); - return { status: result.status, stdout: result.stdout, stderr: result.stderr }; -} - -function listJson(sessionDir: string): any[] { - const result = runCli(sessionDir, "list", "--json"); - return JSON.parse(result.stdout); -} - -afterEach(() => { - // Kill any sessions we may have started - for (const dir of sessionDirs) { - if (!fs.existsSync(dir)) continue; - try { - const entries = fs.readdirSync(dir); - for (const e of entries) { - if (e.endsWith(".pid")) { - const pidStr = fs.readFileSync(path.join(dir, e), "utf-8").trim(); - const pid = parseInt(pidStr, 10); - if (!isNaN(pid)) { - try { process.kill(pid, "SIGTERM"); } catch {} - } - } - } - // Clean up files - for (const e of entries) { - try { fs.unlinkSync(path.join(dir, e)); } catch {} - } - } catch {} - } - sessionDirs = []; -}); - -describe("pty up", () => { - it("starts all sessions from pty.toml", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -[sessions.one] -command = "cat" - -[sessions.two] -command = "cat" -`); - - const result = runCli(sessDir, "up", projDir); - - expect(result.status).toBe(0); - expect(result.stdout).toContain("one (started)"); - expect(result.stdout).toContain("two (started)"); - expect(result.stdout).toContain("Started 2 sessions"); - - const sessions = listJson(sessDir); - expect(sessions.filter((s: any) => s.status === "running")).toHaveLength(2); - }, 15000); - - it("starts only named sessions", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -[sessions.web] -command = "cat" - -[sessions.worker] -command = "cat" - -[sessions.db] -command = "cat" -`); - - const result = runCli(sessDir, "up", projDir, "web", "db"); - - expect(result.status).toBe(0); - expect(result.stdout).toContain("web (started)"); - expect(result.stdout).toContain("db (started)"); - expect(result.stdout).not.toContain("worker"); - - const sessions = listJson(sessDir); - const running = sessions.filter((s: any) => s.status === "running"); - expect(running).toHaveLength(2); - expect(running.map((s: any) => s.displayName).sort()).toEqual(["db", "web"]); - }, 15000); - - it("propagates env from pty.toml into the spawned session", async () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - const outFile = path.join(projDir, "envcheck.out"); - writePtyToml(projDir, ` -[sessions.envprobe] -command = "echo \\"$MY_VAR|$ANOTHER\\" > '${outFile}'; cat" - -[sessions.envprobe.env] -MY_VAR = "hello" -ANOTHER = "world" -`); - - const result = runCli(sessDir, "up", projDir); - expect(result.status).toBe(0); - expect(result.stdout).toContain("envprobe (started)"); - - // `cat` keeps the session alive after the redirect; wait for the file. - const deadline = Date.now() + 3000; - while (Date.now() < deadline && !fs.existsSync(outFile)) { - await new Promise((r) => setTimeout(r, 50)); - } - expect(fs.existsSync(outFile)).toBe(true); - expect(fs.readFileSync(outFile, "utf-8").trim()).toBe("hello|world"); - }, 15000); - - it("skips already running sessions", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -[sessions.mycat] -command = "cat" -`); - - // Start once - runCli(sessDir, "up", projDir); - - // Start again - const result = runCli(sessDir, "up", projDir); - expect(result.stdout).toContain("mycat (already running)"); - expect(result.stdout).toContain("All sessions already running"); - }, 15000); - - it("syncs tags to already-running sessions on pty up", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - - // Start with no tags - writePtyToml(projDir, ` -[sessions.syncme] -command = "cat" -`); - runCli(sessDir, "up", projDir); - - // Update toml to add tags - writePtyToml(projDir, ` -[sessions.syncme] -command = "cat" -tags = { strategy = "permanent", role = "server" } -`); - - const result = runCli(sessDir, "up", projDir); - expect(result.stdout).toContain("updated tags: strategy=permanent, role=server"); - - // Verify tags were applied - const sessions = listJson(sessDir); - const session = sessions.find((s: any) => s.displayName === "syncme"); - expect(session.tags.strategy).toBe("permanent"); - expect(session.tags.role).toBe("server"); - expect(session.tags.ptyfile).toBeDefined(); - }, 15000); - - it("does not remove manually-added tags on pty up", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -[sessions.manual] -command = "cat" -tags = { role = "server" } -`); - - runCli(sessDir, "up", projDir); - - // Manually add an extra tag - runCli(sessDir, "tag", "manual", "custom=yes"); - - // Run pty up again — should NOT remove the custom tag - runCli(sessDir, "up", projDir); - - const sessions = listJson(sessDir); - const session = sessions.find((s: any) => s.displayName === "manual"); - expect(session.tags.role).toBe("server"); - expect(session.tags.custom).toBe("yes"); - }, 15000); - - it("removes tags that were removed from pty.toml", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - - writePtyToml(projDir, ` -[sessions.remover] -command = "cat" -tags = { role = "server", env = "dev" } -`); - runCli(sessDir, "up", projDir); - - let sessions = listJson(sessDir); - let session = sessions.find((s: any) => s.displayName === "remover"); - expect(session.tags.role).toBe("server"); - expect(session.tags.env).toBe("dev"); - - // Remove env from the toml - writePtyToml(projDir, ` -[sessions.remover] -command = "cat" -tags = { role = "server" } -`); - const result = runCli(sessDir, "up", projDir); - expect(result.stdout).toContain("-env"); - - sessions = listJson(sessDir); - session = sessions.find((s: any) => s.displayName === "remover"); - expect(session.tags.role).toBe("server"); - expect(session.tags.env).toBeUndefined(); - // Metadata tags should still be present - expect(session.tags.ptyfile).toBeDefined(); - expect(session.tags["ptyfile.session"]).toBe("remover"); - }, 20000); - - it("removes all toml tags when the tags table is deleted", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - - writePtyToml(projDir, ` -[sessions.cleared] -command = "cat" -tags = { role = "server", env = "dev" } -`); - runCli(sessDir, "up", projDir); - - // Remove the tags table entirely - writePtyToml(projDir, ` -[sessions.cleared] -command = "cat" -`); - const result = runCli(sessDir, "up", projDir); - expect(result.stdout).toContain("-env"); - expect(result.stdout).toContain("-role"); - - const sessions = listJson(sessDir); - const session = sessions.find((s: any) => s.displayName === "cleared"); - expect(session.tags.role).toBeUndefined(); - expect(session.tags.env).toBeUndefined(); - expect(session.tags.ptyfile).toBeDefined(); - }, 20000); - - it("preserves manually-added tags when toml tags are removed", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - - writePtyToml(projDir, ` -[sessions.mixer] -command = "cat" -tags = { role = "server" } -`); - runCli(sessDir, "up", projDir); - - // Add a manual tag - runCli(sessDir, "tag", "mixer", "custom=yes"); - - // Remove the toml tag - writePtyToml(projDir, ` -[sessions.mixer] -command = "cat" -`); - runCli(sessDir, "up", projDir); - - const sessions = listJson(sessDir); - const session = sessions.find((s: any) => s.displayName === "mixer"); - expect(session.tags.role).toBeUndefined(); - expect(session.tags.custom).toBe("yes"); - }, 20000); - - it("replaces a toml tag's value (not remove+re-add)", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - - writePtyToml(projDir, ` -[sessions.mover] -command = "cat" -tags = { env = "dev" } -`); - runCli(sessDir, "up", projDir); - - writePtyToml(projDir, ` -[sessions.mover] -command = "cat" -tags = { env = "prod" } -`); - const result = runCli(sessDir, "up", projDir); - expect(result.stdout).toContain("env=prod"); - expect(result.stdout).not.toContain("-env"); - - const sessions = listJson(sessDir); - const session = sessions.find((s: any) => s.displayName === "mover"); - expect(session.tags.env).toBe("prod"); - }, 20000); - - it("no output for already-running sessions with matching tags", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -[sessions.unchanged] -command = "cat" -tags = { role = "server" } -`); - - runCli(sessDir, "up", projDir); - - // Run again — tags match, no update message - const result = runCli(sessDir, "up", projDir); - expect(result.stdout).toContain("unchanged (already running)"); - expect(result.stdout).not.toContain("updated tags"); - }, 15000); - - it("propagates tags from pty.toml", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -[sessions.tagged] -command = "cat" -tags = { role = "server", env = "dev" } -`); - - runCli(sessDir, "up", projDir); - - const sessions = listJson(sessDir); - const session = sessions.find((s: any) => s.displayName === "tagged"); - expect(session).toBeDefined(); - expect(session.tags.role).toBe("server"); - expect(session.tags.env).toBe("dev"); - expect(session.tags.ptyfile).toBeDefined(); - }, 15000); - - it("sets cwd to the project directory", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -[sessions.checkdir] -command = "cat" -`); - - runCli(sessDir, "up", projDir); - - const sessions = listJson(sessDir); - const session = sessions.find((s: any) => s.displayName === "checkdir"); - expect(session).toBeDefined(); - expect(session.cwd).toBe(projDir); - }, 15000); - - it("uses prefix for session names", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -prefix = "myapp" - -[sessions.web] -command = "cat" - -[sessions.worker] -command = "cat" -`); - - const result = runCli(sessDir, "up", projDir); - - expect(result.status).toBe(0); - expect(result.stdout).toContain("myapp-web (started)"); - expect(result.stdout).toContain("myapp-worker (started)"); - - const sessions = listJson(sessDir); - const names = sessions.filter((s: any) => s.status === "running").map((s: any) => s.displayName); - expect(names.sort()).toEqual(["myapp-web", "myapp-worker"]); - }, 15000); - - it("filters by short name when prefix is set", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -prefix = "myapp" - -[sessions.web] -command = "cat" - -[sessions.worker] -command = "cat" -`); - - const result = runCli(sessDir, "up", projDir, "web"); - - expect(result.status).toBe(0); - expect(result.stdout).toContain("myapp-web (started)"); - expect(result.stdout).not.toContain("worker"); - - const sessions = listJson(sessDir); - const running = sessions.filter((s: any) => s.status === "running"); - expect(running).toHaveLength(1); - expect(running[0].displayName).toBe("myapp-web"); - }, 15000); - - it("sets ptyfile tags on created sessions", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -[sessions.tracked] -command = "cat" -`); - - runCli(sessDir, "up", projDir); - - const sessions = listJson(sessDir); - const session = sessions.find((s: any) => s.displayName === "tracked"); - expect(session).toBeDefined(); - expect(session.tags.ptyfile).toBe(projDir + "/pty.toml"); - expect(session.tags["ptyfile.session"]).toBe("tracked"); - }, 15000); - - it("errors on unknown session name", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -[sessions.real] -command = "cat" -`); - - const result = runCli(sessDir, "up", projDir, "fake"); - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("Unknown session: fake"); - expect(result.stderr).toContain("Available: real"); - }, 15000); - - it("errors when no pty.toml exists", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - - const result = runCli(sessDir, "up", projDir); - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("No pty.toml found"); - }, 15000); - - it("errors on pty.toml with no sessions", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, `# empty config\n`); - - const result = runCli(sessDir, "up", projDir); - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("No sessions defined"); - }, 15000); - - it("errors on session without a command", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -[sessions.bad] -tags = { foo = "bar" } -`); - - const result = runCli(sessDir, "up", projDir); - expect(result.status).not.toBe(0); - expect(result.stderr).toContain('missing a "command" field'); - }, 15000); -}); - -describe("pty down", () => { - it("stops all running sessions from pty.toml", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -[sessions.alpha] -command = "cat" - -[sessions.beta] -command = "cat" -`); - - // Start them - runCli(sessDir, "up", projDir); - let sessions = listJson(sessDir); - expect(sessions.filter((s: any) => s.status === "running")).toHaveLength(2); - - // Stop them - const result = runCli(sessDir, "down", projDir); - expect(result.status).toBe(0); - expect(result.stdout).toContain("alpha (stopped)"); - expect(result.stdout).toContain("beta (stopped)"); - expect(result.stdout).toContain("Stopped 2 sessions"); - }, 15000); - - it("stops only named sessions", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -[sessions.keep] -command = "cat" - -[sessions.stop] -command = "cat" -`); - - runCli(sessDir, "up", projDir); - - const result = runCli(sessDir, "down", projDir, "stop"); - expect(result.stdout).toContain("stop (stopped)"); - expect(result.stdout).not.toContain("keep"); - - // "keep" should still be running - const sessions = listJson(sessDir); - const running = sessions.filter((s: any) => s.status === "running"); - expect(running).toHaveLength(1); - expect(running[0].displayName).toBe("keep"); - }, 15000); - - it("reports when nothing to stop", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -[sessions.ghost] -command = "cat" -`); - - const result = runCli(sessDir, "down", projDir); - expect(result.stdout).toContain("No sessions to stop"); - }, 15000); -}); diff --git a/tests/up-name-decouple.test.ts b/tests/up-name-decouple.test.ts deleted file mode 100644 index 55894aa..0000000 --- a/tests/up-name-decouple.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, it, expect, afterEach, afterAll } from "vitest"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; -import { spawnSync } from "node:child_process"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const nodeBin = process.execPath; -const cliPath = path.join(__dirname, "..", "dist", "cli.js"); - -const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-updc-")); -afterAll(() => { - fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); -}); - -const sessionDirs: string[] = []; - -function makeProjectDir(): string { - const dir = fs.mkdtempSync(path.join(testRoot, "p-")); - return dir; -} - -function makeSessionDir(): string { - const dir = fs.mkdtempSync(path.join(testRoot, "s-")); - sessionDirs.push(dir); - return dir; -} - -function writePtyToml(dir: string, content: string): void { - fs.writeFileSync(path.join(dir, "pty.toml"), content); -} - -function runCli(sessionDir: string, ...args: string[]): { status: number | null; stdout: string; stderr: string } { - const r = spawnSync(nodeBin, [cliPath, ...args], { - cwd: os.tmpdir(), - env: { ...process.env, PTY_SESSION_DIR: sessionDir }, - encoding: "utf-8", - timeout: 15000, - }); - return { status: r.status, stdout: r.stdout, stderr: r.stderr }; -} - -function listJson(sessionDir: string): any[] { - const r = runCli(sessionDir, "list", "--json"); - if (!r.stdout.trim()) return []; - return JSON.parse(r.stdout); -} - -afterEach(() => { - for (const dir of sessionDirs) { - if (!fs.existsSync(dir)) continue; - try { - const entries = fs.readdirSync(dir); - for (const e of entries) { - if (e.endsWith(".pid")) { - const pid = parseInt(fs.readFileSync(path.join(dir, e), "utf-8").trim(), 10); - if (!isNaN(pid)) { try { process.kill(pid, "SIGTERM"); } catch {} } - } - } - for (const e of entries) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } - } catch {} - } - sessionDirs.length = 0; -}); - -describe("pty up: on-disk name decoupled from display label", () => { - it("spawns sessions with a random short id; displayName carries the toml-derived label", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -prefix = "myapp" - -[sessions.web] -command = "cat" -`); - - runCli(sessDir, "up", projDir); - const sessions = listJson(sessDir); - expect(sessions).toHaveLength(1); - const s = sessions[0]; - // On-disk name is a short random base32-ish id, NOT "myapp-web". - expect(s.name).toMatch(/^[a-z0-9]{6,12}$/); - expect(s.name).not.toBe("myapp-web"); - // Display label is the prefix-shortName combo. - expect(s.displayName).toBe("myapp-web"); - expect(s.tags["ptyfile.session"]).toBe("web"); - }, 15000); - - it("supports a long prefix that would have exceeded the sock path limit under the old model", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - // 90-char prefix + "-web" + ".sock" + session-dir would blow past 104. - const longPrefix = "p".repeat(90); - writePtyToml(projDir, ` -prefix = "${longPrefix}" - -[sessions.web] -command = "cat" -`); - - const result = runCli(sessDir, "up", projDir); - expect(result.status).toBe(0); - const sessions = listJson(sessDir); - expect(sessions).toHaveLength(1); - expect(sessions[0].displayName).toBe(`${longPrefix}-web`); - // On-disk name is short. - expect(sessions[0].name.length).toBeLessThan(20); - }, 15000); - - it("re-running pty up matches existing sessions by (ptyfile, ptyfile.session) tag pair, not by name", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -[sessions.svc] -command = "cat" -`); - - runCli(sessDir, "up", projDir); - const firstId = listJson(sessDir).find((s: any) => s.displayName === "svc")!.name; - - // Re-run pty up; should detect already-running and NOT spawn a second. - const second = runCli(sessDir, "up", projDir); - expect(second.stdout).toContain("svc (already running)"); - const sessions = listJson(sessDir).filter((s: any) => s.displayName === "svc"); - expect(sessions).toHaveLength(1); - expect(sessions[0].name).toBe(firstId); - }, 15000); - - it("honors pty.toml `id = \"...\"` to pin the on-disk identifier", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -[sessions.svc] -id = "pinned" -command = "cat" -`); - - runCli(sessDir, "up", projDir); - const sessions = listJson(sessDir); - expect(sessions).toHaveLength(1); - expect(sessions[0].name).toBe("pinned"); - expect(sessions[0].displayName).toBe("svc"); - }, 15000); - - it("honors pty.toml `display_name = \"...\"` to override the default label", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -prefix = "myapp" - -[sessions.web] -display_name = "My Web Server" -command = "cat" -`); - - runCli(sessDir, "up", projDir); - const sessions = listJson(sessDir); - expect(sessions).toHaveLength(1); - expect(sessions[0].displayName).toBe("My Web Server"); - expect(sessions[0].name).toMatch(/^[a-z0-9]{6,12}$/); - }, 15000); - - it("operations (kill, peek, send) resolve by displayName for toml-spawned sessions", () => { - const projDir = makeProjectDir(); - const sessDir = makeSessionDir(); - writePtyToml(projDir, ` -[sessions.svc] -command = "cat" -`); - - runCli(sessDir, "up", projDir); - // Resolve by displayName - const killResult = runCli(sessDir, "kill", "svc"); - expect(killResult.status).toBe(0); - // No running sessions left - const sessions = listJson(sessDir).filter((s: any) => s.status === "running"); - expect(sessions).toHaveLength(0); - }, 15000); -});