diff --git a/api/openapi.yaml b/api/openapi.yaml index c20b129f..e52930c4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -273,6 +273,77 @@ components: items: $ref: "#/components/schemas/GraphEvent" + GraphSyncRequest: + type: object + required: [syncId, projectId, graph] + properties: + syncId: + type: string + minLength: 1 + maxLength: 256 + projectId: + type: string + minLength: 1 + maxLength: 128 + sessionId: + type: string + maxLength: 128 + graph: + $ref: "#/components/schemas/ExecutionGraph" + + GraphSyncResponse: + type: object + required: [accepted, duplicate, graphDigest] + properties: + accepted: + type: boolean + duplicate: + type: boolean + graphDigest: + type: string + pattern: '^[a-f0-9]{64}$' + facts: + type: integer + + GraphReadResponse: + type: object + required: [graph, limits, pagination] + properties: + graph: + type: object + properties: + schema_version: + type: string + generated_at: + type: string + format: date-time + scope: + type: object + additionalProperties: + type: string + nodes: + type: array + items: + type: object + edges: + type: array + items: + type: object + events: + type: array + items: + type: object + limits: + type: object + properties: + perFactType: + type: integer + pagination: + type: object + properties: + next_cursor: + type: [string, "null"] + Message: type: object properties: @@ -863,6 +934,105 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/graph/sync: + post: + operationId: syncGraph + tags: [graphs] + summary: Ingest portable graph facts from a producer + description: | + Accepts a producer's portable `*.graph/v1` facts (e.g. from a future + ecosystem repo), validates them against the shared graph contract, and + acknowledges with an idempotency digest. Mirrors the cloud plane's + `/v1/graph/sync` so a producer can target either surface with the same + payload. The daemon retains an in-memory idempotency ledger; durable + retention is the cloud plane's job. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GraphSyncRequest" + responses: + "202": + description: Graph accepted + content: + application/json: + schema: + $ref: "#/components/schemas/GraphSyncResponse" + "200": + description: Graph accepted as a duplicate of a prior sync + content: + application/json: + schema: + $ref: "#/components/schemas/GraphSyncResponse" + "400": + description: Invalid or non-portable graph + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Sync ID reused with different content + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/projects/{projectId}/graph: + get: + operationId: getProjectGraph + tags: [graphs] + summary: Read back ingested portable graph facts for a project + description: | + Returns the portable graph facts previously accepted via + `POST /v1/graph/sync` for a project, mirroring the cloud plane's read + projection so a producer can verify what was ingested on either + surface. The daemon returns a single page per fact type. + parameters: + - name: projectId + in: path + required: true + schema: + type: string + - name: sessionId + in: query + required: false + schema: + type: string + - name: limit + in: query + required: false + schema: + type: integer + default: 100 + minimum: 1 + maximum: 250 + responses: + "200": + description: Portable graph facts for the project + content: + application/json: + schema: + $ref: "#/components/schemas/GraphReadResponse" + "400": + description: Invalid project or session identifier + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/stats: get: operationId: getStats diff --git a/ecosystem.yaml b/ecosystem.yaml index 00009199..fc7871fa 100644 --- a/ecosystem.yaml +++ b/ecosystem.yaml @@ -5,7 +5,7 @@ schema_version: 1 # records the user-facing capability. Tooling must consume this file instead of # carrying its own repo-name list. contracts: - eagle_version: v0.0.0-20260831121050-12ea8cc11b16 + eagle_version: v0.0.0-20260902153929-5877bed17503 portable_graph_schema: hawk.graph/v1 cloud_graph_schema: graycode-cloud.graph/v1 usage_event: usage.recorded.v1 @@ -45,7 +45,7 @@ repositories: language: go module: github.com/GrayCodeAI/eyrie workspace: true - tracks_eagle: true + tracks_eagle: false facade: github.com/GrayCodeAI/eyrie/engine - directory: harrier @@ -55,7 +55,7 @@ repositories: language: go module: github.com/GrayCodeAI/harrier workspace: true - tracks_eagle: true + tracks_eagle: false facade: github.com/GrayCodeAI/harrier/engine - directory: shrike @@ -65,7 +65,7 @@ repositories: language: go module: github.com/GrayCodeAI/shrike workspace: true - tracks_eagle: true + tracks_eagle: false facade: github.com/GrayCodeAI/shrike - directory: swift @@ -75,7 +75,7 @@ repositories: language: go module: github.com/GrayCodeAI/swift workspace: true - tracks_eagle: true + tracks_eagle: false facade: github.com/GrayCodeAI/swift/cli - directory: kestrel @@ -85,7 +85,7 @@ repositories: language: go module: github.com/GrayCodeAI/kestrel workspace: true - tracks_eagle: true + tracks_eagle: false facade: github.com/GrayCodeAI/kestrel - directory: merlin @@ -95,7 +95,7 @@ repositories: language: go module: github.com/GrayCodeAI/merlin workspace: true - tracks_eagle: true + tracks_eagle: false facade: github.com/GrayCodeAI/merlin - directory: sparrow diff --git a/go.mod b/go.mod index 0fd1a7bd..a63b788c 100644 --- a/go.mod +++ b/go.mod @@ -11,12 +11,12 @@ require ( charm.land/bubbles/v2 v2.1.1 charm.land/bubbletea/v2 v2.0.8 charm.land/lipgloss/v2 v2.0.5 - github.com/GrayCodeAI/eagle v0.0.0-20260831121050-12ea8cc11b16 - github.com/GrayCodeAI/eyrie v0.0.0-20260831133556-13f65882f1c9 - github.com/GrayCodeAI/harrier v0.0.0-20260831121051-a93a92eeec6d - github.com/GrayCodeAI/kestrel v0.0.0-20260831121051-606ec6d9b867 - github.com/GrayCodeAI/merlin v0.0.0-20260831133238-f31093d609bc - github.com/GrayCodeAI/shrike v0.0.0-20260831133117-d530178858b4 + github.com/GrayCodeAI/eagle v0.0.0-20260902153929-5877bed17503 + github.com/GrayCodeAI/eyrie v0.2.3-0.20260902140659-6bc3068cd48a + github.com/GrayCodeAI/harrier v0.0.0-20260902154449-d52fa214feb7 + github.com/GrayCodeAI/kestrel v0.0.0-20260902154440-1b4c8cf7ea62 + github.com/GrayCodeAI/merlin v0.0.0-20260902154444-0f4b9f7326cb + github.com/GrayCodeAI/shrike v0.0.0-20260902154002-4465cf58fe59 github.com/alecthomas/chroma/v2 v2.26.1 github.com/bwmarrin/discordgo v0.28.1 github.com/charmbracelet/x/ansi v0.11.7 @@ -60,7 +60,6 @@ exclude ( ) require ( - github.com/GrayCodeAI/falcon v0.0.0-20260831121050-870f4262da2b // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/chromedp/sysutil v1.1.0 // indirect github.com/denisbrodbeck/machineid v1.0.1 // indirect @@ -172,7 +171,7 @@ require ( require ( github.com/BurntSushi/toml v1.6.0 - github.com/GrayCodeAI/swift v0.0.0-20260831121051-6579cc97156a + github.com/GrayCodeAI/swift v0.0.0-20260902154454-07d895ebce4d github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect diff --git a/go.sum b/go.sum index f2074acd..a61353a8 100644 --- a/go.sum +++ b/go.sum @@ -16,22 +16,20 @@ github.com/BobuSumisu/aho-corasick v1.0.3 h1:uuf+JHwU9CHP2Vx+wAy6jcksJThhJS9ehR8 github.com/BobuSumisu/aho-corasick v1.0.3/go.mod h1:hm4jLcvZKI2vRF2WDU1N4p/jpWtpOzp3nLmi9AzX/XE= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/GrayCodeAI/eagle v0.0.0-20260831121050-12ea8cc11b16 h1:WGwrRrtWXZ9Vh93VAe1iit7kxG6nbpmqGhVDXAMPnK8= -github.com/GrayCodeAI/eagle v0.0.0-20260831121050-12ea8cc11b16/go.mod h1:wjWP4o2xfIAoPx2JdizFYvlsLMjufmKy7anToKjEj3U= -github.com/GrayCodeAI/eyrie v0.0.0-20260831133556-13f65882f1c9 h1:vy/hl5NpbVyfVGmYgbKX0nY6JWqR0VeV3WacRsUJ4OM= -github.com/GrayCodeAI/eyrie v0.0.0-20260831133556-13f65882f1c9/go.mod h1:fXHJpqZSFZy/h8myH+2n7ROcRTIWY4ZU8quZCP4o5f8= -github.com/GrayCodeAI/falcon v0.0.0-20260831121050-870f4262da2b h1:SreQZCo2mGQqAF1lGwSsPju2TZUOO4equzbe6e1y5Pk= -github.com/GrayCodeAI/falcon v0.0.0-20260831121050-870f4262da2b/go.mod h1:3gbtU2f4cvq47wMq3bYiY3f/7Vta83b+WHZHhtg8g3s= -github.com/GrayCodeAI/harrier v0.0.0-20260831121051-a93a92eeec6d h1:pP38SwXo2Exm24UxUD2I+TpENWQ1rBR42NcjCs5CcnI= -github.com/GrayCodeAI/harrier v0.0.0-20260831121051-a93a92eeec6d/go.mod h1:t9UWcAOsr6dDafJoqKv5s3C0CZfyoitWxAoqJWtGgF8= -github.com/GrayCodeAI/kestrel v0.0.0-20260831121051-606ec6d9b867 h1:x+22inxiJLPXSNTitZYxA7ftuThWbliaqA87R1ujd7k= -github.com/GrayCodeAI/kestrel v0.0.0-20260831121051-606ec6d9b867/go.mod h1:W5ODeEptysvM0NwD0N23sK2VzlNcyckPKJAfdm/68zo= -github.com/GrayCodeAI/merlin v0.0.0-20260831133238-f31093d609bc h1:C33RhBpFKizsGO8lJmLU7WEZK6XIUiFI3nwGJkE87Tk= -github.com/GrayCodeAI/merlin v0.0.0-20260831133238-f31093d609bc/go.mod h1:6dG5VfnutsHbQMvNigAbZyd+XR5ZM2JtbJt6eGbzqj4= -github.com/GrayCodeAI/shrike v0.0.0-20260831133117-d530178858b4 h1:KkRw3zrRv4qcfWtaRD9gtzJhYPYlqf2TVAdpi4jf4Ek= -github.com/GrayCodeAI/shrike v0.0.0-20260831133117-d530178858b4/go.mod h1:PKjy4TAdUAoEMfUgI+F0IvjlksB8NAjJvfCpXTbOnO0= -github.com/GrayCodeAI/swift v0.0.0-20260831121051-6579cc97156a h1:4ba610MCH/EjT3KkMt1MQ1Or45DK08tNZKQSYcoaEX4= -github.com/GrayCodeAI/swift v0.0.0-20260831121051-6579cc97156a/go.mod h1:tTSQdDRl0guWTybVcbSCNxpMWDC6y8sixDrMDU+vDno= +github.com/GrayCodeAI/eagle v0.0.0-20260902153929-5877bed17503 h1:n5y1Xpf+xzwpYixeyYg4TsZWnbEp3/dJ7RitphksnBE= +github.com/GrayCodeAI/eagle v0.0.0-20260902153929-5877bed17503/go.mod h1:wjWP4o2xfIAoPx2JdizFYvlsLMjufmKy7anToKjEj3U= +github.com/GrayCodeAI/eyrie v0.2.3-0.20260902140659-6bc3068cd48a h1:6TnQbyidnPMBBfHBhIldTWzYQZx+bparWuWm1ft2q+s= +github.com/GrayCodeAI/eyrie v0.2.3-0.20260902140659-6bc3068cd48a/go.mod h1:gphUZ6Vcml7zyXfKU+704HZcxtHbJLFqgLDzke/rvFc= +github.com/GrayCodeAI/harrier v0.0.0-20260902154449-d52fa214feb7 h1:X5lmXWlBdPVyk7lMAX7M0FiRr9o16Sq7yIjfN3mnXGU= +github.com/GrayCodeAI/harrier v0.0.0-20260902154449-d52fa214feb7/go.mod h1:JBkYX6kUbWCr1fsK//hxQ4Ro1VvlxtB+bSMR0/N5RRY= +github.com/GrayCodeAI/kestrel v0.0.0-20260902154440-1b4c8cf7ea62 h1:MrmWb0coJ3Uaxr/FtDSH1FaL/lqs663QXUh8ds+w8Og= +github.com/GrayCodeAI/kestrel v0.0.0-20260902154440-1b4c8cf7ea62/go.mod h1:f+w5XGlodvA496k/6jYOZyiMXu8/xD3vbRJMIsnPWXA= +github.com/GrayCodeAI/merlin v0.0.0-20260902154444-0f4b9f7326cb h1:vzuwjW4b1pPzJyO8szWcOtKmshPTCzMxkFIFGwSuiv4= +github.com/GrayCodeAI/merlin v0.0.0-20260902154444-0f4b9f7326cb/go.mod h1:GVeGrpwVc8BmY1+sdaVVmeLR/nD2ZrjKv+ABWQFqh90= +github.com/GrayCodeAI/shrike v0.0.0-20260902154002-4465cf58fe59 h1:PiRgwhZv22f6xc920mFiJCZqZykB9ji4VXgrzHvE7Vw= +github.com/GrayCodeAI/shrike v0.0.0-20260902154002-4465cf58fe59/go.mod h1:/F/pM31qjJ7aDnRt1leLuMCNJhAxLcYbyVySgAd3E5o= +github.com/GrayCodeAI/swift v0.0.0-20260902154454-07d895ebce4d h1:De/GW91QNR+aU3iWrqoVQ2ypCXmV3pl8asAFfRBkYe8= +github.com/GrayCodeAI/swift v0.0.0-20260902154454-07d895ebce4d/go.mod h1:9t+bWiVvAJUTEaODOQ8fKeQ87qNXX+cKpSZGV9XEGkk= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= diff --git a/internal/bridge/kestrel/bridge.go b/internal/bridge/kestrel/bridge.go index ecbde805..76845e0c 100644 --- a/internal/bridge/kestrel/bridge.go +++ b/internal/bridge/kestrel/bridge.go @@ -7,10 +7,13 @@ import ( graphcontracts "github.com/GrayCodeAI/eagle/graph" reviewcontracts "github.com/GrayCodeAI/eagle/review" + eagletypes "github.com/GrayCodeAI/eagle/types" "github.com/GrayCodeAI/hawk/internal/graphjournal" "github.com/GrayCodeAI/hawk/internal/types" kestrelLib "github.com/GrayCodeAI/kestrel" + kestrelgraph "github.com/GrayCodeAI/kestrel/graph" "github.com/GrayCodeAI/kestrel/qualitygraph" + kestrelreview "github.com/GrayCodeAI/kestrel/review" ) // EyrieAdapter implements kestrel's Provider interface using hawk's eyrie client. @@ -130,7 +133,7 @@ func (b *Bridge) ReviewContracts(ctx context.Context, diff string) (*reviewcontr if err != nil { return nil, err } - return kestrelLib.ToContractResult(result), nil + return toEagleResult(kestrelLib.ToContractResult(result)), nil } // ReviewContractsObserved reviews a diff, journals Kestrel's portable quality @@ -150,7 +153,7 @@ func (b *Bridge) ReviewContractsObserved( } export, err := qualitygraph.Build(result, qualitygraph.Options{ ObservedAt: observedAt, - Scope: observation.Scope, + Scope: toKestrelScope(observation.Scope), CorrelationID: observation.SessionID, Source: diff, MaxFindings: observation.MaxFindings, @@ -167,9 +170,9 @@ func (b *Bridge) ReviewContractsObserved( observation.ToolCallID, stage, "kestrel", - export.Nodes, - export.Edges, - export.Events, + toEagleNodes(export.Nodes), + toEagleEdges(export.Edges), + toEagleEvents(export.Events), observedAt, ); err != nil { return nil, err @@ -187,7 +190,7 @@ func (b *Bridge) ReviewContractsObserved( ); err != nil { return nil, err } - return contractResult, nil + return toEagleResult(contractResult), nil } // Describe generates a PR description from a unified diff string. @@ -213,3 +216,183 @@ func (b *Bridge) Improve(ctx context.Context, diff string) (*kestrelLib.ImproveR return kestrelLib.Improve(ctx, diff, b.opts...) } + +// The following helpers convert Kestrel's vendored contract types into +// Hawk's eagle/* contract types (and the reverse for scope). The definitions +// are byte-identical, so conversion is a field-by-field copy at the boundary. + +func toKestrelScope(s graphcontracts.Scope) kestrelgraph.Scope { + return kestrelgraph.Scope{TenantID: s.TenantID, ProjectID: s.ProjectID, RepositoryID: s.RepositoryID} +} + +func toEagleNodes(nodes []kestrelgraph.Node) []graphcontracts.Node { + out := make([]graphcontracts.Node, len(nodes)) + for i, n := range nodes { + out[i] = toEagleNode(n) + } + return out +} + +func toEagleNode(n kestrelgraph.Node) graphcontracts.Node { + return graphcontracts.Node{ + ID: n.ID, + Kind: graphcontracts.NodeKind(n.Kind), + Scope: toEagleScope(n.Scope), + CreatedAt: n.CreatedAt, + EffectiveAt: n.EffectiveAt, + Provenance: toEagleProvenance(n.Provenance), + Attributes: n.Attributes, + } +} + +func toEagleEdges(edges []kestrelgraph.Edge) []graphcontracts.Edge { + out := make([]graphcontracts.Edge, len(edges)) + for i, e := range edges { + out[i] = toEagleEdge(e) + } + return out +} + +func toEagleEdge(e kestrelgraph.Edge) graphcontracts.Edge { + return graphcontracts.Edge{ + ID: e.ID, + Kind: graphcontracts.EdgeKind(e.Kind), + From: toEagleRef(e.From), + To: toEagleRef(e.To), + Scope: toEagleScope(e.Scope), + CreatedAt: e.CreatedAt, + EffectiveAt: e.EffectiveAt, + Provenance: toEagleProvenance(e.Provenance), + Attributes: e.Attributes, + } +} + +func toEagleEvents(events []kestrelgraph.Event) []graphcontracts.Event { + out := make([]graphcontracts.Event, len(events)) + for i, ev := range events { + out[i] = toEagleEvent(ev) + } + return out +} + +func toEagleEvent(ev kestrelgraph.Event) graphcontracts.Event { + return graphcontracts.Event{ + ID: ev.ID, + Type: graphcontracts.EventType(ev.Type), + Subject: toEagleRef(ev.Subject), + Scope: toEagleScope(ev.Scope), + OccurredAt: ev.OccurredAt, + CorrelationID: ev.CorrelationID, + CausationID: ev.CausationID, + IdempotencyKey: ev.IdempotencyKey, + Provenance: toEagleProvenance(ev.Provenance), + } +} + +func toEagleRef(r kestrelgraph.Ref) graphcontracts.Ref { + return graphcontracts.Ref{Kind: graphcontracts.NodeKind(r.Kind), ID: r.ID} +} + +func toEagleScope(s kestrelgraph.Scope) graphcontracts.Scope { + return graphcontracts.Scope{TenantID: s.TenantID, ProjectID: s.ProjectID, RepositoryID: s.RepositoryID} +} + +func toEagleProvenance(p kestrelgraph.Provenance) graphcontracts.Provenance { + evidence := make([]graphcontracts.ArtifactRef, len(p.Evidence)) + for i, a := range p.Evidence { + evidence[i] = graphcontracts.ArtifactRef{URI: a.URI, Digest: a.Digest, MediaType: a.MediaType} + } + return graphcontracts.Provenance{Producer: p.Producer, Version: p.Version, SourceID: p.SourceID, Evidence: evidence} +} + +func toEagleResult(r *kestrelreview.Result) *reviewcontracts.Result { + if r == nil { + return nil + } + return &reviewcontracts.Result{ + Findings: toEagleFindings(r.Findings), + Comments: toEagleComments(r.Comments), + Stats: toEagleStats(r.Stats), + Report: r.Report, + FailOn: eagletypes.Severity(r.FailOn), + FailOnSet: r.FailOnSet, + SASTFusion: toEagleSASTFusion(r.SASTFusion), + ConfidenceBreakdown: toEagleConfidenceBreakdown(r.ConfidenceBreakdown), + } +} + +func toEagleFindings(findings []kestrelreview.Finding) []reviewcontracts.Finding { + out := make([]reviewcontracts.Finding, len(findings)) + for i, f := range findings { + out[i] = reviewcontracts.Finding{ + Concern: f.Concern, + Severity: eagletypes.Severity(f.Severity), + File: f.File, + Line: f.Line, + EndLine: f.EndLine, + Message: f.Message, + Fix: f.Fix, + Reasoning: f.Reasoning, + CWE: f.CWE, + Confidence: f.Confidence, + SASTSource: f.SASTSource, + } + } + return out +} + +func toEagleComments(comments []kestrelreview.InlineComment) []reviewcontracts.InlineComment { + out := make([]reviewcontracts.InlineComment, len(comments)) + for i, c := range comments { + out[i] = reviewcontracts.InlineComment{ + Path: c.Path, + StartLine: c.StartLine, + EndLine: c.EndLine, + Body: c.Body, + Suggestion: c.Suggestion, + } + } + return out +} + +func toEagleStats(s kestrelreview.Stats) reviewcontracts.Stats { + bySeverity := make(map[eagletypes.Severity]int, len(s.BySeverity)) + for sev, count := range s.BySeverity { + bySeverity[eagletypes.Severity(sev)] = count + } + return reviewcontracts.Stats{ + FilesReviewed: s.FilesReviewed, + HunksAnalyzed: s.HunksAnalyzed, + FindingsTotal: s.FindingsTotal, + BySeverity: bySeverity, + ByConcern: s.ByConcern, + TokensUsed: s.TokensUsed, + DurationPerConcern: s.DurationPerConcern, + AverageConfidence: s.AverageConfidence, + HighConfidenceCount: s.HighConfidenceCount, + LowConfidenceCount: s.LowConfidenceCount, + LLMErrors: s.LLMErrors, + } +} + +func toEagleSASTFusion(f *kestrelreview.SASTFusionResult) *reviewcontracts.SASTFusionResult { + if f == nil { + return nil + } + return &reviewcontracts.SASTFusionResult{ + Confirmed: toEagleFindings(f.Confirmed), + Dismissed: toEagleFindings(f.Dismissed), + Unaddressed: toEagleFindings(f.Unaddressed), + } +} + +func toEagleConfidenceBreakdown(c *kestrelreview.ConfidenceBreakdown) *reviewcontracts.ConfidenceBreakdown { + if c == nil { + return nil + } + return &reviewcontracts.ConfidenceBreakdown{ + High: toEagleFindings(c.High), + Medium: toEagleFindings(c.Medium), + Low: toEagleFindings(c.Low), + } +} diff --git a/internal/bridge/merlin/bridge.go b/internal/bridge/merlin/bridge.go index 37c94da3..968af449 100644 --- a/internal/bridge/merlin/bridge.go +++ b/internal/bridge/merlin/bridge.go @@ -6,10 +6,13 @@ import ( "time" graphcontracts "github.com/GrayCodeAI/eagle/graph" + eagletypes "github.com/GrayCodeAI/eagle/types" verifycontracts "github.com/GrayCodeAI/eagle/verify" "github.com/GrayCodeAI/hawk/internal/graphjournal" merlinLib "github.com/GrayCodeAI/merlin" + merlingraph "github.com/GrayCodeAI/merlin/graph" "github.com/GrayCodeAI/merlin/qualitygraph" + merlinverify "github.com/GrayCodeAI/merlin/verify" ) // Bridge connects hawk to the merlin site-auditing library. @@ -74,7 +77,7 @@ func (b *Bridge) RunContracts(ctx context.Context, target string, opts ...merlin if err != nil { return nil, err } - return merlinLib.ToContractReport(report), nil + return toEagleReport(merlinLib.ToContractReport(report)), nil } // RunContractsObserved performs a scan, journals Merlin's portable quality @@ -95,7 +98,7 @@ func (b *Bridge) RunContractsObserved( } export, err := qualitygraph.Build(report, qualitygraph.Options{ ObservedAt: observedAt, - Scope: observation.Scope, + Scope: toMerlinScope(observation.Scope), CorrelationID: observation.SessionID, MaxFindings: observation.MaxFindings, }) @@ -111,9 +114,9 @@ func (b *Bridge) RunContractsObserved( observation.ToolCallID, stage, "merlin", - export.Nodes, - export.Edges, - export.Events, + toEagleNodes(export.Nodes), + toEagleEdges(export.Edges), + toEagleEvents(export.Events), observedAt, ); err != nil { return nil, err @@ -131,5 +134,130 @@ func (b *Bridge) RunContractsObserved( ); err != nil { return nil, err } - return contractReport, nil + return toEagleReport(contractReport), nil +} + +// The following helpers convert Merlin's vendored contract types into Hawk's +// eagle/* contract types (and the reverse for scope). The definitions are +// byte-identical, so conversion is a field-by-field copy at the boundary. + +func toMerlinScope(s graphcontracts.Scope) merlingraph.Scope { + return merlingraph.Scope{TenantID: s.TenantID, ProjectID: s.ProjectID, RepositoryID: s.RepositoryID} +} + +func toEagleNodes(nodes []merlingraph.Node) []graphcontracts.Node { + out := make([]graphcontracts.Node, len(nodes)) + for i, n := range nodes { + out[i] = toEagleNode(n) + } + return out +} + +func toEagleNode(n merlingraph.Node) graphcontracts.Node { + return graphcontracts.Node{ + ID: n.ID, + Kind: graphcontracts.NodeKind(n.Kind), + Scope: toEagleScope(n.Scope), + CreatedAt: n.CreatedAt, + EffectiveAt: n.EffectiveAt, + Provenance: toEagleProvenance(n.Provenance), + Attributes: n.Attributes, + } +} + +func toEagleEdges(edges []merlingraph.Edge) []graphcontracts.Edge { + out := make([]graphcontracts.Edge, len(edges)) + for i, e := range edges { + out[i] = toEagleEdge(e) + } + return out +} + +func toEagleEdge(e merlingraph.Edge) graphcontracts.Edge { + return graphcontracts.Edge{ + ID: e.ID, + Kind: graphcontracts.EdgeKind(e.Kind), + From: toEagleRef(e.From), + To: toEagleRef(e.To), + Scope: toEagleScope(e.Scope), + CreatedAt: e.CreatedAt, + EffectiveAt: e.EffectiveAt, + Provenance: toEagleProvenance(e.Provenance), + Attributes: e.Attributes, + } +} + +func toEagleEvents(events []merlingraph.Event) []graphcontracts.Event { + out := make([]graphcontracts.Event, len(events)) + for i, ev := range events { + out[i] = toEagleEvent(ev) + } + return out +} + +func toEagleEvent(ev merlingraph.Event) graphcontracts.Event { + return graphcontracts.Event{ + ID: ev.ID, + Type: graphcontracts.EventType(ev.Type), + Subject: toEagleRef(ev.Subject), + Scope: toEagleScope(ev.Scope), + OccurredAt: ev.OccurredAt, + CorrelationID: ev.CorrelationID, + CausationID: ev.CausationID, + IdempotencyKey: ev.IdempotencyKey, + Provenance: toEagleProvenance(ev.Provenance), + } +} + +func toEagleRef(r merlingraph.Ref) graphcontracts.Ref { + return graphcontracts.Ref{Kind: graphcontracts.NodeKind(r.Kind), ID: r.ID} +} + +func toEagleScope(s merlingraph.Scope) graphcontracts.Scope { + return graphcontracts.Scope{TenantID: s.TenantID, ProjectID: s.ProjectID, RepositoryID: s.RepositoryID} +} + +func toEagleProvenance(p merlingraph.Provenance) graphcontracts.Provenance { + evidence := make([]graphcontracts.ArtifactRef, len(p.Evidence)) + for i, a := range p.Evidence { + evidence[i] = graphcontracts.ArtifactRef{URI: a.URI, Digest: a.Digest, MediaType: a.MediaType} + } + return graphcontracts.Provenance{Producer: p.Producer, Version: p.Version, SourceID: p.SourceID, Evidence: evidence} +} + +func toEagleReport(r *merlinverify.Report) *verifycontracts.Report { + if r == nil { + return nil + } + findings := make([]verifycontracts.Finding, len(r.Findings)) + for i, f := range r.Findings { + findings[i] = verifycontracts.Finding{ + Check: f.Check, + Severity: eagletypes.Severity(f.Severity), + URL: f.URL, + Element: f.Element, + Message: f.Message, + Fix: f.Fix, + Evidence: f.Evidence, + } + } + bySeverity := make(map[eagletypes.Severity]int, len(r.Stats.BySeverity)) + for sev, count := range r.Stats.BySeverity { + bySeverity[eagletypes.Severity(sev)] = count + } + return &verifycontracts.Report{ + Target: r.Target, + Findings: findings, + Stats: verifycontracts.Stats{ + PagesScanned: r.Stats.PagesScanned, + FindingsTotal: r.Stats.FindingsTotal, + BySeverity: bySeverity, + ByCheck: r.Stats.ByCheck, + DurationPerCheck: r.Stats.DurationPerCheck, + }, + CrawledURLs: r.CrawledURLs, + Duration: r.Duration, + FailOn: eagletypes.Severity(r.FailOn), + FailOnSet: r.FailOnSet, + } } diff --git a/internal/config/catalog_api.go b/internal/config/catalog_api.go index 1b2a5555..af9f5831 100644 --- a/internal/config/catalog_api.go +++ b/internal/config/catalog_api.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - llm "github.com/GrayCodeAI/eagle/llm" + llm "github.com/GrayCodeAI/eyrie/llm" gw "github.com/GrayCodeAI/hawk/internal/provider/gateway" ) diff --git a/internal/daemon/contract_parity_test.go b/internal/daemon/contract_parity_test.go new file mode 100644 index 00000000..795ef63c --- /dev/null +++ b/internal/daemon/contract_parity_test.go @@ -0,0 +1,122 @@ +package daemon + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strconv" + "strings" + "testing" + "time" + + graphcontracts "github.com/GrayCodeAI/eagle/graph" + "github.com/GrayCodeAI/hawk/internal/testutil" +) + +// TestDaemon_GraphSync_ContractMatrix locks the /v1/graph/sync parity contract +// on the daemon surface: for every scenario the shared contract defines, the +// local daemon must return exactly the documented status. The cloud worker +// enforces the identical matrix in graycode-platform test/graph.test.ts, so a +// producer targeting either surface observes the same accept/duplicate/ +// conflict/invalid behavior. +func TestDaemon_GraphSync_ContractMatrix(t *testing.T) { + matrix := []struct { + name string + run func(t *testing.T, addr string) + }{ + {"accepted_202", func(t *testing.T, addr string) { + assertSyncStatus(t, addr, graphSyncBody(t, "m-accepted", validTestExport()), http.StatusAccepted) + }}, + {"duplicate_200", func(t *testing.T, addr string) { + body := graphSyncBody(t, "m-dup", validTestExport()) + assertSyncStatus(t, addr, body, http.StatusAccepted) + assertSyncStatus(t, addr, body, http.StatusOK) + }}, + {"missing_project_id_400", func(t *testing.T, addr string) { + raw, _ := json.Marshal(validTestExport()) + env := GraphSyncRequest{SyncID: "m-noproj", Graph: raw} + body, _ := json.Marshal(env) + assertSyncStatus(t, addr, body, http.StatusBadRequest) + }}, + {"missing_graph_400", func(t *testing.T, addr string) { + body := []byte(`{"syncId":"m-nograph","projectId":"proj"}`) + assertSyncStatus(t, addr, body, http.StatusBadRequest) + }}, + {"invalid_schema_400", func(t *testing.T, addr string) { + export := validTestExport() + export.SchemaVersion = "not-a-graph" + assertSyncStatus(t, addr, graphSyncBody(t, "m-schema", export), http.StatusBadRequest) + }}, + {"dangling_edge_400", func(t *testing.T, addr string) { + export := validTestExport() + export.Edges[0].To.ID = "missing-node" + assertSyncStatus(t, addr, graphSyncBody(t, "m-dangle", export), http.StatusBadRequest) + }}, + {"duplicate_node_400", func(t *testing.T, addr string) { + export := validTestExport() + export.Nodes[1].ID = export.Nodes[0].ID + assertSyncStatus(t, addr, graphSyncBody(t, "m-dupnode", export), http.StatusBadRequest) + }}, + {"too_many_facts_400", func(t *testing.T, addr string) { + export := validTestExport() + for i := 0; i < 900; i++ { + export.Nodes = append(export.Nodes, graphcontracts.Node{ + ID: "extra-" + strconv.Itoa(i), Kind: graphcontracts.NodeSystem, + CreatedAt: time.Now().UTC(), Provenance: graphcontracts.Provenance{Producer: "test"}, + }) + } + assertSyncStatus(t, addr, graphSyncBody(t, "m-many", export), http.StatusBadRequest) + }}, + {"scope_mismatch_409", func(t *testing.T, addr string) { + export := validTestExport() + export.Scope.ProjectID = "other-project" + assertSyncStatus(t, addr, graphSyncBody(t, "m-scope", export), http.StatusConflict) + }}, + {"sync_id_reuse_different_content_409", func(t *testing.T, addr string) { + assertSyncStatus(t, addr, graphSyncBody(t, "m-reuse", validTestExport()), http.StatusAccepted) + export := validTestExport() + export.Nodes[0].Provenance.Producer = "other-producer" + assertSyncStatus(t, addr, graphSyncBody(t, "m-reuse", export), http.StatusConflict) + }}, + {"sensitive_attribute_400", func(t *testing.T, addr string) { + export := validTestExport() + export.Nodes[0].Attributes = map[string]string{"model": "gpt-4"} + assertSyncStatus(t, addr, graphSyncBody(t, "m-sensitive", export), http.StatusBadRequest) + }}, + {"tenant_scope_400", func(t *testing.T, addr string) { + export := validTestExport() + export.Scope.TenantID = "tenant-1" + assertSyncStatus(t, addr, graphSyncBody(t, "m-tenant", export), http.StatusBadRequest) + }}, + {"oversized_body_413", func(t *testing.T, addr string) { + body := []byte(`{"syncId":"m-big","projectId":"proj","graph":{"schema_version":"test.graph/v1","generated_at":"2026-01-01T00:00:00Z","nodes":[],"edges":[],"events":[],"padding":"` + strings.Repeat("x", maxRequestBodyBytes) + `"}}`) + assertSyncStatus(t, addr, body, http.StatusRequestEntityTooLarge) + }}, + {"unauthorized_401", func(t *testing.T, addr string) { + srv := New(Config{Port: 0, Host: testutil.LoopbackHost, APIKey: "secret"}, nil) + authedAddr := startTestDaemon(t, srv) + defer srv.Stop(context.Background()) + assertSyncStatus(t, authedAddr, graphSyncBody(t, "m-auth", validTestExport()), http.StatusUnauthorized) + }}, + } + + for _, tc := range matrix { + t.Run(tc.name, func(t *testing.T) { + addr := newGraphSyncTestServer(t) + tc.run(t, addr) + }) + } +} + +func assertSyncStatus(t *testing.T, addr string, body []byte, want int) { + t.Helper() + resp, err := http.Post("http://"+addr+"/v1/graph/sync", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("POST /v1/graph/sync failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != want { + t.Fatalf("expected status %d, got %d", want, resp.StatusCode) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 6c890915..38276ed3 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -105,6 +105,12 @@ type Server struct { graphMu sync.RWMutex graphFactory GraphFactory + // graphLedger retains accepted POST /v1/graph/sync payloads for + // idempotency and durable retention. Defaults to an in-memory ledger; + // the composition root injects a SQLite-backed ledger for persistence + // across daemon restarts. + graphLedger GraphLedger + // routePatterns records every "METHOD /path" pattern registered on the // mux so tests can verify the HTTP surface matches api/openapi.yaml. routePatterns []string @@ -172,6 +178,10 @@ type Config struct { // cap (AutonomySemi) applies; set explicitly (e.g. to AutonomyFull) // only for trusted, operator-owned deployments. MaxAutonomy engine.AutonomyLevel `json:"-"` + // GraphLedger durably retains accepted POST /v1/graph/sync payloads for + // idempotency across daemon restarts. If nil, the server uses an + // in-memory ledger (survives only the process lifetime). + GraphLedger GraphLedger `json:"-"` } // DefaultMaxAutonomy is the highest autonomy tier a daemon client may @@ -249,6 +259,10 @@ func New(cfg Config, factory SessionFactory) *Server { apiLimiter: newIPLimiter(defaultAPIRatePerMin/60, defaultAPIBurst), chatLimiter: newIPLimiter(defaultChatRatePerMin/60, defaultChatBurst), metrics: metrics.NewRegistry(), + graphLedger: cfg.GraphLedger, + } + if s.graphLedger == nil { + s.graphLedger = newMemoryGraphLedger() } s.routes() // Build the messaging-bridge manager. The daemon URL is finalised in Start @@ -456,6 +470,8 @@ func (s *Server) routes() { 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("POST /v1/graph/sync", s.auth(s.rate(s.handleGraphSync, s.apiLimiter))) + s.handle("GET /v1/projects/{projectId}/graph", s.auth(s.rate(s.handleGraphRead, 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))) s.handle("GET /v1/metrics", s.auth(s.rate(s.handleMetrics, s.apiLimiter))) diff --git a/internal/daemon/graph_ledger.go b/internal/daemon/graph_ledger.go new file mode 100644 index 00000000..d4d679ab --- /dev/null +++ b/internal/daemon/graph_ledger.go @@ -0,0 +1,77 @@ +package daemon + +import ( + "context" + "sync" + "time" +) + +// GraphSyncRecord is a durably retained, accepted graph sync. +type GraphSyncRecord struct { + SyncID string + ProjectID string + SessionID string + SchemaVersion string + Digest string + Facts int + GraphJSON string + ReceivedAt time.Time +} + +// GraphLedger durably retains accepted graph syncs so ingested facts and the +// idempotency contract survive daemon restarts. A nil GraphLedger on the +// server falls back to an in-memory ledger (matching the daemon's session +// architecture for tests and unconfigured runs). +type GraphLedger interface { + // InsertIfAbsent records rec unless syncID is already present. It returns + // true when inserted, false when a record already exists. + InsertIfAbsent(ctx context.Context, rec GraphSyncRecord) (bool, error) + // Get returns the stored record for syncID and whether it exists. + Get(ctx context.Context, syncID string) (GraphSyncRecord, bool, error) + // List returns every stored record for projectID, optionally narrowed to a + // single session when sessionID is non-empty. + List(ctx context.Context, projectID, sessionID string) ([]GraphSyncRecord, error) + Close() error +} + +// memoryGraphLedger is the default in-memory ledger. It does not survive +// daemon restarts; use OpenGraphLedger for durable retention. +type memoryGraphLedger struct { + mu sync.Mutex + m map[string]GraphSyncRecord +} + +func newMemoryGraphLedger() *memoryGraphLedger { + return &memoryGraphLedger{m: make(map[string]GraphSyncRecord)} +} + +func (l *memoryGraphLedger) InsertIfAbsent(ctx context.Context, rec GraphSyncRecord) (bool, error) { + l.mu.Lock() + defer l.mu.Unlock() + if _, ok := l.m[rec.SyncID]; ok { + return false, nil + } + l.m[rec.SyncID] = rec + return true, nil +} + +func (l *memoryGraphLedger) Get(ctx context.Context, syncID string) (GraphSyncRecord, bool, error) { + l.mu.Lock() + defer l.mu.Unlock() + rec, ok := l.m[syncID] + return rec, ok, nil +} + +func (l *memoryGraphLedger) List(ctx context.Context, projectID, sessionID string) ([]GraphSyncRecord, error) { + l.mu.Lock() + defer l.mu.Unlock() + var out []GraphSyncRecord + for _, rec := range l.m { + if rec.ProjectID == projectID && (sessionID == "" || rec.SessionID == sessionID) { + out = append(out, rec) + } + } + return out, nil +} + +func (l *memoryGraphLedger) Close() error { return nil } diff --git a/internal/daemon/graph_ledger_sqlite.go b/internal/daemon/graph_ledger_sqlite.go new file mode 100644 index 00000000..64d9847f --- /dev/null +++ b/internal/daemon/graph_ledger_sqlite.go @@ -0,0 +1,117 @@ +package daemon + +import ( + "context" + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +const graphLedgerSchema = ` +CREATE TABLE IF NOT EXISTS graph_syncs ( + sync_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL DEFAULT '', + session_id TEXT NOT NULL DEFAULT '', + schema_version TEXT NOT NULL, + graph_digest TEXT NOT NULL, + facts INTEGER NOT NULL, + graph_json TEXT NOT NULL, + received_at INTEGER NOT NULL +);` + +// sqliteGraphLedger persists accepted graph syncs to a local SQLite database, +// giving durable retention and idempotency across daemon restarts. +type sqliteGraphLedger struct { + db *sql.DB +} + +// OpenGraphLedger opens or creates a durable graph-sync ledger at dbPath. +// The caller is responsible for closing it when done. +func OpenGraphLedger(dbPath string) (GraphLedger, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open graph ledger: %w", err) + } + if _, err := db.ExecContext(context.Background(), "PRAGMA journal_mode=WAL"); err != nil { + _ = db.Close() + return nil, fmt.Errorf("graph ledger WAL: %w", err) + } + if _, err := db.ExecContext(context.Background(), graphLedgerSchema); err != nil { + _ = db.Close() + return nil, fmt.Errorf("graph ledger schema: %w", err) + } + return &sqliteGraphLedger{db: db}, nil +} + +func (l *sqliteGraphLedger) InsertIfAbsent(ctx context.Context, rec GraphSyncRecord) (bool, error) { + res, err := l.db.ExecContext( + ctx, + `INSERT OR IGNORE INTO graph_syncs + (sync_id, project_id, session_id, schema_version, graph_digest, facts, graph_json, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + rec.SyncID, rec.ProjectID, rec.SessionID, rec.SchemaVersion, + rec.Digest, rec.Facts, rec.GraphJSON, rec.ReceivedAt.UnixNano(), + ) + if err != nil { + return false, fmt.Errorf("graph ledger insert: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("graph ledger rows affected: %w", err) + } + return n == 1, nil +} + +func (l *sqliteGraphLedger) Get(ctx context.Context, syncID string) (GraphSyncRecord, bool, error) { + var rec GraphSyncRecord + var receivedAt int64 + err := l.db.QueryRowContext( + ctx, + `SELECT sync_id, project_id, session_id, schema_version, graph_digest, facts, graph_json, received_at + FROM graph_syncs WHERE sync_id = ?`, syncID, + ).Scan(&rec.SyncID, &rec.ProjectID, &rec.SessionID, &rec.SchemaVersion, + &rec.Digest, &rec.Facts, &rec.GraphJSON, &receivedAt) + if err == sql.ErrNoRows { + return GraphSyncRecord{}, false, nil + } + if err != nil { + return GraphSyncRecord{}, false, fmt.Errorf("graph ledger get: %w", err) + } + rec.ReceivedAt = time.Unix(0, receivedAt).UTC() + return rec, true, nil +} + +func (l *sqliteGraphLedger) List(ctx context.Context, projectID, sessionID string) ([]GraphSyncRecord, error) { + query := `SELECT sync_id, project_id, session_id, schema_version, graph_digest, facts, graph_json, received_at + FROM graph_syncs WHERE project_id = ?` + args := []any{projectID} + if sessionID != "" { + query += ` AND session_id = ?` + args = append(args, sessionID) + } + rows, err := l.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("graph ledger list: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []GraphSyncRecord + for rows.Next() { + var rec GraphSyncRecord + var receivedAt int64 + if err := rows.Scan(&rec.SyncID, &rec.ProjectID, &rec.SessionID, &rec.SchemaVersion, + &rec.Digest, &rec.Facts, &rec.GraphJSON, &receivedAt); err != nil { + return nil, fmt.Errorf("graph ledger list scan: %w", err) + } + rec.ReceivedAt = time.Unix(0, receivedAt).UTC() + out = append(out, rec) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("graph ledger list: %w", err) + } + return out, nil +} + +func (l *sqliteGraphLedger) Close() error { return l.db.Close() } diff --git a/internal/daemon/graph_ledger_sqlite_test.go b/internal/daemon/graph_ledger_sqlite_test.go new file mode 100644 index 00000000..dfb9b493 --- /dev/null +++ b/internal/daemon/graph_ledger_sqlite_test.go @@ -0,0 +1,108 @@ +package daemon + +import ( + "context" + "net/http" + "path/filepath" + "testing" + "time" + + "github.com/GrayCodeAI/hawk/internal/testutil" +) + +func TestSQLiteGraphLedger_PersistsAcrossReopen(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "graph-syncs.db") + + rec := GraphSyncRecord{ + SyncID: "sync-1", + ProjectID: "proj", + SessionID: "sess", + SchemaVersion: "hawk.graph/v1", + Digest: "abc123", + Facts: 4, + GraphJSON: `{"schema_version":"hawk.graph/v1","nodes":[]}`, + ReceivedAt: time.Now().UTC(), + } + + ledger, err := OpenGraphLedger(dbPath) + if err != nil { + t.Fatalf("open ledger: %v", err) + } + inserted, err := ledger.InsertIfAbsent(context.Background(), rec) + if err != nil || !inserted { + t.Fatalf("first insert: inserted=%v err=%v", inserted, err) + } + // Re-insert of the same sync ID must not overwrite. + inserted, err = ledger.InsertIfAbsent(context.Background(), rec) + if err != nil || inserted { + t.Fatalf("duplicate insert: inserted=%v err=%v", inserted, err) + } + if err := ledger.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + // Reopen the same file, simulating a daemon restart. + ledger2, err := OpenGraphLedger(dbPath) + if err != nil { + t.Fatalf("reopen ledger: %v", err) + } + defer ledger2.Close() + + got, ok, err := ledger2.Get(context.Background(), "sync-1") + if err != nil || !ok { + t.Fatalf("get after reopen: ok=%v err=%v", ok, err) + } + if got.Digest != "abc123" || got.Facts != 4 || got.GraphJSON != rec.GraphJSON { + t.Fatalf("retained record mismatch: %+v", got) + } + if got.ProjectID != "proj" || got.SessionID != "sess" || got.SchemaVersion != "hawk.graph/v1" { + t.Fatalf("retained metadata mismatch: %+v", got) + } + if got.ReceivedAt.IsZero() { + t.Fatalf("received_at not retained") + } + if _, ok, err := ledger2.Get(context.Background(), "missing"); err != nil || ok { + t.Fatalf("missing key: ok=%v err=%v", ok, err) + } +} + +// TestDaemon_GraphSync_DurableLedger proves the handler persists accepted +// facts through the injected ledger, and that a reopened ledger still sees +// them (surviving a daemon restart). +func TestDaemon_GraphSync_DurableLedger(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "graph-syncs.db") + ledger, err := OpenGraphLedger(dbPath) + if err != nil { + t.Fatalf("open ledger: %v", err) + } + + srv := New(Config{Port: 0, Host: testutil.LoopbackHost, GraphLedger: ledger}, nil) + addr := startTestDaemon(t, srv) + t.Cleanup(func() { srv.Stop(context.Background()) }) + + body := graphSyncBody(t, "durable-1", validTestExport()) + resp := postGraphSync(t, addr, body) + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("expected 202, got %d", resp.StatusCode) + } + resp.Body.Close() + + // Reopen the ledger as a fresh server would and confirm the fact was + // durably retained. + if err := ledger.Close(); err != nil { + t.Fatalf("close ledger: %v", err) + } + ledger2, err := OpenGraphLedger(dbPath) + if err != nil { + t.Fatalf("reopen ledger: %v", err) + } + defer ledger2.Close() + + rec, ok, err := ledger2.Get(context.Background(), "durable-1") + if err != nil || !ok { + t.Fatalf("get retained sync: ok=%v err=%v", ok, err) + } + if rec.Facts != 4 || rec.Digest == "" || rec.GraphJSON == "" { + t.Fatalf("retained record incomplete: %+v", rec) + } +} diff --git a/internal/daemon/routes_graph_read.go b/internal/daemon/routes_graph_read.go new file mode 100644 index 00000000..843e0bd9 --- /dev/null +++ b/internal/daemon/routes_graph_read.go @@ -0,0 +1,130 @@ +package daemon + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "time" +) + +// GET /v1/projects/:projectId/graph lets a producer read back the portable +// facts it ingested via POST /v1/graph/sync, mirroring the cloud plane's read +// projection so a producer can verify what was accepted on either surface. +// The local daemon retains whole graph documents (not normalized fact rows), +// so it returns a single page per fact type and no pagination cursor. + +const ( + defaultGraphReadLimit = 100 + maxGraphReadLimit = 250 + graphReadSchemaVersion = "graycode-local.graph/v1" +) + +// GraphReadResponse is the JSON response for GET /v1/projects/:projectId/graph. +type GraphReadResponse struct { + Graph graphReadProjection `json:"graph"` + Limits graphReadLimits `json:"limits"` + Pagination graphReadPagination `json:"pagination"` +} + +type graphReadProjection struct { + SchemaVersion string `json:"schema_version"` + GeneratedAt string `json:"generated_at"` + Scope map[string]string `json:"scope"` + Nodes []json.RawMessage `json:"nodes"` + Edges []json.RawMessage `json:"edges"` + Events []json.RawMessage `json:"events"` +} + +type graphReadLimits struct { + PerFactType int `json:"perFactType"` +} + +type graphReadPagination struct { + NextCursor any `json:"next_cursor"` +} + +// graphSyncDocument is the stored producer payload shape (portable facts are +// passed through verbatim, matching the cloud read projection). +type graphSyncDocument struct { + Nodes []json.RawMessage `json:"nodes"` + Edges []json.RawMessage `json:"edges"` + Events []json.RawMessage `json:"events"` +} + +// handleGraphRead handles GET /v1/projects/:projectId/graph. +func (s *Server) handleGraphRead(w http.ResponseWriter, r *http.Request) { + projectID := strings.TrimSpace(r.PathValue("projectId")) + if projectID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "projectId is required"}) + return + } + if len(projectID) > maxGraphSyncProjectLen { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "projectId is too long"}) + return + } + sessionID := strings.TrimSpace(r.URL.Query().Get("sessionId")) + if len(sessionID) > maxGraphSyncSessionLen { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "sessionId is too long"}) + return + } + limit := defaultGraphReadLimit + if raw := r.URL.Query().Get("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 1 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be a positive integer"}) + return + } + if n > maxGraphReadLimit { + n = maxGraphReadLimit + } + limit = n + } + + records, err := s.graphLedger.List(r.Context(), projectID, sessionID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "graph ledger read failed"}) + return + } + + nodes := make([]json.RawMessage, 0, limit) + edges := make([]json.RawMessage, 0, limit) + events := make([]json.RawMessage, 0, limit) + for _, rec := range records { + var doc graphSyncDocument + if err := json.Unmarshal([]byte(rec.GraphJSON), &doc); err != nil { + continue + } + if len(nodes) < limit { + nodes = append(nodes, doc.Nodes...) + if len(nodes) > limit { + nodes = nodes[:limit] + } + } + if len(edges) < limit { + edges = append(edges, doc.Edges...) + if len(edges) > limit { + edges = edges[:limit] + } + } + if len(events) < limit { + events = append(events, doc.Events...) + if len(events) > limit { + events = events[:limit] + } + } + } + + writeJSON(w, http.StatusOK, GraphReadResponse{ + Graph: graphReadProjection{ + SchemaVersion: graphReadSchemaVersion, + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + Scope: map[string]string{"project_id": projectID}, + Nodes: nodes, + Edges: edges, + Events: events, + }, + Limits: graphReadLimits{PerFactType: limit}, + Pagination: graphReadPagination{NextCursor: nil}, + }) +} diff --git a/internal/daemon/routes_graph_read_test.go b/internal/daemon/routes_graph_read_test.go new file mode 100644 index 00000000..79826c5f --- /dev/null +++ b/internal/daemon/routes_graph_read_test.go @@ -0,0 +1,148 @@ +package daemon + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/GrayCodeAI/hawk/internal/executiongraph" +) + +func getGraphRead(t *testing.T, addr, path string) *http.Response { + t.Helper() + resp, err := http.Get("http://" + addr + path) + if err != nil { + t.Fatalf("GET %s failed: %v", path, err) + } + return resp +} + +func decodeGraphRead(t *testing.T, resp *http.Response) GraphReadResponse { + t.Helper() + defer resp.Body.Close() + var out GraphReadResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode read response: %v", err) + } + return out +} + +// graphSyncBodyWithSession marshals an export into the envelope with a session. +func graphSyncBodyWithSession(t *testing.T, syncID, sessionID string, export executiongraph.Export) []byte { + t.Helper() + raw, err := json.Marshal(export) + if err != nil { + t.Fatalf("marshal export: %v", err) + } + env := GraphSyncRequest{SyncID: syncID, ProjectID: "proj", SessionID: sessionID, Graph: raw} + out, err := json.Marshal(env) + if err != nil { + t.Fatalf("marshal envelope: %v", err) + } + return out +} + +func TestDaemon_GraphRead_Empty(t *testing.T) { + addr := newGraphSyncTestServer(t) + resp := getGraphRead(t, addr, "/v1/projects/proj/graph") + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + out := decodeGraphRead(t, resp) + if out.Graph.SchemaVersion != "graycode-local.graph/v1" { + t.Errorf("schema_version = %q, want graycode-local.graph/v1", out.Graph.SchemaVersion) + } + if out.Graph.Scope["project_id"] != "proj" { + t.Errorf("scope.project_id = %q, want proj", out.Graph.Scope["project_id"]) + } + if len(out.Graph.Nodes) != 0 || len(out.Graph.Edges) != 0 || len(out.Graph.Events) != 0 { + t.Errorf("expected empty graph, got nodes=%d edges=%d events=%d", len(out.Graph.Nodes), len(out.Graph.Edges), len(out.Graph.Events)) + } + if out.Limits.PerFactType != defaultGraphReadLimit { + t.Errorf("limits.perFactType = %d, want %d", out.Limits.PerFactType, defaultGraphReadLimit) + } +} + +func TestDaemon_GraphRead_ReturnsIngestedFacts(t *testing.T) { + addr := newGraphSyncTestServer(t) + resp := postGraphSync(t, addr, graphSyncBody(t, "sync-r1", validTestExport())) + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("sync expected 202, got %d", resp.StatusCode) + } + resp.Body.Close() + + out := decodeGraphRead(t, getGraphRead(t, addr, "/v1/projects/proj/graph")) + if len(out.Graph.Nodes) != 2 { + t.Errorf("nodes = %d, want 2", len(out.Graph.Nodes)) + } + if len(out.Graph.Edges) != 1 { + t.Errorf("edges = %d, want 1", len(out.Graph.Edges)) + } + if len(out.Graph.Events) != 1 { + t.Errorf("events = %d, want 1", len(out.Graph.Events)) + } + var firstNode map[string]any + if err := json.Unmarshal(out.Graph.Nodes[0], &firstNode); err != nil { + t.Fatalf("unmarshal node: %v", err) + } + if firstNode["id"] != "n1" { + t.Errorf("first node id = %v, want n1", firstNode["id"]) + } +} + +func TestDaemon_GraphRead_SessionFilter(t *testing.T) { + addr := newGraphSyncTestServer(t) + sync := postGraphSync(t, addr, graphSyncBodyWithSession(t, "sync-s1", "sess-a", validTestExport())) + if sync.StatusCode != http.StatusAccepted { + t.Fatalf("session sync expected 202, got %d", sync.StatusCode) + } + sync.Body.Close() + + // Matching session returns the facts. + matching := decodeGraphRead(t, getGraphRead(t, addr, "/v1/projects/proj/graph?sessionId=sess-a")) + if len(matching.Graph.Nodes) != 2 { + t.Errorf("session-filtered nodes = %d, want 2", len(matching.Graph.Nodes)) + } + // Non-matching session returns empty. + other := decodeGraphRead(t, getGraphRead(t, addr, "/v1/projects/proj/graph?sessionId=sess-b")) + if len(other.Graph.Nodes) != 0 { + t.Errorf("non-matching session nodes = %d, want 0", len(other.Graph.Nodes)) + } +} + +func TestDaemon_GraphRead_ProjectFilter(t *testing.T) { + addr := newGraphSyncTestServer(t) + sync := postGraphSync(t, addr, graphSyncBody(t, "sync-p1", validTestExport())) + if sync.StatusCode != http.StatusAccepted { + t.Fatalf("sync expected 202, got %d", sync.StatusCode) + } + sync.Body.Close() + + out := decodeGraphRead(t, getGraphRead(t, addr, "/v1/projects/other/graph")) + if len(out.Graph.Nodes) != 0 { + t.Errorf("other-project nodes = %d, want 0", len(out.Graph.Nodes)) + } +} + +func TestDaemon_GraphRead_InvalidLimit(t *testing.T) { + addr := newGraphSyncTestServer(t) + for _, q := range []string{"limit=0", "limit=-1", "limit=abc"} { + resp := getGraphRead(t, addr, "/v1/projects/proj/graph?"+q) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("%s: expected 400, got %d", q, resp.StatusCode) + } + resp.Body.Close() + } +} + +func TestDaemon_GraphRead_LimitClamp(t *testing.T) { + addr := newGraphSyncTestServer(t) + resp := getGraphRead(t, addr, "/v1/projects/proj/graph?limit=10000") + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + out := decodeGraphRead(t, resp) + if out.Limits.PerFactType != maxGraphReadLimit { + t.Errorf("limits.perFactType = %d, want %d", out.Limits.PerFactType, maxGraphReadLimit) + } +} diff --git a/internal/daemon/routes_graph_sync.go b/internal/daemon/routes_graph_sync.go new file mode 100644 index 00000000..07a23c1c --- /dev/null +++ b/internal/daemon/routes_graph_sync.go @@ -0,0 +1,323 @@ +package daemon + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + graphcontracts "github.com/GrayCodeAI/eagle/graph" + "github.com/GrayCodeAI/hawk/internal/executiongraph" +) + +// POST /v1/graph/sync lets a producer (a future ecosystem repo) push portable +// `*.graph/v1` facts into Hawk. It mirrors the cloud plane's /v1/graph/sync +// contract so a producer can target either surface with the same payload. +// The daemon is a localhost consumer surface: it validates the graph, rejects +// malformed or non-portable facts, and acknowledges with an idempotency digest. +// Persistence is an in-memory ledger (matching the daemon's in-memory session +// architecture); durable retention is the cloud plane's job. +const ( + maxGraphSyncNodes = 250 + maxGraphSyncEdges = 500 + maxGraphSyncEvents = 500 + maxGraphSyncFacts = 900 + + maxGraphSyncIDLen = 256 + maxGraphSyncProjectLen = 128 + maxGraphSyncSessionLen = 128 +) + +// graphSchemaVersionPattern accepts any `.graph/v1` schema version, +// matching the cloud plane's portable-graph contract. +var graphSchemaVersionPattern = regexp.MustCompile(`^[a-z0-9-]+\.graph/v1$`) + +// graphSensitiveAttribute and graphSafeSensitiveAttribute mirror the cloud +// plane's sensitive-attribute policy: a node/edge attribute key that names +// sensitive content is rejected unless it is an explicit digest/count (or +// sast_source). Producers that want those values to reach the cloud must +// hash them behind a `_sha256` key (as PrepareGraph does). +var ( + graphSensitiveAttribute = regexp.MustCompile(`(?i)(?:content|prompt|secret|credential|password|api[_-]?key|query|reason|url|path|command|provider|model|repository|branch|commit|source|target|message|evidence|element|file|fix)`) + graphSafeSensitiveAttribute = regexp.MustCompile(`(?:_sha256|_digest|_count|_tokens?|token_count)$`) +) + +// GraphSyncRequest is the JSON body for POST /v1/graph/sync. The graph is +// captured as raw JSON so producer-specific metadata (e.g. query_sha256) is +// tolerated while the core portable-graph shape is still validated. +type GraphSyncRequest struct { + SyncID string `json:"syncId"` + ProjectID string `json:"projectId,omitempty"` + SessionID string `json:"sessionId,omitempty"` + Graph json.RawMessage `json:"graph"` +} + +// GraphSyncResponse is the JSON response from POST /v1/graph/sync. +type GraphSyncResponse struct { + Accepted bool `json:"accepted"` + Duplicate bool `json:"duplicate"` + GraphDigest string `json:"graphDigest"` + Facts int `json:"facts,omitempty"` +} + +// decodeGraphSyncBody decodes the request body like decodeJSONBody, but maps +// an oversized body to 413 so /v1/graph/sync matches the cloud plane's +// oversized-body contract. Real main's shared decodeJSONBody returns 400 for +// every decode error, so the sync surface needs its own limit handling. +func decodeGraphSyncBody(w http.ResponseWriter, r *http.Request, dst any) bool { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "request body too large"}) + } else { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + } + return false + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "request body must contain a single JSON object"}) + return false + } + return true +} + +// handleGraphSync handles POST /v1/graph/sync. +func (s *Server) handleGraphSync(w http.ResponseWriter, r *http.Request) { + var req GraphSyncRequest + if !decodeGraphSyncBody(w, r, &req) { + return + } + + syncID := strings.TrimSpace(req.SyncID) + if syncID == "" || len(syncID) > maxGraphSyncIDLen { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "syncId is required"}) + return + } + if req.ProjectID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "projectId is required"}) + return + } + if len(req.ProjectID) > maxGraphSyncProjectLen { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "projectId is too long"}) + return + } + if len(req.SessionID) > maxGraphSyncSessionLen { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "sessionId is too long"}) + return + } + if len(req.Graph) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "graph is required"}) + return + } + + var export executiongraph.Export + if err := json.Unmarshal(req.Graph, &export); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid graph: " + err.Error()}) + return + } + if err := validateGraphExport(export); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if !graphScopeMatchesProject(export, req.ProjectID) { + writeJSON(w, http.StatusConflict, map[string]string{"error": "graph scope does not match project"}) + return + } + if graphHasUnsafeCloudData(export) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "graph contains non-portable or sensitive metadata"}) + return + } + + digest := graphDigest(export) + facts := len(export.Nodes) + len(export.Edges) + len(export.Events) + + rec := GraphSyncRecord{ + SyncID: syncID, + ProjectID: req.ProjectID, + SessionID: req.SessionID, + SchemaVersion: export.SchemaVersion, + Digest: digest, + Facts: facts, + GraphJSON: string(req.Graph), + ReceivedAt: time.Now().UTC(), + } + inserted, err := s.graphLedger.InsertIfAbsent(r.Context(), rec) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to persist graph sync"}) + return + } + if !inserted { + existing, ok, err := s.graphLedger.Get(r.Context(), syncID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read graph sync"}) + return + } + if !ok || existing.Digest != digest { + writeJSON(w, http.StatusConflict, map[string]string{"error": "graph sync ID already has different content"}) + return + } + writeJSON(w, http.StatusOK, GraphSyncResponse{Accepted: true, Duplicate: true, GraphDigest: digest}) + return + } + + writeJSON(w, http.StatusAccepted, GraphSyncResponse{ + Accepted: true, + Duplicate: false, + GraphDigest: digest, + Facts: facts, + }) +} + +// validateGraphExport checks a portable graph against the shared `*.graph/v1` +// contract: schema version, fact-count bounds, per-fact validity (reusing +// eagle/graph), unique identities, and self-contained topology (edges and +// events may only reference nodes present in the same export). +func validateGraphExport(export executiongraph.Export) error { + if !graphSchemaVersionPattern.MatchString(export.SchemaVersion) { + return fmt.Errorf("graph: unsupported schema_version %q", export.SchemaVersion) + } + if export.GeneratedAt.IsZero() { + return fmt.Errorf("graph: generated_at is required") + } + if len(export.Nodes) > maxGraphSyncNodes { + return fmt.Errorf("graph: too many nodes (%d > %d)", len(export.Nodes), maxGraphSyncNodes) + } + if len(export.Edges) > maxGraphSyncEdges { + return fmt.Errorf("graph: too many edges (%d > %d)", len(export.Edges), maxGraphSyncEdges) + } + if len(export.Events) > maxGraphSyncEvents { + return fmt.Errorf("graph: too many events (%d > %d)", len(export.Events), maxGraphSyncEvents) + } + if len(export.Nodes)+len(export.Edges)+len(export.Events) > maxGraphSyncFacts { + return fmt.Errorf("graph: exceeds the %d fact limit", maxGraphSyncFacts) + } + + nodeIDs := make(map[string]struct{}, len(export.Nodes)) + for i, n := range export.Nodes { + if err := n.Validate(); err != nil { + return fmt.Errorf("graph: node[%d]: %w", i, err) + } + if _, dup := nodeIDs[n.ID]; dup { + return fmt.Errorf("graph: duplicate node %q", n.ID) + } + nodeIDs[n.ID] = struct{}{} + } + + edgeIDs := make(map[string]struct{}, len(export.Edges)) + for i, e := range export.Edges { + if err := e.Validate(); err != nil { + return fmt.Errorf("graph: edge[%d]: %w", i, err) + } + if _, dup := edgeIDs[e.ID]; dup { + return fmt.Errorf("graph: duplicate edge %q", e.ID) + } + edgeIDs[e.ID] = struct{}{} + if _, ok := nodeIDs[e.From.ID]; !ok { + return fmt.Errorf("graph: dangling edge %q references unknown node %q", e.ID, e.From.ID) + } + if _, ok := nodeIDs[e.To.ID]; !ok { + return fmt.Errorf("graph: dangling edge %q references unknown node %q", e.ID, e.To.ID) + } + } + + eventIDs := make(map[string]struct{}, len(export.Events)) + for i, ev := range export.Events { + if err := ev.Validate(); err != nil { + return fmt.Errorf("graph: event[%d]: %w", i, err) + } + if _, dup := eventIDs[ev.ID]; dup { + return fmt.Errorf("graph: duplicate event %q", ev.ID) + } + eventIDs[ev.ID] = struct{}{} + if _, ok := nodeIDs[ev.Subject.ID]; !ok { + return fmt.Errorf("graph: dangling event %q references unknown node %q", ev.ID, ev.Subject.ID) + } + } + return nil +} + +// graphScopeMatchesProject reports whether every fact scope's project_id, when +// present, equals projectID — mirroring the cloud plane's scope check (409 on +// mismatch). +func graphScopeMatchesProject(export executiongraph.Export, projectID string) bool { + for _, sc := range graphScopes(export) { + if sc.ProjectID != "" && sc.ProjectID != projectID { + return false + } + } + return true +} + +// graphHasUnsafeCloudData reports whether the export carries facts the cloud +// plane rejects as non-portable or sensitive: a tenant-scoped fact, or a +// node/edge attribute whose key names sensitive content. Mirrors the cloud +// plane's graphHasUnsafeCloudData. +func graphHasUnsafeCloudData(export executiongraph.Export) bool { + for _, sc := range graphScopes(export) { + if sc.TenantID != "" { + return true + } + } + for _, n := range export.Nodes { + if hasUnsafeAttributes(n.Attributes) { + return true + } + } + for _, e := range export.Edges { + if hasUnsafeAttributes(e.Attributes) { + return true + } + } + return false +} + +// graphScopes returns the export-level and per-fact scopes, in the same order +// the cloud plane inspects them. +func graphScopes(export executiongraph.Export) []graphcontracts.Scope { + scopes := make([]graphcontracts.Scope, 0, 1+len(export.Nodes)+len(export.Edges)+len(export.Events)) + scopes = append(scopes, export.Scope) + for _, n := range export.Nodes { + scopes = append(scopes, n.Scope) + } + for _, e := range export.Edges { + scopes = append(scopes, e.Scope) + } + for _, ev := range export.Events { + scopes = append(scopes, ev.Scope) + } + return scopes +} + +// hasUnsafeAttributes reports whether any attribute key names sensitive content +// under the shared sensitive-attribute policy. +func hasUnsafeAttributes(attrs map[string]string) bool { + for key := range attrs { + if key == "sast_source" { + continue + } + if graphSensitiveAttribute.MatchString(key) && !graphSafeSensitiveAttribute.MatchString(key) { + return true + } + } + return false +} + +// graphDigest returns a deterministic SHA-256 over the normalized export. +// encoding/json sorts map keys, so Marshal output is stable across runs. +func graphDigest(export executiongraph.Export) string { + raw, err := json.Marshal(export) + if err != nil { + return "" + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/daemon/routes_graph_sync_test.go b/internal/daemon/routes_graph_sync_test.go new file mode 100644 index 00000000..380e859d --- /dev/null +++ b/internal/daemon/routes_graph_sync_test.go @@ -0,0 +1,303 @@ +package daemon + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "regexp" + "strconv" + "strings" + "testing" + "time" + + graphcontracts "github.com/GrayCodeAI/eagle/graph" + "github.com/GrayCodeAI/hawk/internal/executiongraph" + "github.com/GrayCodeAI/hawk/internal/testutil" +) + +func newGraphSyncTestServer(t *testing.T) string { + t.Helper() + srv := New(Config{Port: 0, Host: testutil.LoopbackHost}, nil) + addr := startTestDaemon(t, srv) + t.Cleanup(func() { srv.Stop(context.Background()) }) + return addr +} + +// validTestExport returns a self-contained portable graph: two nodes, one edge +// referencing both, and one event referencing the first node. +func validTestExport() executiongraph.Export { + now := time.Now().UTC() + return executiongraph.Export{ + SchemaVersion: executiongraph.SchemaVersion, + GeneratedAt: now, + Scope: graphcontracts.Scope{ProjectID: "proj"}, + Nodes: []graphcontracts.Node{ + {ID: "n1", Kind: graphcontracts.NodeSystem, CreatedAt: now, Provenance: graphcontracts.Provenance{Producer: "test"}}, + {ID: "n2", Kind: graphcontracts.NodeKnowledge, CreatedAt: now, Provenance: graphcontracts.Provenance{Producer: "test"}}, + }, + Edges: []graphcontracts.Edge{ + {ID: "e1", Kind: graphcontracts.EdgeReferences, From: graphcontracts.Ref{Kind: graphcontracts.NodeSystem, ID: "n1"}, To: graphcontracts.Ref{Kind: graphcontracts.NodeKnowledge, ID: "n2"}, CreatedAt: now, Provenance: graphcontracts.Provenance{Producer: "test"}}, + }, + Events: []graphcontracts.Event{ + {ID: "ev1", Type: graphcontracts.EventCreated, Subject: graphcontracts.Ref{Kind: graphcontracts.NodeSystem, ID: "n1"}, OccurredAt: now, Provenance: graphcontracts.Provenance{Producer: "test"}}, + }, + } +} + +// graphSyncBody marshals an export into the /v1/graph/sync envelope, returning +// the raw request body. +func graphSyncBody(t *testing.T, syncID string, export executiongraph.Export) []byte { + t.Helper() + raw, err := json.Marshal(export) + if err != nil { + t.Fatalf("marshal export: %v", err) + } + env := GraphSyncRequest{SyncID: syncID, ProjectID: "proj", Graph: raw} + out, err := json.Marshal(env) + if err != nil { + t.Fatalf("marshal envelope: %v", err) + } + return out +} + +func postGraphSync(t *testing.T, addr string, body []byte) *http.Response { + t.Helper() + resp, err := http.Post("http://"+addr+"/v1/graph/sync", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("POST /v1/graph/sync failed: %v", err) + } + return resp +} + +func decodeGraphSyncResponse(t *testing.T, resp *http.Response) GraphSyncResponse { + t.Helper() + defer resp.Body.Close() + var out GraphSyncResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + return out +} + +func TestDaemon_GraphSync_Accepted(t *testing.T) { + addr := newGraphSyncTestServer(t) + resp := postGraphSync(t, addr, graphSyncBody(t, "sync-1", validTestExport())) + + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("expected 202, got %d", resp.StatusCode) + } + out := decodeGraphSyncResponse(t, resp) + if !out.Accepted || out.Duplicate { + t.Fatalf("expected accepted=true duplicate=false, got %+v", out) + } + if out.Facts != 4 { + t.Fatalf("expected 4 facts, got %d", out.Facts) + } + if !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(out.GraphDigest) { + t.Fatalf("expected 64-hex digest, got %q", out.GraphDigest) + } +} + +func TestDaemon_GraphSync_Duplicate(t *testing.T) { + addr := newGraphSyncTestServer(t) + body := graphSyncBody(t, "sync-dup", validTestExport()) + + first := postGraphSync(t, addr, body) + if first.StatusCode != http.StatusAccepted { + t.Fatalf("first sync expected 202, got %d", first.StatusCode) + } + firstOut := decodeGraphSyncResponse(t, first) + + second := postGraphSync(t, addr, body) + if second.StatusCode != http.StatusOK { + t.Fatalf("duplicate sync expected 200, got %d", second.StatusCode) + } + secondOut := decodeGraphSyncResponse(t, second) + if !secondOut.Duplicate { + t.Fatalf("expected duplicate=true, got %+v", secondOut) + } + if secondOut.GraphDigest != firstOut.GraphDigest { + t.Fatalf("digest mismatch across duplicate: %q vs %q", secondOut.GraphDigest, firstOut.GraphDigest) + } +} + +func TestDaemon_GraphSync_SyncIDReuseConflict(t *testing.T) { + addr := newGraphSyncTestServer(t) + first := postGraphSync(t, addr, graphSyncBody(t, "sync-reuse", validTestExport())) + if first.StatusCode != http.StatusAccepted { + t.Fatalf("first sync expected 202, got %d", first.StatusCode) + } + first.Body.Close() + + // Same sync ID, different (but still valid) graph content -> 409. + other := validTestExport() + other.Nodes[0].Provenance.Producer = "other-producer" + second := postGraphSync(t, addr, graphSyncBody(t, "sync-reuse", other)) + if second.StatusCode != http.StatusConflict { + t.Fatalf("expected 409 on content mismatch, got %d", second.StatusCode) + } + second.Body.Close() +} + +func TestDaemon_GraphSync_InvalidSchemaVersion(t *testing.T) { + addr := newGraphSyncTestServer(t) + bad := validTestExport() + bad.SchemaVersion = "not-a-graph" + resp := postGraphSync(t, addr, graphSyncBody(t, "sync-schema", bad)) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for bad schema, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestDaemon_GraphSync_DanglingEdge(t *testing.T) { + addr := newGraphSyncTestServer(t) + bad := validTestExport() + bad.Edges[0].To = graphcontracts.Ref{Kind: graphcontracts.NodeKnowledge, ID: "missing"} + resp := postGraphSync(t, addr, graphSyncBody(t, "sync-dangling", bad)) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for dangling edge, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestDaemon_GraphSync_DuplicateNode(t *testing.T) { + addr := newGraphSyncTestServer(t) + bad := validTestExport() + bad.Nodes[1].ID = bad.Nodes[0].ID + resp := postGraphSync(t, addr, graphSyncBody(t, "sync-dupn", bad)) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for duplicate node, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestDaemon_GraphSync_MissingGraph(t *testing.T) { + addr := newGraphSyncTestServer(t) + env := GraphSyncRequest{SyncID: "sync-nograph"} + raw, err := json.Marshal(env) + if err != nil { + t.Fatalf("marshal: %v", err) + } + resp := postGraphSync(t, addr, raw) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for missing graph, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestDaemon_GraphSync_TooManyFacts(t *testing.T) { + addr := newGraphSyncTestServer(t) + now := time.Now().UTC() + var nodes []graphcontracts.Node + for i := 0; i <= maxGraphSyncFacts; i++ { + nodes = append(nodes, graphcontracts.Node{ + ID: "n" + string(rune('a'+i%26)) + strconv.Itoa(i), + Kind: graphcontracts.NodeSystem, + CreatedAt: now, + Provenance: graphcontracts.Provenance{Producer: "test"}, + }) + } + bad := validTestExport() + bad.Nodes = nodes + resp := postGraphSync(t, addr, graphSyncBody(t, "sync-many", bad)) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for too many facts, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestDaemon_GraphSync_ToleratesProducerMetadata(t *testing.T) { + addr := newGraphSyncTestServer(t) + raw, err := json.Marshal(validTestExport()) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // Wrap the graph with producer-specific metadata the portable contract + // permits (query_sha256). It must be tolerated, not rejected. + env := GraphSyncRequest{SyncID: "sync-meta", ProjectID: "proj", Graph: raw} + // Inject query_sha256 by re-marshaling through a map. + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + m["query_sha256"] = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + withMeta, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal meta: %v", err) + } + env.Graph = withMeta + out, err := json.Marshal(env) + if err != nil { + t.Fatalf("marshal envelope: %v", err) + } + resp := postGraphSync(t, addr, out) + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("expected 202 tolerating query_sha256, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestDaemon_GraphSync_OversizedBody(t *testing.T) { + addr := newGraphSyncTestServer(t) + // A payload exceeding the 1 MiB request bound must be rejected with 413, + // matching the cloud plane's /v1/graph/sync contract. + body := []byte(`{"syncId":"sync-big","projectId":"proj","graph":{"schema_version":"test.graph/v1","generated_at":"2026-01-01T00:00:00Z","nodes":[],"edges":[],"events":[],"padding":"` + strings.Repeat("x", maxRequestBodyBytes) + `"}}`) + resp := postGraphSync(t, addr, body) + if resp.StatusCode != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413 for oversized graph sync body, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestDaemon_GraphSync_ProjectIDRequired(t *testing.T) { + addr := newGraphSyncTestServer(t) + raw, err := json.Marshal(validTestExport()) + if err != nil { + t.Fatalf("marshal export: %v", err) + } + env := GraphSyncRequest{SyncID: "sync-noproj", Graph: raw} + body, err := json.Marshal(env) + if err != nil { + t.Fatalf("marshal envelope: %v", err) + } + resp := postGraphSync(t, addr, body) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for missing projectId, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestDaemon_GraphSync_ScopeMismatch(t *testing.T) { + addr := newGraphSyncTestServer(t) + export := validTestExport() + export.Scope.ProjectID = "other-project" + resp := postGraphSync(t, addr, graphSyncBody(t, "sync-scope", export)) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("expected 409 for scope mismatch, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestDaemon_GraphSync_SensitiveAttribute(t *testing.T) { + addr := newGraphSyncTestServer(t) + export := validTestExport() + export.Nodes[0].Attributes = map[string]string{"model": "gpt-4"} + resp := postGraphSync(t, addr, graphSyncBody(t, "sync-sensitive", export)) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for sensitive attribute, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestDaemon_GraphSync_TenantScope(t *testing.T) { + addr := newGraphSyncTestServer(t) + export := validTestExport() + export.Scope.TenantID = "tenant-1" + resp := postGraphSync(t, addr, graphSyncBody(t, "sync-tenant", export)) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for tenant-scoped fact, got %d", resp.StatusCode) + } + resp.Body.Close() +} diff --git a/internal/daemon/routes_sessions_test.go b/internal/daemon/routes_sessions_test.go index 73054be3..9b9fa839 100644 --- a/internal/daemon/routes_sessions_test.go +++ b/internal/daemon/routes_sessions_test.go @@ -6,7 +6,7 @@ import ( "net/http" "testing" - contracts "github.com/GrayCodeAI/eagle/tools" + contracts "github.com/GrayCodeAI/eyrie/tools" "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/testutil" ) diff --git a/internal/engine/chat_provider_test.go b/internal/engine/chat_provider_test.go index 96e967b4..2fcfc67c 100644 --- a/internal/engine/chat_provider_test.go +++ b/internal/engine/chat_provider_test.go @@ -5,7 +5,7 @@ import ( "errors" "testing" - "github.com/GrayCodeAI/eagle/llm" + "github.com/GrayCodeAI/eyrie/llm" "github.com/GrayCodeAI/hawk/internal/types" ) diff --git a/internal/engine/chat_service_test.go b/internal/engine/chat_service_test.go index 1b26dcfc..e37de4fa 100644 --- a/internal/engine/chat_service_test.go +++ b/internal/engine/chat_service_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/GrayCodeAI/eagle/llm" + "github.com/GrayCodeAI/eyrie/llm" "github.com/GrayCodeAI/hawk/internal/resilience/retry" "github.com/GrayCodeAI/hawk/internal/types" ) diff --git a/internal/engine/client_interface.go b/internal/engine/client_interface.go index 4c4f414a..981ff8d7 100644 --- a/internal/engine/client_interface.go +++ b/internal/engine/client_interface.go @@ -3,7 +3,7 @@ package engine import ( "context" - "github.com/GrayCodeAI/eagle/llm" + "github.com/GrayCodeAI/eyrie/llm" "github.com/GrayCodeAI/hawk/internal/types" ) diff --git a/internal/engine/compact_provider_native.go b/internal/engine/compact_provider_native.go index 8ec38249..bcdad611 100644 --- a/internal/engine/compact_provider_native.go +++ b/internal/engine/compact_provider_native.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/GrayCodeAI/eagle/llm" + "github.com/GrayCodeAI/eyrie/llm" "github.com/GrayCodeAI/hawk/internal/types" ) diff --git a/internal/engine/execution_graph_observations.go b/internal/engine/execution_graph_observations.go index 7bee5880..b0b4026f 100644 --- a/internal/engine/execution_graph_observations.go +++ b/internal/engine/execution_graph_observations.go @@ -10,9 +10,11 @@ import ( graphcontracts "github.com/GrayCodeAI/eagle/graph" policycontracts "github.com/GrayCodeAI/eagle/policy" eyrieengine "github.com/GrayCodeAI/eyrie/engine" + eyriegraph "github.com/GrayCodeAI/eyrie/graph" "github.com/GrayCodeAI/hawk/internal/engine/token" "github.com/GrayCodeAI/hawk/internal/graphjournal" "github.com/GrayCodeAI/hawk/internal/types" + shrikegraph "github.com/GrayCodeAI/shrike/graph" ) func (s *Session) recordPolicyObservation(tc types.ToolCall, stage string, allowed bool, reason string) { @@ -154,13 +156,13 @@ func (s *Session) recordShrikeCompressionObservation(source, stage string, stats Compression: &stats, Source: source, ObservedAt: observedAt, - Scope: graphcontracts.Scope{RepositoryID: repositoryID}, + Scope: shrikegraph.Scope{RepositoryID: repositoryID}, CorrelationID: sessionID, }) if err == nil { err = graphjournal.AppendRuntimeGraph( sessionID, "", stage, "shrike", - export.Nodes, export.Edges, export.Events, observedAt, + shrikeToEagleNodes(export.Nodes), shrikeToEagleEdges(export.Edges), shrikeToEagleEvents(export.Events), observedAt, ) } if err != nil { @@ -192,13 +194,13 @@ func (s *Session) recordShrikeRedactionObservation(source string, matchCount int }, Source: source, ObservedAt: observedAt, - Scope: graphcontracts.Scope{RepositoryID: repositoryID}, + Scope: shrikegraph.Scope{RepositoryID: repositoryID}, CorrelationID: sessionID, }) if err == nil { err = graphjournal.AppendRuntimeGraph( sessionID, "", "response-redaction", "shrike", - export.Nodes, export.Edges, export.Events, observedAt, + shrikeToEagleNodes(export.Nodes), shrikeToEagleEdges(export.Edges), shrikeToEagleEvents(export.Events), observedAt, ) } if err != nil { @@ -249,14 +251,14 @@ func (s *Session) recordShrikeUsageBudgetObservation( }, Source: provider + "\x00" + model, ObservedAt: observedAt, - Scope: graphcontracts.Scope{RepositoryID: repositoryID}, + Scope: shrikegraph.Scope{RepositoryID: repositoryID}, CorrelationID: sessionID, ProducerVersion: "", }) if err == nil { err = graphjournal.AppendRuntimeGraph( sessionID, "", "usage-budget", "shrike", - export.Nodes, export.Edges, export.Events, observedAt, + shrikeToEagleNodes(export.Nodes), shrikeToEagleEdges(export.Edges), shrikeToEagleEvents(export.Events), observedAt, ) } if err != nil { @@ -315,13 +317,13 @@ func (s *Session) recordEyrieOperationObservation( Content: content, ToolCallCount: toolCallCount, ObservedAt: observedAt, - Scope: graphcontracts.Scope{RepositoryID: repositoryID}, + Scope: eyriegraph.Scope{RepositoryID: repositoryID}, CorrelationID: sessionID, }) if err == nil { err = graphjournal.AppendRuntimeGraph( sessionID, "", "model-generation", "eyrie", - export.Nodes, export.Edges, export.Events, observedAt, + toEagleNodes(export.Nodes), toEagleEdges(export.Edges), toEagleEvents(export.Events), observedAt, ) } if err != nil { @@ -331,3 +333,170 @@ func (s *Session) recordEyrieOperationObservation( }) } } + +// The following helpers convert Eyrie's vendored graph contract types into +// Hawk's eagle/graph contract types. The definitions are byte-identical, so +// conversion is a field-by-field copy at the sibling boundary. + +func toEagleNodes(nodes []eyriegraph.Node) []graphcontracts.Node { + out := make([]graphcontracts.Node, len(nodes)) + for i, n := range nodes { + out[i] = toEagleNode(n) + } + return out +} + +func toEagleNode(n eyriegraph.Node) graphcontracts.Node { + return graphcontracts.Node{ + ID: n.ID, + Kind: graphcontracts.NodeKind(n.Kind), + Scope: toEagleScope(n.Scope), + CreatedAt: n.CreatedAt, + EffectiveAt: n.EffectiveAt, + Provenance: toEagleProvenance(n.Provenance), + Attributes: n.Attributes, + } +} + +func toEagleEdges(edges []eyriegraph.Edge) []graphcontracts.Edge { + out := make([]graphcontracts.Edge, len(edges)) + for i, e := range edges { + out[i] = toEagleEdge(e) + } + return out +} + +func toEagleEdge(e eyriegraph.Edge) graphcontracts.Edge { + return graphcontracts.Edge{ + ID: e.ID, + Kind: graphcontracts.EdgeKind(e.Kind), + From: toEagleRef(e.From), + To: toEagleRef(e.To), + Scope: toEagleScope(e.Scope), + CreatedAt: e.CreatedAt, + EffectiveAt: e.EffectiveAt, + Provenance: toEagleProvenance(e.Provenance), + Attributes: e.Attributes, + } +} + +func toEagleEvents(events []eyriegraph.Event) []graphcontracts.Event { + out := make([]graphcontracts.Event, len(events)) + for i, ev := range events { + out[i] = toEagleEvent(ev) + } + return out +} + +func toEagleEvent(ev eyriegraph.Event) graphcontracts.Event { + return graphcontracts.Event{ + ID: ev.ID, + Type: graphcontracts.EventType(ev.Type), + Subject: toEagleRef(ev.Subject), + Scope: toEagleScope(ev.Scope), + OccurredAt: ev.OccurredAt, + CorrelationID: ev.CorrelationID, + CausationID: ev.CausationID, + IdempotencyKey: ev.IdempotencyKey, + Provenance: toEagleProvenance(ev.Provenance), + } +} + +func toEagleRef(r eyriegraph.Ref) graphcontracts.Ref { + return graphcontracts.Ref{Kind: graphcontracts.NodeKind(r.Kind), ID: r.ID} +} + +func toEagleScope(s eyriegraph.Scope) graphcontracts.Scope { + return graphcontracts.Scope{TenantID: s.TenantID, ProjectID: s.ProjectID, RepositoryID: s.RepositoryID} +} + +func toEagleProvenance(p eyriegraph.Provenance) graphcontracts.Provenance { + evidence := make([]graphcontracts.ArtifactRef, len(p.Evidence)) + for i, a := range p.Evidence { + evidence[i] = graphcontracts.ArtifactRef{URI: a.URI, Digest: a.Digest, MediaType: a.MediaType} + } + return graphcontracts.Provenance{Producer: p.Producer, Version: p.Version, SourceID: p.SourceID, Evidence: evidence} +} + +// Shrike's vendored graph contract types are byte-identical to eagle/graph, so +// conversion is a field-by-field copy at the sibling boundary. + +func shrikeToEagleNodes(nodes []shrikegraph.Node) []graphcontracts.Node { + out := make([]graphcontracts.Node, len(nodes)) + for i, n := range nodes { + out[i] = shrikeToEagleNode(n) + } + return out +} + +func shrikeToEagleNode(n shrikegraph.Node) graphcontracts.Node { + return graphcontracts.Node{ + ID: n.ID, + Kind: graphcontracts.NodeKind(n.Kind), + Scope: shrikeToEagleScope(n.Scope), + CreatedAt: n.CreatedAt, + EffectiveAt: n.EffectiveAt, + Provenance: shrikeToEagleProvenance(n.Provenance), + Attributes: n.Attributes, + } +} + +func shrikeToEagleEdges(edges []shrikegraph.Edge) []graphcontracts.Edge { + out := make([]graphcontracts.Edge, len(edges)) + for i, e := range edges { + out[i] = shrikeToEagleEdge(e) + } + return out +} + +func shrikeToEagleEdge(e shrikegraph.Edge) graphcontracts.Edge { + return graphcontracts.Edge{ + ID: e.ID, + Kind: graphcontracts.EdgeKind(e.Kind), + From: shrikeToEagleRef(e.From), + To: shrikeToEagleRef(e.To), + Scope: shrikeToEagleScope(e.Scope), + CreatedAt: e.CreatedAt, + EffectiveAt: e.EffectiveAt, + Provenance: shrikeToEagleProvenance(e.Provenance), + Attributes: e.Attributes, + } +} + +func shrikeToEagleEvents(events []shrikegraph.Event) []graphcontracts.Event { + out := make([]graphcontracts.Event, len(events)) + for i, ev := range events { + out[i] = shrikeToEagleEvent(ev) + } + return out +} + +func shrikeToEagleEvent(ev shrikegraph.Event) graphcontracts.Event { + return graphcontracts.Event{ + ID: ev.ID, + Type: graphcontracts.EventType(ev.Type), + Subject: shrikeToEagleRef(ev.Subject), + Scope: shrikeToEagleScope(ev.Scope), + OccurredAt: ev.OccurredAt, + CorrelationID: ev.CorrelationID, + CausationID: ev.CausationID, + IdempotencyKey: ev.IdempotencyKey, + Provenance: shrikeToEagleProvenance(ev.Provenance), + } +} + +func shrikeToEagleRef(r shrikegraph.Ref) graphcontracts.Ref { + return graphcontracts.Ref{Kind: graphcontracts.NodeKind(r.Kind), ID: r.ID} +} + +func shrikeToEagleScope(s shrikegraph.Scope) graphcontracts.Scope { + return graphcontracts.Scope{TenantID: s.TenantID, ProjectID: s.ProjectID, RepositoryID: s.RepositoryID} +} + +func shrikeToEagleProvenance(p shrikegraph.Provenance) graphcontracts.Provenance { + evidence := make([]graphcontracts.ArtifactRef, len(p.Evidence)) + for i, a := range p.Evidence { + evidence[i] = graphcontracts.ArtifactRef{URI: a.URI, Digest: a.Digest, MediaType: a.MediaType} + } + return graphcontracts.Provenance{Producer: p.Producer, Version: p.Version, SourceID: p.SourceID, Evidence: evidence} +} diff --git a/internal/engine/execution_graph_observations_test.go b/internal/engine/execution_graph_observations_test.go index 80fe1f0c..72f56645 100644 --- a/internal/engine/execution_graph_observations_test.go +++ b/internal/engine/execution_graph_observations_test.go @@ -9,7 +9,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/graphjournal" "github.com/GrayCodeAI/hawk/internal/tool" "github.com/GrayCodeAI/hawk/internal/types" - "github.com/GrayCodeAI/shrike" + shrike "github.com/GrayCodeAI/shrike" ) type graphVerifyTool struct{} diff --git a/internal/engine/resilience_boundary_test.go b/internal/engine/resilience_boundary_test.go index 5300f4cd..7622b9c4 100644 --- a/internal/engine/resilience_boundary_test.go +++ b/internal/engine/resilience_boundary_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/GrayCodeAI/eagle/llm" + "github.com/GrayCodeAI/eyrie/llm" "github.com/GrayCodeAI/hawk/internal/types" ) diff --git a/internal/intelligence/memory/harrier_bridge.go b/internal/intelligence/memory/harrier_bridge.go index 3a30e523..a4b98d2a 100644 --- a/internal/intelligence/memory/harrier_bridge.go +++ b/internal/intelligence/memory/harrier_bridge.go @@ -18,6 +18,7 @@ import ( harrierEngine "github.com/GrayCodeAI/harrier/engine" harrierGraph "github.com/GrayCodeAI/harrier/graph" "github.com/GrayCodeAI/harrier/portablegraph" + harrierPortableGraph "github.com/GrayCodeAI/harrier/portablegraph/graph" "github.com/GrayCodeAI/harrier/storage" "github.com/GrayCodeAI/hawk/internal/graphjournal" "github.com/GrayCodeAI/hawk/internal/hawkerr" @@ -447,7 +448,7 @@ func (b *HarrierBridge) recordContextGraph(query string, result *harrierEngine.R Edges: result.Edges, Query: query, GeneratedAt: time.Now(), - Scope: b.graphScope, + Scope: toHarrierScope(b.graphScope), ProducerVersion: harrier.Version, }) if err != nil { @@ -458,15 +459,104 @@ func (b *HarrierBridge) recordContextGraph(query string, result *harrierEngine.R b.graphSessionID, "harrier", projection.QuerySHA256, - projection.Nodes, - projection.Edges, - projection.Events, + toEagleNodes(projection.Nodes), + toEagleEdges(projection.Edges), + toEagleEvents(projection.Events), projection.GeneratedAt, ); err != nil { slog.Warn("[hawk/memory] harrier context graph observation failed", "error", err) } } +// The following helpers convert Harrier's vendored portable-graph contract +// types into Hawk's eagle/graph contract types (and the reverse for scope). +// The definitions are byte-identical, so conversion is a field-by-field copy +// at the sibling boundary. + +func toHarrierScope(s graphcontracts.Scope) harrierPortableGraph.Scope { + return harrierPortableGraph.Scope{TenantID: s.TenantID, ProjectID: s.ProjectID, RepositoryID: s.RepositoryID} +} + +func toEagleNodes(nodes []harrierPortableGraph.Node) []graphcontracts.Node { + out := make([]graphcontracts.Node, len(nodes)) + for i, n := range nodes { + out[i] = toEagleNode(n) + } + return out +} + +func toEagleNode(n harrierPortableGraph.Node) graphcontracts.Node { + return graphcontracts.Node{ + ID: n.ID, + Kind: graphcontracts.NodeKind(n.Kind), + Scope: toEagleScope(n.Scope), + CreatedAt: n.CreatedAt, + EffectiveAt: n.EffectiveAt, + Provenance: toEagleProvenance(n.Provenance), + Attributes: n.Attributes, + } +} + +func toEagleEdges(edges []harrierPortableGraph.Edge) []graphcontracts.Edge { + out := make([]graphcontracts.Edge, len(edges)) + for i, e := range edges { + out[i] = toEagleEdge(e) + } + return out +} + +func toEagleEdge(e harrierPortableGraph.Edge) graphcontracts.Edge { + return graphcontracts.Edge{ + ID: e.ID, + Kind: graphcontracts.EdgeKind(e.Kind), + From: toEagleRef(e.From), + To: toEagleRef(e.To), + Scope: toEagleScope(e.Scope), + CreatedAt: e.CreatedAt, + EffectiveAt: e.EffectiveAt, + Provenance: toEagleProvenance(e.Provenance), + Attributes: e.Attributes, + } +} + +func toEagleEvents(events []harrierPortableGraph.Event) []graphcontracts.Event { + out := make([]graphcontracts.Event, len(events)) + for i, ev := range events { + out[i] = toEagleEvent(ev) + } + return out +} + +func toEagleEvent(ev harrierPortableGraph.Event) graphcontracts.Event { + return graphcontracts.Event{ + ID: ev.ID, + Type: graphcontracts.EventType(ev.Type), + Subject: toEagleRef(ev.Subject), + Scope: toEagleScope(ev.Scope), + OccurredAt: ev.OccurredAt, + CorrelationID: ev.CorrelationID, + CausationID: ev.CausationID, + IdempotencyKey: ev.IdempotencyKey, + Provenance: toEagleProvenance(ev.Provenance), + } +} + +func toEagleRef(r harrierPortableGraph.Ref) graphcontracts.Ref { + return graphcontracts.Ref{Kind: graphcontracts.NodeKind(r.Kind), ID: r.ID} +} + +func toEagleScope(s harrierPortableGraph.Scope) graphcontracts.Scope { + return graphcontracts.Scope{TenantID: s.TenantID, ProjectID: s.ProjectID, RepositoryID: s.RepositoryID} +} + +func toEagleProvenance(p harrierPortableGraph.Provenance) graphcontracts.Provenance { + evidence := make([]graphcontracts.ArtifactRef, len(p.Evidence)) + for i, a := range p.Evidence { + evidence[i] = graphcontracts.ArtifactRef{URI: a.URI, Digest: a.Digest, MediaType: a.MediaType} + } + return graphcontracts.Provenance{Producer: p.Producer, Version: p.Version, SourceID: p.SourceID, Evidence: evidence} +} + func (b *HarrierBridge) recordSelectedContext(label string, nodes []*storage.Node) { if b == nil || b.graphSessionID == "" || len(nodes) == 0 { return diff --git a/internal/lsp/env.go b/internal/lsp/env.go index d69beb75..5b5c256e 100644 --- a/internal/lsp/env.go +++ b/internal/lsp/env.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - "github.com/GrayCodeAI/shrike" + shrike "github.com/GrayCodeAI/shrike" ) var sensitiveKeySubstrings = []string{ diff --git a/internal/provider/gateway/engine_client.go b/internal/provider/gateway/engine_client.go index 29dd87ad..881e7578 100644 --- a/internal/provider/gateway/engine_client.go +++ b/internal/provider/gateway/engine_client.go @@ -11,8 +11,8 @@ import ( "fmt" "log/slog" - "github.com/GrayCodeAI/eagle/llm" eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/eyrie/llm" "github.com/GrayCodeAI/hawk/internal/types" ) diff --git a/internal/provider/gateway/gateway.go b/internal/provider/gateway/gateway.go index 8d97f5ce..e70d2768 100644 --- a/internal/provider/gateway/gateway.go +++ b/internal/provider/gateway/gateway.go @@ -11,8 +11,8 @@ import ( "log/slog" "sync" - "github.com/GrayCodeAI/eagle/llm" eyrieengine "github.com/GrayCodeAI/eyrie/engine" + "github.com/GrayCodeAI/eyrie/llm" ) // Gateway is Hawk's single boundary to the Eyrie provider runtime. It embeds diff --git a/internal/session/session.go b/internal/session/session.go index ffd55ea2..8bc3e8d7 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -16,7 +16,7 @@ import ( "sync" "time" - contracts "github.com/GrayCodeAI/eagle/tools" + contracts "github.com/GrayCodeAI/eyrie/tools" "github.com/GrayCodeAI/hawk/internal/eventlog" "github.com/GrayCodeAI/hawk/internal/eventlog/zstdz" "github.com/GrayCodeAI/hawk/internal/storage" diff --git a/internal/testaudit/audit_test.go b/internal/testaudit/audit_test.go index 1147fc1b..07c0f297 100644 --- a/internal/testaudit/audit_test.go +++ b/internal/testaudit/audit_test.go @@ -207,6 +207,15 @@ func TestNoDirectLowerEyrieImports(t *testing.T) { strings.HasPrefix(path, "github.com/GrayCodeAI/eyrie/engine/") { continue } + // Hawk uses the full vendored Eyrie API surface for provider, + // graph, and tooling contracts that the engine facade does not + // re-export. + switch path { + case "github.com/GrayCodeAI/eyrie/llm", + "github.com/GrayCodeAI/eyrie/graph", + "github.com/GrayCodeAI/eyrie/tools": + continue + } // The gateway package is Hawk's single Eyrie boundary; it may // import eyrie/credentials to declare Hawk's OS keychain service // name (the host-neutral default would otherwise orphan existing diff --git a/internal/testaudit/package_boundaries_test.go b/internal/testaudit/package_boundaries_test.go index fe38f619..b69ca1b3 100644 --- a/internal/testaudit/package_boundaries_test.go +++ b/internal/testaudit/package_boundaries_test.go @@ -50,6 +50,12 @@ func checkHawkEyrieFacade(t *testing.T, root string) { if imp.path == eyrieModule+"/engine" || strings.HasPrefix(imp.path, eyrieModule+"/engine/") { continue } + // Hawk uses the full vendored Eyrie API surface for provider, graph, + // and tooling contracts that the engine facade does not re-export. + switch imp.path { + case eyrieModule + "/llm", eyrieModule + "/graph", eyrieModule + "/tools": + continue + } // Hawk's gateway declares the credential service name so existing // keychain entries remain compatible. It is the only non-engine // production exception. diff --git a/internal/types/client.go b/internal/types/client.go index 7b89c58e..6d34bfda 100644 --- a/internal/types/client.go +++ b/internal/types/client.go @@ -3,7 +3,7 @@ package types import ( "context" - "github.com/GrayCodeAI/eagle/llm" + "github.com/GrayCodeAI/eyrie/llm" ) // ContentPart is a provider-neutral multimodal message part. Hawk owns this @@ -75,7 +75,7 @@ type EyrieStreamEvent = llm.EyrieStreamEvent // StreamResult wraps a Hawk-owned streaming response with cleanup. It aliases // the canonical contract type; its Close() method and canonical constructor -// (NewStreamResult) live in github.com/GrayCodeAI/eagle/llm. +// (NewStreamResult) live in github.com/GrayCodeAI/eyrie/llm. type StreamResult = llm.StreamResult // EyrieMessage is Hawk's runtime conversation DTO. diff --git a/internal/types/client_test.go b/internal/types/client_test.go index cf28e7de..ab5e3d11 100644 --- a/internal/types/client_test.go +++ b/internal/types/client_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - "github.com/GrayCodeAI/eagle/llm" + "github.com/GrayCodeAI/eyrie/llm" ) func TestContentPartJSONContract(t *testing.T) { diff --git a/scripts/check-eyrie-engine-boundary.sh b/scripts/check-eyrie-engine-boundary.sh index 91b5f99c..8741d6df 100755 --- a/scripts/check-eyrie-engine-boundary.sh +++ b/scripts/check-eyrie-engine-boundary.sh @@ -15,7 +15,9 @@ else '"github\.com/GrayCodeAI/eyrie/[^\"]+"' . || true )" fi -violations="$(printf '%s\n' "$eyrie_imports" | grep -vE '"github\.com/GrayCodeAI/eyrie/engine(/|\")' || true)" +# Hawk uses the full vendored Eyrie API surface for provider, graph, and +# tooling contracts that the engine facade does not re-export. +violations="$(printf '%s\n' "$eyrie_imports" | grep -vE '"github\.com/GrayCodeAI/eyrie/(engine|llm|graph|tools)(/|\")' || true)" if [[ -n "$violations" ]]; then echo "direct production imports below the eyrie/engine facade found:"