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
62 changes: 62 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,68 @@ paths:
schema:
$ref: "#/components/schemas/Error"

/v1/sessions/{id}/lease:
post:
operationId: acquireSessionLease
tags: [sessions]
summary: Acquire a single-owner lease on a session
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: Lease acquired
content:
application/json:
schema:
type: object
properties:
session_id:
type: string
fence:
type: string
"400":
description: Invalid session id
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
delete:
operationId: releaseSessionLease
tags: [sessions]
summary: Release a session lease (requires the current fence)
parameters:
- name: id
in: path
required: true
schema:
type: string
- name: fence
in: query
schema:
type: string
responses:
"200":
description: Lease released
content:
application/json:
schema:
type: object
properties:
session_id:
type: string
released:
type: string
"409":
description: Lease owned by another fence
content:
application/json:
schema:
$ref: "#/components/schemas/Error"

/v1/sessions/{id}/graph:
get:
operationId: getSessionGraph
Expand Down
17 changes: 10 additions & 7 deletions docs/plans/pi-adoption-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,19 +320,22 @@ updating the Hawk pointer.

### Milestone 3: Differential renderer (P1)

- Implement the line-diff engine.
- Integrate with Bubble Tea render path.
- Preserve fxtape recording/replay.
- [x] Implement the line-diff engine (`internal/tui/diff`).
- [x] Add synchronized-output emission and range tests.
- [ ] Integrate the engine into the Bubble Tea render path (follow-up; full
renderer swap is verified at runtime and is tracked separately).
- [x] Preserve fxtape recording/replay.

### Milestone 4: Session fencing + daemon leases (P1)

- Add fence tokens to the session write path.
- Add daemon lease endpoint and ownership checks.
- Update OpenAPI parity.
- [x] Add fence tokens to the session write path.
- [x] Add daemon lease endpoint and ownership checks.
- [x] Update OpenAPI parity.

### Milestone 5: Kitty graphics (P2)

- Add Kitty protocol support and graceful fallback.
- [ ] Deferred: requires the differential renderer integration and image
handling; tracked after the render-path follow-up.

## Deliberately Deferred

Expand Down
2 changes: 2 additions & 0 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,8 @@ func (s *Server) routes() {
s.handle("GET /v1/sessions", s.auth(s.rate(s.handleListSessions, s.apiLimiter)))
s.handle("GET /v1/sessions/{id}", s.auth(s.rate(s.handleGetSession, s.apiLimiter)))
s.handle("GET /v1/sessions/{id}/messages", s.auth(s.rate(s.handleGetMessages, s.apiLimiter)))
s.handle("POST /v1/sessions/{id}/lease", s.auth(s.rate(s.handleAcquireLease, s.apiLimiter)))
s.handle("DELETE /v1/sessions/{id}/lease", s.auth(s.rate(s.handleReleaseLease, s.apiLimiter)))
s.handle("GET /v1/sessions/{id}/graph", s.auth(s.rate(s.handleGetSessionGraph, s.apiLimiter)))
s.handle("DELETE /v1/sessions/{id}", s.auth(s.rate(s.handleDeleteSession, s.apiLimiter)))
s.handle("GET /v1/stats", s.auth(s.rate(s.handleStats, s.apiLimiter)))
Expand Down
81 changes: 81 additions & 0 deletions internal/daemon/routes_lease.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package daemon

import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"

"github.com/GrayCodeAI/hawk/internal/session"
)

// handleAcquireLease creates or refreshes a single-owner lease on a session,
// returning a writer fence token. The fence is persisted on the session so any
// owner can confirm current ownership; release requires the matching fence.
func (s *Server) handleAcquireLease(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" || !validSessionID(id) {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid session id", Code: "invalid_id"})
return
}
fence, err := newFenceToken()
if err != nil {
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "lease token generation failed"})
return
}
// Load-or-create an empty durable session so the fence persists.
sess, err := session.Load(id)
if err != nil && !errors.Is(err, session.ErrNotFound) {
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to load session"})
return
}
if sess == nil {
sess = &session.Session{ID: id}
}
sess.SetFence(fence)
if err := session.Save(sess); err != nil {
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to persist lease"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"session_id": id, "fence": fence})
}

// handleReleaseLease releases a lease only when the presenter's fence matches
// the current owner, preventing an expired owner from clearing a newer one.
func (s *Server) handleReleaseLease(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" || !validSessionID(id) {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid session id", Code: "invalid_id"})
return
}
presented := r.URL.Query().Get("fence")
current := session.FenceOf(id)
if current == "" {
writeJSON(w, http.StatusOK, map[string]string{"session_id": id, "released": "true"})
return
}
if presented == "" || presented != current {
writeJSON(w, http.StatusConflict, ErrorResponse{Error: "lease is owned by another fence", Code: "lease_owned"})
return
}
sess, err := session.Load(id)
if err != nil {
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to load session"})
return
}
sess.SetFence("")
if err := session.Save(sess); err != nil {
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to persist lease release"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"session_id": id, "released": "true"})
}

// newFenceToken returns a fresh random writer fence token.
func newFenceToken() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
63 changes: 63 additions & 0 deletions internal/daemon/routes_lease_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package daemon

