diff --git a/internal/server/document_revision_handlers.go b/internal/server/document_revision_handlers.go index 916cb9e5..d7a3b1bc 100644 --- a/internal/server/document_revision_handlers.go +++ b/internal/server/document_revision_handlers.go @@ -1,8 +1,10 @@ package server import ( + "context" "encoding/json" "errors" + "fmt" "net/http" "strconv" "strings" @@ -198,11 +200,20 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re } // 1) Controlled: author duty + lease WITHOUT writing body (fail-closed before ledger). + // This UI has no edit-session heartbeat: release the request-scoped human lease on every exit + // (success, 409, projection failure) so a 15m residual cannot block a later squad run. if kind == "controlled" && s.svc != nil && s.svc.DocGov() != nil { if err := s.svc.DocGov().AssertHumanCanEdit(r.Context(), docID, user.ID, docgov.RoleAuthor); err != nil { writeDocGovError(w, err) return } + holderID := fmt.Sprintf("%d", user.ID) + if user.ID <= 0 { + holderID = "human_anon" + } + defer func() { + _, _ = s.svc.DocGov().ReleaseLease(context.Background(), docID, string(docgov.LeaseHolderHuman), holderID) + }() } // 2) Bootstrap if needed, then commit ledger. No fact-source mutation yet. diff --git a/internal/server/document_revision_patch_order_test.go b/internal/server/document_revision_patch_order_test.go index 558951b2..d06bc709 100644 --- a/internal/server/document_revision_patch_order_test.go +++ b/internal/server/document_revision_patch_order_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "chat-agent-system/internal/docgov" "chat-agent-system/internal/docrevision" ) @@ -64,6 +65,7 @@ func TestDocRevision_PatchOrder_AssertBeforeLedger_ProjectionFailureKeepsLedger( if after.HeadRevisionNo <= beforeRev && after.HeadSnapshotID == beforeSnap { t.Fatalf("ledger should commit before projection; before rev=%d snap=%d after=%+v", beforeRev, beforeSnap, after) } + assertHumanLeaseReleasedSquadCanAcquire(t, srv, docID) } func TestDocRevision_PatchOrder_LeaseDeniedDoesNotAdvanceLedger(t *testing.T) { @@ -150,4 +152,73 @@ func TestDocRevision_PatchOrder_SaveDocumentContentSetsAuthorAudit(t *testing.T) if _, err := os.Stat(filepath.Join(workDir, "output.md")); err != nil { t.Fatalf("output.md should exist after SaveDocumentContent: %v", err) } + assertHumanLeaseReleasedSquadCanAcquire(t, srv, docID) +} + +func TestDocRevision_PatchReleasesHumanLeaseOnHeadConflict(t *testing.T) { + srv := newTestServer(t) + ctx := context.Background() + workDir := filepath.Join(srv.dataRoot(), "ws-conflict-lease") + _ = os.MkdirAll(workDir, 0o755) + docID := "doc_proj1_patch_conflict_lease" + + if _, err := srv.svc.DocGov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil { + t.Fatal(err) + } + st, err := srv.svc.DocRevision().OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir) + if err != nil { + t.Fatal(err) + } + token := loginTestAdmin(t, srv) + etag := `"` + strconv.FormatInt(st.HeadSnapshotID, 10) + ":" + strconv.Itoa(st.HeadRevisionNo) + `"` + + first := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content", + bytes.NewReader([]byte(`{"markdown":"# base\n\nbody\n\nA1\n"}`))) + withBearerAuth(first, token) + first.Header.Set("Idempotency-Key", "conflict-lease-a1") + first.Header.Set("If-Match", etag) + w1 := httptest.NewRecorder() + srv.Routes().ServeHTTP(w1, first) + if w1.Code != http.StatusOK { + t.Fatalf("first save want 200 got %d %s", w1.Code, w1.Body.String()) + } + + second := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content", + bytes.NewReader([]byte(`{"markdown":"# base\n\nbody\n\nA2\n"}`))) + withBearerAuth(second, token) + second.Header.Set("Idempotency-Key", "conflict-lease-a2") + second.Header.Set("If-Match", etag) + w2 := httptest.NewRecorder() + srv.Routes().ServeHTTP(w2, second) + if w2.Code != http.StatusConflict { + t.Fatalf("stale etag want 409 got %d %s", w2.Code, w2.Body.String()) + } + assertHumanLeaseReleasedSquadCanAcquire(t, srv, docID) +} + +func assertHumanLeaseReleasedSquadCanAcquire(t *testing.T, srv *Server, docID string) { + t.Helper() + ctx := context.Background() + doc, err := srv.svc.DocGov().GetDocument(ctx, docID) + if err != nil { + t.Fatal(err) + } + if doc.HasActiveLease(time.Now()) { + t.Fatalf("human lease must be released after PATCH, holder=%s/%s", doc.LeaseHolderType, doc.LeaseHolderID) + } + leased, err := srv.svc.DocGov().AcquireLease(ctx, docgov.LeaseAcquireRequest{ + DocID: docID, + HolderType: docgov.LeaseHolderSquad, + HolderID: "run_after_human_save", + TTL: time.Minute, + }) + if err != nil { + t.Fatalf("squad must acquire after human PATCH released lease: %v", err) + } + if leased.LeaseHolderType != docgov.LeaseHolderSquad { + t.Fatalf("want squad holder, got %s", leased.LeaseHolderType) + } + if _, err := srv.svc.DocGov().ReleaseLease(ctx, docID, string(docgov.LeaseHolderSquad), "run_after_human_save"); err != nil { + t.Fatal(err) + } }