Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion blueprint/build-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,3 @@
- [x] 16b. **Project facts probe** - additive optional `ProjectGroup` fields (explicit contract decision): project description (package.json/Cargo.toml, README first line fallback), dev/build/start scripts, key deps, workspaces, node version, plus last commit age and dirty/clean from local git. Description renders inline in the Projects tab headers; full facts live in a project drawer (decided 2026-07-08: slide-over like the inspect drawer, no routing/detail page - the app has no router and the drawer pattern is established).
- [x] 16c. **Project actions** - open in editor (new endpoint), copy cd command, and stop-all-services-in-project built on the feature 12 confirmation contract. Editor decided 2026-07-09: config `editor` key, `code` default. Shipped with two found-in-verification fixes: tilde expansion on `/api/open`/`/api/reveal`, stop-all dialog at App level.
- [x] 17. **Signed Windows releases** - Authenticode-sign `portdoc.exe` in the release pipeline so Smart App Control and SmartScreen accept installs without overrides (v0.1.0 finding: SAC hard-blocks the unsigned binary on fresh Win11). Decided 2026-07-10: Azure Artifact Signing, Basic tier ($9.99/mo, 5k signatures), individual validation as "Brad Traversy". Integration is the proven unzip/sign/re-zip step on the Windows build leg (azure/artifact-signing-action@v2, OIDC login, RFC3161 timestamp) with `allow-dirty = ["ci"]`, swapped for cargo-dist's native `azure-windows-sign` when PR #2396 merges. Blocked on Brad's one-time Azure identity validation (1-20 business days); ships as v0.1.1. macOS signing/notarization deliberately deferred (curl/brew paths dodge Gatekeeper; needs the $99/yr Apple Developer membership and rcodesign - decide later).
- [ ] 18. **Cross-platform restart service** - add a confirmed Restart action beside single-service Stop controls on Linux, macOS, and Windows. Include Windows normal and forced process termination parity, revalidate the live service, capture its executable, argument vector, and working directory on the server before stopping it, wait for the old listener to release, accept a matching supervisor-created replacement, otherwise relaunch without a shell, and verify the same port returns. Disable restart when exact launch metadata is unavailable; PortDoc itself, Docker rows, unknown owners, and batch restart stay out of scope.
226 changes: 6 additions & 220 deletions blueprint/context/current-feature.md
Original file line number Diff line number Diff line change
@@ -1,222 +1,8 @@
# Feature: Restart service
# Current Feature

**From build-plan:** feature 18
**Status:** in progress
> **Generated file.** Holds the one feature or fix being built right now. Run
> `/feature <number-or-name>` to spec a build-plan feature, or `/fix "<bug>"` for
> an ad-hoc fix. Build one thing at a time; `/complete` archives it (to
> `blueprint/history/features/` or `blueprint/history/fixes/`) and resets this file.

## Goal

Add a Restart action beside PortDoc's existing single-service Stop actions. A
restart must stop the currently verified owner, then either recognize that a
supervisor already replaced it or safely relaunch the same executable and
arguments from the same working directory.

This is a best-effort developer convenience, not a universal process manager.
The action is available only when PortDoc can capture enough server-side launch
metadata to reproduce the process without guessing.

## In scope

- Add Restart beside the existing single-service Stop actions in service rows,
the Services table, the inspect drawer, and stale-service callouts.
- Show a confirmation dialog with the current process, PID, port, command, and
working directory before anything is stopped.
- Re-probe and verify `service_id` plus PID immediately before acting.
- Capture the executable, argument vector, and working directory on the server
before sending a signal.
- Send the normal stop request first and verify the claimed PID releases the
port.
- If a different PID takes the same port, compare its executable, arguments,
and working directory with the captured launch identity. Report a matching
process as a successful supervisor restart. Report a non-matching process as
a port replacement. Never launch a duplicate in either case.
- Otherwise spawn the captured executable directly with its captured arguments
and working directory, without a shell.
- Add server-derived `restartable` and `restart_blocked_reason` fields to each
service so the UI never guesses eligibility from the display command. Keep
executable paths and argument vectors out of the JSON response.
- Poll for the same port to return and report the replacement PID when readable.
- Offer Force restart only after the original process ignores the normal stop,
using the same second-confirmation rule as Force kill.
- Keep existing error states visible, including changed service, permission
denied, missing launch metadata, spawn failure, and a replacement that does
not return to the expected port.
- Add Windows normal and forced termination behind the existing action
boundary so Stop and Restart use the same safety contract on every platform.
- Support Linux, macOS, and Windows with the same visible behavior.

## Out of scope

