| 1 |
|
package mcpsrv |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"fmt" |
| 6 |
|
|
| 7 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/authn" |
| 8 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/service" |
| 9 |
|
) |
| 10 |
|
|
| 11 |
|
// Writer is the write side of the orchestration layer the write tools call. |
| 12 |
|
// *service.Service satisfies it. |
| 13 |
|
// |
| 14 |
|
// It is a distinct interface from Reader, and optional on the Backend, because |
| 15 |
|
// the read tools need no write path and a read-only deployment (or a test) |
| 16 |
|
// should be able to register the three read tools without a service that can |
| 17 |
|
// mutate anything. |
| 18 |
|
// |
| 19 |
|
// It is a union of one narrow interface per write tool rather than a flat |
| 20 |
|
// method list, and each handler takes only its own half. That is what stops |
| 21 |
|
// spec_comment from reaching Propose and — the reason it matters — what stops |
| 22 |
|
// it from reaching a resolve method however service/ grows; see [Commenter]. |
| 23 |
|
type Writer interface { |
| 24 |
|
Proposer |
| 25 |
|
Commenter |
| 26 |
|
} |
| 27 |
|
|
| 28 |
|
// Proposer is what spec_propose calls — the same service.Propose the REST PUT |
| 29 |
|
// calls, so the two write surfaces share one implementation of If-Match, |
| 30 |
|
// provenance and auto-merge rather than drifting apart. |
| 31 |
|
type Proposer interface { |
| 32 |
|
Propose(ctx context.Context, req service.ProposeRequest) (service.ProposeResult, error) |
| 33 |
|
} |
| 34 |
|
|
| 35 |
|
// proposeDoc is one whole-document upload. The write plane takes whole |
| 36 |
|
// documents, not patches — that is how agents work and what makes the merge |
| 37 |
|
// model plumbing — so an agent sends the full markdown it wants the document to |
| 38 |
|
// have, frontmatter included. |
| 39 |
|
type proposeDoc struct { |
| 40 |
|
Path string `json:"path" jsonschema:"the document's path in the space, e.g. \"specs/0007-storage.md\""` |
| 41 |
|
Content string `json:"content" jsonschema:"the whole document, frontmatter included, exactly as it should be stored"` |
| 42 |
|
} |
| 43 |
|
|
| 44 |
|
type proposeInput struct { |
| 45 |
|
Space string `json:"space" jsonschema:"the space to propose against, written \"~owner/name\""` |
| 46 |
|
// IfMatch is the base: the approved-head sha the agent read the document at, |
| 47 |
|
// which is the rev field spec_read returns for an approved read. It pins the |
| 48 |
|
// proposal's base and is what staleness is measured against. |
| 49 |
|
IfMatch string `json:"if_match" jsonschema:"the approved-head revision you read at — the rev value from spec_read of the approved head. It becomes the proposal's base; a base the approved branch has moved off is rejected."` |
| 50 |
|
Proposal int `json:"proposal,omitempty" jsonschema:"add these documents to an existing open proposal with this id, rather than opening a new one. Omit to open a new proposal."` |
| 51 |
|
Title string `json:"title,omitempty" jsonschema:"a short title for a new proposal (required when opening one, ignored when adding)"` |
| 52 |
|
Rationale string `json:"rationale,omitempty" jsonschema:"why the change is proposed, for the reviewer"` |
| 53 |
|
Message string `json:"message,omitempty" jsonschema:"the commit message for this write; defaults to the title when opening"` |
| 54 |
|
Documents []proposeDoc `json:"documents" jsonschema:"the whole documents to write, at least one"` |
| 55 |
|
} |
| 56 |
|
|
| 57 |
|
type proposeOutput struct { |
| 58 |
|
// Proposal is the proposal id, and Url the stable link to hand a human. An |
| 59 |
|
// agent that proposes without surfacing the url makes the work invisible. |
| 60 |
|
Proposal int `json:"proposal"` |
| 61 |
|
URL string `json:"url"` |
| 62 |
|
// Merged reports whether auto-merge policy landed this immediately. When |
| 63 |
|
// true the change is already on the approved head; when false it is open and |
| 64 |
|
// waiting for a human, and the url is where they review it. |
| 65 |
|
Merged bool `json:"merged"` |
| 66 |
|
// State is the proposal's lifecycle state after this write: "open" or, when |
| 67 |
|
// policy auto-merged, "merged". |
| 68 |
|
State string `json:"state"` |
| 69 |
|
// Branch is the proposal branch, "proposals/<id>". BaseRev is the base the |
| 70 |
|
// proposal is measured against — the value to keep sending as if_match when |
| 71 |
|
// adding to this proposal. |
| 72 |
|
Branch string `json:"branch"` |
| 73 |
|
BaseRev string `json:"base_rev"` |
| 74 |
|
} |
| 75 |
|
|
| 76 |
9 |
func proposeHandler(ctx context.Context, w Proposer, in proposeInput) (proposeOutput, error) { |
| 77 |
9 |
ref, err := parseSpace(in.Space) |
| 78 |
9 |
if err != nil { |
| 79 |
0 |
return proposeOutput{}, err |
| 80 |
0 |
} |
| 81 |
9 |
if len(in.Documents) == 0 { |
| 82 |
1 |
return proposeOutput{}, fmt.Errorf("documents must not be empty; a proposal writes at least one whole document") |
| 83 |
1 |
} |
| 84 |
8 |
writes := make([]service.DocumentWrite, 0, len(in.Documents)) |
| 85 |
8 |
for _, d := range in.Documents { |
| 86 |
8 |
writes = append(writes, service.DocumentWrite{Path: d.Path, Content: []byte(d.Content)}) |
| 87 |
8 |
} |
| 88 |
|
|
| 89 |
|
// The principal is resolved by the resolver middleware on /mcp from the |
| 90 |
|
// bearer token on this very request. service.Propose refuses a non-agent, so |
| 91 |
|
// an anonymous or owner caller is rejected there rather than here — the ACL |
| 92 |
|
// has one home, in service/. |
| 93 |
8 |
principal := authn.PrincipalFromContext(ctx) |
| 94 |
8 |
|
| 95 |
8 |
res, err := w.Propose(ctx, service.ProposeRequest{ |
| 96 |
8 |
Space: ref, |
| 97 |
8 |
Principal: principal, |
| 98 |
8 |
ProposalID: in.Proposal, |
| 99 |
8 |
Title: in.Title, |
| 100 |
8 |
Rationale: in.Rationale, |
| 101 |
8 |
IfMatch: in.IfMatch, |
| 102 |
8 |
Message: in.Message, |
| 103 |
8 |
Writes: writes, |
| 104 |
8 |
}) |
| 105 |
8 |
if err != nil { |
| 106 |
7 |
return proposeOutput{}, err |
| 107 |
7 |
} |
| 108 |
1 |
return proposeOutput{ |
| 109 |
1 |
Proposal: res.Proposal.ID, |
| 110 |
1 |
URL: res.URL, |
| 111 |
1 |
Merged: res.Merged, |
| 112 |
1 |
State: string(res.Proposal.State), |
| 113 |
1 |
Branch: res.Proposal.Branch, |
| 114 |
1 |
BaseRev: res.Proposal.BaseRev, |
| 115 |
1 |
}, nil |
| 116 |
|
} |