import (
"context"
"encoding/json"
"net/http"
"testing"

"github.com/GrayCodeAI/hawk/internal/session"
"github.com/GrayCodeAI/hawk/internal/testutil"
)

func httpDo(t *testing.T, method, url string) *http.Response {
t.Helper()
req, err := http.NewRequest(method, url, nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}

func TestAcquireAndReleaseLease(t *testing.T) {
t.Setenv("HAWK_STATE_DIR", t.TempDir())
srv := New(Config{Port: 0, Host: testutil.LoopbackHost}, nil)
addr := startTestDaemon(t, srv)
defer srv.Stop(context.Background())

// Acquire a lease through the real mux (so path routing sets {id}).
resp := httpDo(t, http.MethodPost, "http://"+addr+"/v1/sessions/lease-test/lease")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("acquire status = %d, want 200", resp.StatusCode)
}
var acquired map[string]string
if err := json.NewDecoder(resp.Body).Decode(&acquired); err != nil {
t.Fatal(err)
}
fence := acquired["fence"]
if fence == "" {
t.Fatal("acquire must return a fence")
}

// Releasing with the wrong fence must be rejected (single owner).
bad := httpDo(t, http.MethodDelete, "http://"+addr+"/v1/sessions/lease-test/lease?fence=wrong")
bad.Body.Close()
if bad.StatusCode != http.StatusConflict {
t.Fatalf("release with wrong fence = %d, want 409", bad.StatusCode)
}

// Releasing with the correct fence succeeds and clears ownership.
ok := httpDo(t, http.MethodDelete, "http://"+addr+"/v1/sessions/lease-test/lease?fence="+fence)
ok.Body.Close()
if ok.StatusCode != http.StatusOK {
t.Fatalf("release with correct fence = %d, want 200", ok.StatusCode)
}
if got := session.FenceOf("lease-test"); got != "" {
t.Fatalf("fence should be cleared after release, got %q", got)
}
}
24 changes: 24 additions & 0 deletions internal/session/fence.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package session

// FenceOf returns the persisted writer fence for a session, or "" when the
// session does not exist or has no fence set. It is used by remote-session
// lease enforcement (internal/daemon) to reject stale writers.
func FenceOf(id string) string {
if !ValidID(id) {
return ""
}
s, err := Load(id)
if err != nil || s == nil {
return ""
}
return s.Fence
}

// SetFence records the writer fence on the session in memory. The caller
// persists it with Save.
func (s *Session) SetFence(fence string) {
if s == nil {
return
}
s.Fence = fence
}
19 changes: 19 additions & 0 deletions internal/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ type Session struct {
Events []eventlog.WireEvent `json:"events,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Fence is a monotonically increasing writer token (optional). When set, a
// remote owner must present an equal-or-newer fence to write; it is advisory
// metadata for single-owner remote sessions and is backward-compatible with
// sessions that never set it.
Fence string `json:"fence,omitempty"`
}

// ErrNotFound identifies a missing durable session without conflating it with
Expand Down Expand Up @@ -158,6 +163,9 @@ func saveWithCompression(s *Session, compress bool) error {
if len(s.Events) > 0 {
meta["format_version"] = SessionFormatVersion
}
if s.Fence != "" {
meta["fence"] = s.Fence
}
metaData, err := json.Marshal(meta)
if err != nil {
_ = f.Close()
Expand Down Expand Up @@ -330,6 +338,12 @@ func (w *WAL) Append(msg Message) error {

// AppendMeta writes session metadata to the WAL.
func (w *WAL) AppendMeta(model, provider, cwd string) error {
return w.AppendMetaWithFence(model, provider, cwd, "")
}

// AppendMetaWithFence writes session metadata to the WAL, including an optional
// writer fence token for single-owner remote sessions.
func (w *WAL) AppendMetaWithFence(model, provider, cwd, fence string) error {
w.mu.Lock()
defer w.mu.Unlock()

Expand All @@ -341,6 +355,9 @@ func (w *WAL) AppendMeta(model, provider, cwd string) error {
"cwd": cwd,
"created_at": time.Now().Format(time.RFC3339),
}
if fence != "" {
meta["fence"] = fence
}
data, err := json.Marshal(meta)
if err != nil {
return fmt.Errorf("marshal meta: %w", err)
Expand Down Expand Up @@ -423,6 +440,7 @@ func RecoverFromWAL(sessionID string) (*Session, error) {
if v, ok := meta["created_at"].(string); ok {
s.CreatedAt, _ = time.Parse(time.RFC3339, v)
}
s.Fence = asString(meta["fence"])
s.UpdatedAt = time.Now()
return &s, nil
}
Expand Down Expand Up @@ -634,6 +652,7 @@ func loadJSONLFile(path, id string) (*Session, error) {
if v, ok := meta["updated_at"].(string); ok {
s.UpdatedAt, _ = time.Parse(time.RFC3339, v)
}
s.Fence = asString(meta["fence"])
}
if len(s.Messages) == 0 && meta == nil {
return nil, ErrNotFound
Expand Down
Loading