- Restarting PortDoc itself.
- Docker or Compose restart. Killing `docker-proxy` is not container control.
- Restarting services without a readable PID, executable, argument vector, or
working directory.
- Accepting a command, path, arguments, or working directory from the browser.
- Reconstructing a command by parsing the display string or running `sh -c`.
- Copying the original process environment. The relaunched process inherits
PortDoc's environment and may still load project-local `.env` files normally.
- Restart all for a project, background service management, saved launch
recipes, log capture, terminal attachment, or automatic restart policies.

## Build loop

Build one step at a time, never the whole feature at once.

1. Plan mode lays out the step before any code.
2. The AI implements just that step.
3. It shows the diff, not full files; you read it and understand it.
4. You approve, then choose whether to commit a checkpoint or roll straight on.
Checkpoints are optional; `/complete` makes the feature-level commit.

Never accept a step you have not read. If a diff is too big to review, split the
step.

## Build steps

- [x] **Step 1 - Capture and relaunch a process safely** - Extend the Linux,
macOS, and Windows probes with a non-serialized launch description containing
the executable, original argument vector, and working directory. Derive restart
eligibility and a blocked reason without exposing the launch description. Add
action helpers that spawn the executable directly, use null standard input,
inherit PortDoc's output destinations, prevent zombie children, and classify
whether the expected port returned under a matching or unrelated new PID.
*Done when:* focused Rust tests prove argument boundaries are preserved without
a shell, missing launch fields are rejected, eligibility reasons are stable,
and replacement polling distinguishes matching, unrelated, and absent
listeners.
- [x] **Step 2 - Add Windows stop parity** - Implement normal and forced Windows
termination behind the existing action boundary. Invoke `taskkill` directly,
never through a shell: `/PID <pid>` first and `/F /PID <pid>` only after the
second confirmation. Do not use `/T`, because PortDoc targets only the verified
listener PID. Replace Unix-specific UI wording with platform-neutral stop and
force language. *Done when:* Windows-focused tests cover argument construction,
a stopped child, a forced child, a missing PID, and command failure, while the
existing Linux and macOS signal tests remain green.
- [x] **Step 3 - Add the verified restart API** - Add `POST /api/restart` with
the same service and PID revalidation used by `/api/stop`. Capture launch
metadata before signaling, use the normal stop and release polling, detect a
supervisor-created replacement before spawning, then launch and verify the
expected port. Preserve the second-confirmation force path. *Done when:*
request-validation and restart-state tests cover stale identity, self-refusal,
matching supervisor replacement, unrelated port replacement, failed release,
spawn failure, successful replacement, and a spawned process that never
listens.
- [x] **Step 4 - Build the restart dialog and client contract** - Add the typed
restart request and result contract, an App-level restart context, and a
confirmation dialog that shows the exact target, working progress, force
escalation, success, unrelated port replacement, and actionable errors. Use
the server-derived eligibility fields for disabled states. *Done when:* the
frontend builds, no command data is sent by the client, and every backend
outcome has an explicit UI state.
- [x] **Step 5 - Place and verify Restart actions** - Put Restart beside
single-service Stop controls in service rows, the Services table, the inspect
drawer, and stale-service callouts. Use the server-derived eligibility fields
to disable unavailable actions with a clear reason.
*Done when:* `cargo test`, `cargo clippy`, `npm run lint`, and
`npm run build` pass, and browser evidence shows one successful restart plus
one disabled or failed case without console errors.

## Files / areas

- `src/probe/mod.rs`
- `src/probe/linux.rs`
- `src/probe/macos.rs`
- `src/probe/windows.rs`
- `src/action.rs`
- `src/snapshot.rs`
- `src/adapter.rs`
- `web/src/lib/types.ts`
- `src/main.rs`
- `web/src/App.tsx`
- `web/src/lib/derive.ts`
- `web/src/lib/restart.ts` (new)
- `web/src/components/RestartDialog.tsx` (new)
- `web/src/components/ServiceRow.tsx`
- `web/src/components/ServicesTable.tsx`
- `web/src/components/InspectDrawer.tsx`
- `web/src/components/Callouts.tsx`

## Data / contracts

- `POST /api/restart`
- Request:

```json
{
"service_id": "svc-3000-node",
"pid": 1234,
"force": false
}
```

- Success response:

```json
{
"outcome": "restarted",
"pid": 5678
}
```

- `outcome` is one of:
- `restarted` - PortDoc launched a replacement and observed it on the port.
- `supervisor_restarted` - a matching replacement PID claimed the port after
the stop, so PortDoc did not launch a duplicate.
- `still_listening` - the original PID ignored the requested signal.
- `port_replaced` - a different process claimed the port but its launch
identity does not match the stopped service. PortDoc did not launch a
duplicate.
- `not_listening` - the replacement was launched but did not claim the
expected port within the bounded wait.
- `pid` is optional and contains the replacement listener PID when readable.
- Error responses use the existing `{ "error": "message" }` shape and suitable
`400`, `403`, `409`, or `500` status codes.
- The browser never supplies executable, arguments, command text, or cwd.
- `Service` gains two additive fields:
- `restartable` (boolean) - true only when the current platform and captured
launch description support restart.
- `restart_blocked_reason` (optional string) - protected self, Docker-managed,
unknown owner, incomplete launch metadata, or unsupported platform.
- Raw executable, argument, and launch metadata remain internal and are not
added to `DevSnapshot` JSON.

## Testing

- Rust logic is covered by `cargo test` in the same step that introduces it.
- Test direct spawning with arguments containing spaces and shell characters to
prove no shell interpretation occurs.
- Test service and PID revalidation before any signal.
- Test normal release, force escalation, matching supervisor replacement, spawn
failure, unrelated port replacement, successful return on the expected port,
and bounded no-listener timeout.
- Run the Rust test matrix on `ubuntu-latest`, `macos-latest`, and
`windows-latest` before the feature is complete.
- Manually verify one successful restart and one blocked or failed restart on
Linux, macOS, and Windows before release. Brad starts the test servers on each
machine.
- Keep real process and socket fixtures short-lived and bounded so failures
cannot hang the suite.
- The frontend has no test runner. Verify it with `npm run lint`,
`npm run build`, and browser evidence against a server Brad starts.

## Notes for the AI

- Preserve the existing `/api/stop` behavior and reuse its validation and signal
rules rather than creating a weaker parallel path.
- Treat the server-side re-probe as authoritative. Client-side eligibility is
only a convenience and must not replace backend checks.
- Never parse the display command back into arguments.
- Never execute restart data through a shell.
- On Windows, call `taskkill` with structured arguments through
`std::process::Command`. Never use a shell and never add `/T` to the request.
A normal Windows stop is best-effort; if the PID remains, the existing second
confirmation offers the forced path.
- Capture launch metadata before signaling because the process may disappear
immediately.
- Re-probe immediately before spawning. If another PID already owns the port,
compare its launch identity with the captured one. Report
`supervisor_restarted` only for a match, otherwise report `port_replaced`. Do
not create a duplicate in either case.
- A spawned process that does not return to the expected port is a visible
`not_listening` outcome, not a silent success.
- Do not touch the pre-existing uncommitted edits in `AGENTS.md`,
`src/adapter.rs`, or `src/config.rs`.
_Nothing in progress. Run `/feature` or `/fix` to start._
53 changes: 53 additions & 0 deletions blueprint/history/fixes/remove-unsafe-restart-action.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Fix: Remove unsafe restart action

**Type:** Fix (not a build-plan item)
**Status:** complete

## The problem

Manual Linux validation of `v0.1.2-rc.1` showed that Restart is not safe for a
supervised development server. PortDoc targeted the process listening on the
port instead of the process supervisor, left the original watcher tree behind,
and launched a non-equivalent replacement attached to PortDoc's terminal.

The current implementation cannot reliably reconstruct ownership or supervisor
behavior for arbitrary development servers across Linux, macOS, and Windows.
It can therefore stop the wrong layer of a process tree and make PortDoc the
replacement process's accidental supervisor.

## The fix

Revert the Restart feature introduced by merge commit `575b819`. Remove its API,
process relaunch behavior, probe metadata, snapshot fields, and frontend actions
on every supported platform.

Preserve the existing safe Stop behavior, all release and installer work, and
Windows signing. Keep `v0.1.2-rc.1` as an immutable failed prerelease rather than
rewriting published history.

A future Restart design requires an explicit, reviewable supervisor or project
launch contract. That redesign is not part of this rollback.

## Build steps

- [x] **Step 1 - Revert Restart without disturbing other shipped work** - Reverse
the production and UI changes from merge commit `575b819`, keeping this fix
spec while resolving the Blueprint file touched by the original merge.
Preserve `/api/stop`, the existing Stop and Force stop controls, cargo-dist,
installers, Windows signing, and the separate `v0.1.2-rc.1` version changes.
*Done when:* PortDoc exposes no Restart action or `/api/restart` endpoint, no
longer captures or spawns replacement launch data, and the remaining diff
contains no unrelated rollback.

## Verify

- Run `cargo fmt --check`.
- Run `cargo test`.
- Run `cargo clippy -- -D warnings`.
- Run `npm run lint` in `web/`.
- Run `npm run build` in `web/`.
- Confirm the frontend contains Stop controls but no Restart controls.
- Confirm `/api/stop` and its force path remain unchanged.
- Confirm the pull request CI matrix passes on Ubuntu, macOS, and Windows.
- Do not push, publish, retag, or create a replacement release as part of this
fix.
Loading
Loading