archsight 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +33 -0
  3. data/chart/archsight/Chart.yaml +6 -0
  4. data/chart/archsight/README.md +160 -0
  5. data/chart/archsight/templates/NOTES.txt +22 -0
  6. data/chart/archsight/templates/_helpers.tpl +62 -0
  7. data/chart/archsight/templates/deployment.yaml +114 -0
  8. data/chart/archsight/templates/ingress.yaml +56 -0
  9. data/chart/archsight/templates/resources-configmap.yaml +10 -0
  10. data/chart/archsight/templates/resources-pvc.yaml +23 -0
  11. data/chart/archsight/templates/service.yaml +15 -0
  12. data/chart/archsight/templates/serviceaccount.yaml +12 -0
  13. data/chart/archsight/values.yaml +162 -0
  14. data/lib/archsight/analysis/executor.rb +0 -10
  15. data/lib/archsight/annotations/annotation.rb +85 -36
  16. data/lib/archsight/annotations/architecture_annotations.rb +1 -34
  17. data/lib/archsight/annotations/computed.rb +1 -1
  18. data/lib/archsight/annotations/generated_annotations.rb +6 -3
  19. data/lib/archsight/annotations/git_annotations.rb +8 -4
  20. data/lib/archsight/annotations/interface_annotations.rb +35 -0
  21. data/lib/archsight/cli.rb +3 -1
  22. data/lib/archsight/editor/content_hasher.rb +37 -0
  23. data/lib/archsight/editor/file_writer.rb +79 -0
  24. data/lib/archsight/editor.rb +237 -0
  25. data/lib/archsight/import/handlers/github.rb +14 -6
  26. data/lib/archsight/import/handlers/gitlab.rb +14 -6
  27. data/lib/archsight/import/handlers/repository.rb +3 -1
  28. data/lib/archsight/import/team_matcher.rb +111 -61
  29. data/lib/archsight/mcp/execute_analysis_tool.rb +100 -0
  30. data/lib/archsight/mcp.rb +1 -0
  31. data/lib/archsight/resources/analysis.rb +1 -17
  32. data/lib/archsight/resources/application_interface.rb +1 -5
  33. data/lib/archsight/resources/base.rb +14 -14
  34. data/lib/archsight/resources/business_actor.rb +1 -1
  35. data/lib/archsight/resources/technology_interface.rb +1 -1
  36. data/lib/archsight/resources/technology_service.rb +5 -0
  37. data/lib/archsight/version.rb +1 -1
  38. data/lib/archsight/web/application.rb +8 -0
  39. data/lib/archsight/web/doc/import.md +10 -2
  40. data/lib/archsight/web/editor/form_builder.rb +100 -0
  41. data/lib/archsight/web/editor/routes.rb +293 -0
  42. data/lib/archsight/web/public/css/editor.css +863 -0
  43. data/lib/archsight/web/public/css/instance.css +6 -0
  44. data/lib/archsight/web/public/js/editor.js +421 -0
  45. data/lib/archsight/web/public/js/lexical-editor.js +308 -0
  46. data/lib/archsight/web/views/partials/editor/_field.haml +80 -0
  47. data/lib/archsight/web/views/partials/editor/_form.haml +131 -0
  48. data/lib/archsight/web/views/partials/editor/_relations.haml +39 -0
  49. data/lib/archsight/web/views/partials/editor/_yaml_output.haml +33 -0
  50. data/lib/archsight/web/views/partials/instance/_analysis_detail.haml +4 -11
  51. data/lib/archsight/web/views/partials/instance/_detail.haml +4 -0
  52. data/lib/archsight/web/views/partials/layout/_content.haml +8 -2
  53. data/lib/archsight/web/views/partials/layout/_head.haml +2 -0
  54. metadata +26 -1
@@ -51,6 +51,7 @@ class Archsight::Resources::Analysis < Archsight::Resources::Base
51
51
  annotation "analysis/script",
52
52
  description: "Ruby script to execute in sandboxed environment",
53
53
  title: "Script",
54
+ format: :ruby,
54
55
  sidebar: false
55
56
 
56
57
  # Description
@@ -63,23 +64,6 @@ class Archsight::Resources::Analysis < Archsight::Resources::Base
63
64
  description: "Maximum execution time (e.g., '30s', '5m')",
64
65
  title: "Timeout"
65
66
 
66
- # Output configuration
67
- annotation "analysis/output",
68
- description: "Output mode for results",
69
- title: "Output",
70
- enum: %w[console file]
71
-
72
- annotation "analysis/outputPath",
73
- description: "File path for output (when output mode is 'file')",
74
- title: "Output Path",
75
- sidebar: false
76
-
77
- # Enabled flag
78
- annotation "analysis/enabled",
79
- description: "Whether this analysis is enabled",
80
- title: "Enabled",
81
- enum: %w[true false]
82
-
83
67
  # Pattern annotation for custom configuration
84
68
  annotation "analysis/config/*",
85
69
  description: "Custom configuration values for the analysis script",
@@ -2,7 +2,7 @@
2
2
 
3
3
  # ApplicationInterface between ApplicationComponent
4
4
  class Archsight::Resources::ApplicationInterface < Archsight::Resources::Base
5
- include_annotations :git, :architecture, :generated
5
+ include_annotations :git, :architecture, :interface, :generated
6
6
 
7
7
  description <<~MD
8
8
  Represents a point of access where application services are made available.
@@ -39,10 +39,6 @@ class Archsight::Resources::ApplicationInterface < Archsight::Resources::Base
39
39
  description: "API authentication method",
40
40
  title: "API Authentication Method",
41
41
  enum: ["none", "hard coded", "token", "oidc"]
42
- annotation "api/authenticationProvider",
43
- description: "API authentication provider",
44
- title: "API Authentication Provider",
45
- enum: %w[eiam cloud custom]
46
42
  annotation "api/authorization",
47
43
  description: "API authorization mechanism",
48
44
  title: "API Authorization",
@@ -15,7 +15,7 @@ module Archsight
15
15
  end
16
16
 
17
17
  def self.relation(verb, kind, klass_name)
18
- @relations ||= []
18
+ @relations ||= [] #: Array[[Symbol, Symbol, String]]
19
19
  @relations << [verb, kind, klass_name]
20
20
  end
21
21
 
@@ -25,10 +25,10 @@ module Archsight
25
25
 
26
26
  # Define an annotation using the Annotation class
27
27
  def self.annotation(key, description: nil, filter: nil, title: nil, format: nil, enum: nil, sidebar: true,
28
- type: nil, list: false)
29
- @annotations ||= []
28
+ type: nil, list: false, editor: true)
29
+ @annotations ||= [] #: Array[Archsight::Annotations::Annotation]
30
30
  options = { description: description, filter: filter, title: title, format: format, enum: enum,
31
- sidebar: sidebar, type: type, list: list }
31
+ sidebar: sidebar, type: type, list: list, editor: editor }
32
32
  @annotations << Archsight::Annotations::Annotation.new(key, options)
33
33
  end
34
34
 
@@ -51,15 +51,15 @@ module Archsight
51
51
  # @param list [Boolean] Whether values are lists (default false)
52
52
  # @yield Block that computes the annotation value, evaluated in Evaluator context
53
53
  def self.computed_annotation(key, description: nil, filter: nil, title: nil, format: nil, enum: nil,
54
- sidebar: false, type: nil, list: false, &)
54
+ sidebar: false, type: nil, list: false, editor: true, &)
55
55
  require_relative "../annotations/computed"
56
- @computed_annotations ||= []
56
+ @computed_annotations ||= [] #: Array[Archsight::Annotations::Computed]
57
57
  @computed_annotations << Archsight::Annotations::Computed.new(key, description: description, type: type, &)
58
58
 
59
59
  # Also register as a regular annotation so it passes validation and is recognized
60
- @annotations ||= []
60
+ @annotations ||= [] #: Array[Archsight::Annotations::Annotation]
61
61
  options = { description: description, filter: filter, title: title, format: format, enum: enum,
62
- sidebar: sidebar, type: type, list: list }
62
+ sidebar: sidebar, type: type, list: list, editor: editor }
63
63
  @annotations << Archsight::Annotations::Annotation.new(key, options)
64
64
  end
65
65
 
@@ -180,11 +180,11 @@ module Archsight
180
180
  # @param key [String] The annotation key
181
181
  # @param value [Object] The computed value
182
182
  def set_computed_annotation(key, value)
183
- @computed_values ||= {}
183
+ @computed_values ||= {} #: Hash[String, untyped]
184
184
  @computed_values[key] = value
185
185
  # Write to annotations hash for query compatibility
186
186
  @raw["metadata"] ||= {}
187
- @raw["metadata"]["annotations"] ||= {}
187
+ @raw["metadata"]["annotations"] = @raw["metadata"]["annotations"] || {} #: Hash[String, String]
188
188
  @raw["metadata"]["annotations"][key] = value
189
189
  end
190
190
 
@@ -241,13 +241,13 @@ module Archsight
241
241
  # Get references grouped by kind and verb for display (incoming)
242
242
  # Returns: { "Kind" => { "verb" => [instances...] } }
243
243
  def references_grouped
244
- grouped = {}
244
+ grouped = {} #: Hash[String, Hash[untyped, Array[Base]]]
245
245
  @references.each do |ref|
246
246
  inst = ref[:instance]
247
247
  verb = ref[:verb]
248
248
  kind = inst.klass
249
249
  grouped[kind] ||= {}
250
- grouped[kind][verb] ||= []
250
+ grouped[kind][verb] ||= [] #: Array[Base]
251
251
  grouped[kind][verb] << inst
252
252
  end
253
253
  # Sort by kind name, then by verb name
@@ -257,7 +257,7 @@ module Archsight
257
257
  # Get outgoing relations grouped by verb and kind for display
258
258
  # Returns: { "verb" => { "Kind" => [instances...] } }
259
259
  def relations_grouped
260
- grouped = {}
260
+ grouped = {} #: Hash[String, Hash[String, Array[Base]]]
261
261
  spec.each do |verb, kinds|
262
262
  next unless kinds.is_a?(Hash)
263
263
 
@@ -267,7 +267,7 @@ module Archsight
267
267
  instances.each do |inst|
268
268
  kind = inst.klass
269
269
  grouped[verb] ||= {}
270
- grouped[verb][kind] ||= []
270
+ grouped[verb][kind] ||= [] #: Array[Base]
271
271
  grouped[verb][kind] << inst
272
272
  end
273
273
  end
@@ -43,7 +43,7 @@ class Archsight::Resources::BusinessActor < Archsight::Resources::Base
43
43
  title: "Team Members",
44
44
  sidebar: false,
45
45
  filter: :list,
46
- format: :tag_list,
46
+ format: :multiline,
47
47
  type: Archsight::Annotations::EmailRecipient
48
48
 
49
49
  annotation "team/jira",
@@ -2,7 +2,7 @@
2
2
 
3
3
  # TechnologyInterface is the backing of an applicationInterface
4
4
  class Archsight::Resources::TechnologyInterface < Archsight::Resources::Base
5
- include_annotations :git, :architecture
5
+ include_annotations :git, :architecture, :interface
6
6
 
7
7
  description <<~MD
8
8
  Represents a point of access where technology services are made available.
@@ -30,6 +30,11 @@ class Archsight::Resources::TechnologyService < Archsight::Resources::Base
30
30
  icon "cloud"
31
31
  layer "technology"
32
32
 
33
+ annotation "architecture/applicationSets",
34
+ description: "Related ArgoCD ApplicationSets",
35
+ title: "ApplicationSets",
36
+ format: :markdown
37
+
33
38
  relation :suppliedBy, :technologyComponents, :TechnologySystemSoftware
34
39
  relation :servedBy, :businessActors, :BusinessActor
35
40
  end
@@ -4,5 +4,5 @@
4
4
  # Do not edit manually.
5
5
 
6
6
  module Archsight
7
- VERSION = "0.1.4"
7
+ VERSION = "0.1.5"
8
8
  end
@@ -16,6 +16,7 @@ require_relative "../resources"
16
16
  require_relative "../mcp"
17
17
  require_relative "api/routes"
18
18
  require_relative "api/docs"
19
+ require_relative "editor/routes"
19
20
 
20
21
  # Define the Web namespace before the class definition
21
22
  module Archsight::Web; end
@@ -59,6 +60,7 @@ class Archsight::Web::Application < Sinatra::Base
59
60
  set :haml, format: :html5
60
61
  set :server, :puma
61
62
  set :reload_enabled, true
63
+ set :inline_edit_enabled, false
62
64
  end
63
65
 
64
66
  # MCP Server setup
@@ -74,6 +76,7 @@ class Archsight::Web::Application < Sinatra::Base
74
76
  mcp_server.register_tool(Archsight::MCP::QueryTool)
75
77
  mcp_server.register_tool(Archsight::MCP::AnalyzeResourceTool)
76
78
  mcp_server.register_tool(Archsight::MCP::ResourceDocTool)
79
+ mcp_server.register_tool(Archsight::MCP::ExecuteAnalysisTool)
77
80
 
78
81
  use FastMcp::Transports::RackTransport, mcp_server,
79
82
  path_prefix: "/mcp",
@@ -85,6 +88,7 @@ class Archsight::Web::Application < Sinatra::Base
85
88
  # Register API modules
86
89
  register Archsight::Web::API::Routes
87
90
  register Archsight::Web::API::Docs
91
+ register Archsight::Web::Editor::Routes
88
92
 
89
93
  helpers do
90
94
  def db
@@ -95,6 +99,10 @@ class Archsight::Web::Application < Sinatra::Base
95
99
  settings.reload_enabled
96
100
  end
97
101
 
102
+ def inline_edit_enabled?
103
+ settings.inline_edit_enabled
104
+ end
105
+
98
106
  def production?
99
107
  settings.environment == :production
100
108
  end
@@ -104,8 +104,9 @@ Lists repositories from a GitLab instance and generates child Import resources.
104
104
  - `host` - GitLab host (required)
105
105
  - `exploreGroups` - If "true", explore all visible groups (default: false)
106
106
  - `repoOutputPath` - Output path for repository handler results
107
- - `fallbackTeam` - Default team when no contributor match found
108
- - `botTeam` - Team for bot-only repositories
107
+ - `fallbackTeam` - Default team when no contributor match found (propagated to child imports)
108
+ - `botTeam` - Team for bot-only repositories (propagated to child imports)
109
+ - `corporateAffixes` - Comma-separated corporate username affixes for team matching, e.g., "ionos,1and1" (propagated to child imports)
109
110
 
110
111
  **Environment:**
111
112
  - `GITLAB_TOKEN` - Personal access token (required)
@@ -119,6 +120,9 @@ Lists repositories from a GitHub organization and generates child Import resourc
119
120
  **Configuration:**
120
121
  - `org` - GitHub organization (required)
121
122
  - `repoOutputPath` - Output path for repository handler results
123
+ - `fallbackTeam` - Default team when no contributor match found (propagated to child imports)
124
+ - `botTeam` - Team for bot-only repositories (propagated to child imports)
125
+ - `corporateAffixes` - Comma-separated corporate username affixes for team matching, e.g., "ionos,1and1" (propagated to child imports)
122
126
 
123
127
  **Environment:**
124
128
  - `GITHUB_TOKEN` - GitHub Personal Access Token (required)
@@ -140,6 +144,7 @@ Analyzes a single git repository and generates a TechnologyArtifact resource.
140
144
  - `sccPath` - Path to scc binary (default: scc)
141
145
  - `fallbackTeam` - Default team when no contributor match found
142
146
  - `botTeam` - Team for bot-only repositories
147
+ - `corporateAffixes` - Comma-separated corporate username affixes for team matching (e.g., "ionos,1and1"). Enables matching git usernames like `jsmith-ionos` or `ionos-jsmith` to team member "John Smith". Without this, corporate username pattern matching is inactive.
143
148
 
144
149
  **Output:** Generates one TechnologyArtifact resource with:
145
150
  - Code analysis metrics (languages, LOC, estimated cost)
@@ -287,6 +292,7 @@ metadata:
287
292
  import/config/host: gitlab.company.com
288
293
  import/config/repoOutputPath: generated/repositories.yaml
289
294
  import/config/fallbackTeam: "Team:Platform"
295
+ import/config/corporateAffixes: "ionos,1and1"
290
296
  spec: {}
291
297
  ```
292
298
 
@@ -305,6 +311,8 @@ metadata:
305
311
  import/config/gitUrl: git@gitlab.company.com:company/my-service.git
306
312
  import/config/archived: "false"
307
313
  import/config/visibility: internal
314
+ import/config/fallbackTeam: "Team:Platform"
315
+ import/config/corporateAffixes: "ionos,1and1"
308
316
  import/outputPath: generated/repositories.yaml
309
317
  spec: {}
310
318
  ---
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../editor"
4
+
5
+ module Archsight
6
+ module Web
7
+ module Editor
8
+ # FormBuilder generates form field metadata from annotation definitions
9
+ class FormBuilder
10
+ # Field represents a single form field configuration
11
+ Field = Struct.new(:key, :title, :description, :input_type, :options, :step, :required, :code_language, keyword_init: true) do
12
+ def select?
13
+ input_type == :select
14
+ end
15
+
16
+ def textarea?
17
+ %i[textarea markdown].include?(input_type)
18
+ end
19
+
20
+ def markdown?
21
+ input_type == :markdown
22
+ end
23
+
24
+ def code?
25
+ input_type == :code
26
+ end
27
+
28
+ def number?
29
+ input_type == :number
30
+ end
31
+
32
+ def url?
33
+ input_type == :url
34
+ end
35
+
36
+ def text?
37
+ input_type == :text
38
+ end
39
+
40
+ def list?
41
+ input_type == :list
42
+ end
43
+ end
44
+
45
+ # Build form fields for a resource kind
46
+ # @param kind [String] Resource kind
47
+ # @return [Array<Field>]
48
+ def self.fields_for(kind)
49
+ annotations = Archsight::Editor.editable_annotations(kind)
50
+
51
+ annotations.map do |ann|
52
+ Field.new(
53
+ key: ann.key,
54
+ title: ann.title,
55
+ description: ann.description,
56
+ input_type: determine_input_type(ann),
57
+ options: ann.enum,
58
+ step: determine_step(ann),
59
+ required: false,
60
+ code_language: ann.code_language
61
+ )
62
+ end
63
+ end
64
+
65
+ # Determine input type based on annotation properties
66
+ # @param annotation [Archsight::Annotations::Annotation]
67
+ # @return [Symbol]
68
+ def self.determine_input_type(annotation)
69
+ return :select if annotation.enum
70
+
71
+ case annotation.type.to_s
72
+ when "Integer", "Float"
73
+ :number
74
+ when "URI"
75
+ :url
76
+ else
77
+ return :markdown if annotation.markdown?
78
+ return :textarea if annotation.multiline?
79
+ return :code if annotation.code?
80
+ return :list if annotation.list?
81
+
82
+ :text
83
+ end
84
+ end
85
+
86
+ # Determine step attribute for number inputs
87
+ # @param annotation [Archsight::Annotations::Annotation]
88
+ # @return [String, nil]
89
+ def self.determine_step(annotation)
90
+ case annotation.type.to_s
91
+ when "Integer"
92
+ "1"
93
+ when "Float"
94
+ "0.01"
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sinatra/base"
4
+ require "sinatra/extension"
5
+ require_relative "../../editor"
6
+ require_relative "form_builder"
7
+
8
+ module Archsight
9
+ module Web
10
+ module Editor
11
+ # Routes for the resource editor
12
+ module Routes
13
+ extend Sinatra::Extension
14
+
15
+ helpers do
16
+ # Get form fields for a resource kind
17
+ def editor_fields(kind)
18
+ FormBuilder.fields_for(kind)
19
+ end
20
+
21
+ # Get relation verbs for a resource kind
22
+ def relation_verbs(kind)
23
+ Archsight::Editor.relation_verbs(kind)
24
+ end
25
+
26
+ # Get available relations for a resource kind
27
+ def available_relations(kind)
28
+ Archsight::Editor.available_relations(kind)
29
+ end
30
+
31
+ # Extract annotations from form params
32
+ def extract_annotations(params)
33
+ annotations = params["annotations"] || {}
34
+ annotations.transform_values { |v| v.is_a?(String) ? v.strip : v }
35
+ end
36
+
37
+ # Extract relations from form params
38
+ # Relations come in as arrays: verb[], kind[], name[]
39
+ def parse_form_relations(params)
40
+ relations = []
41
+ relation_data = params["relations"] || []
42
+
43
+ relation_data.each do |rel|
44
+ next unless rel.is_a?(Hash)
45
+
46
+ verb = rel["verb"]&.strip
47
+ kind = rel["kind"]&.strip
48
+ name = rel["name"]&.strip
49
+
50
+ next if verb.nil? || verb.empty?
51
+ next if kind.nil? || kind.empty?
52
+ next if name.nil? || name.empty?
53
+
54
+ # Find existing relation group or create new one
55
+ existing = relations.find { |r| r[:verb] == verb && r[:kind] == kind }
56
+ if existing
57
+ existing[:names] << name unless existing[:names].include?(name)
58
+ else
59
+ relations << { verb: verb, kind: kind, names: [name] }
60
+ end
61
+ end
62
+
63
+ relations
64
+ end
65
+
66
+ # Validate content hash for optimistic locking
67
+ def validate_content_hash(instance, expected_hash)
68
+ Archsight::Editor::ContentHasher.validate(
69
+ path: instance.path_ref.path,
70
+ start_line: instance.path_ref.line_no,
71
+ expected_hash: expected_hash
72
+ )
73
+ end
74
+ end
75
+
76
+ # Create mode - empty form
77
+ get "/kinds/:kind/new" do
78
+ @kind = params["kind"]
79
+ @klass = Archsight::Resources[@kind]
80
+ halt 404, "Kind not found" unless @klass
81
+
82
+ @editor_mode = true
83
+ @mode = :create
84
+ @name = ""
85
+ @annotations = {}
86
+ @relations = []
87
+ @fields = editor_fields(@kind)
88
+ @errors = {}
89
+
90
+ haml :index
91
+ end
92
+
93
+ # Create mode - generate YAML
94
+ post "/kinds/:kind/generate" do
95
+ @kind = params["kind"]
96
+ @klass = Archsight::Resources[@kind]
97
+ halt 404, "Kind not found" unless @klass
98
+
99
+ @editor_mode = true
100
+ @mode = :create
101
+ @name = (params["name"] || "").strip
102
+ @annotations = extract_annotations(params)
103
+ @relations = parse_form_relations(params)
104
+ @fields = editor_fields(@kind)
105
+
106
+ # Validate
107
+ validation = Archsight::Editor.validate(@kind, name: @name, annotations: @annotations)
108
+
109
+ unless validation[:valid]
110
+ @errors = validation[:errors]
111
+ return haml :index
112
+ end
113
+
114
+ # Build and render YAML
115
+ resource = Archsight::Editor.build_resource(
116
+ kind: @kind,
117
+ name: @name,
118
+ annotations: @annotations,
119
+ relations: @relations
120
+ )
121
+
122
+ @generated_yaml = Archsight::Editor.to_yaml(resource)
123
+ @errors = {}
124
+
125
+ haml :index
126
+ end
127
+
128
+ # Edit mode - pre-filled form
129
+ get "/kinds/:kind/instances/:name/edit" do
130
+ @kind = params["kind"]
131
+ @instance_name = params["name"]
132
+ @klass = Archsight::Resources[@kind]
133
+ halt 404, "Kind not found" unless @klass
134
+
135
+ instance = db.instance_by_kind(@kind, @instance_name)
136
+ halt 404, "Instance not found" unless instance
137
+
138
+ @editor_mode = true
139
+ @mode = :edit
140
+ @name = instance.name
141
+ @annotations = instance.annotations.dup
142
+ @relations = extract_instance_relations(instance)
143
+ @fields = editor_fields(@kind)
144
+ @errors = {}
145
+
146
+ # Compute content hash for optimistic locking
147
+ original_content = Archsight::Editor::FileWriter.read_document(
148
+ path: instance.path_ref.path,
149
+ start_line: instance.path_ref.line_no
150
+ )
151
+ @content_hash = Archsight::Editor::ContentHasher.hash(original_content)
152
+
153
+ haml :index
154
+ end
155
+
156
+ # Edit mode - generate YAML
157
+ post "/kinds/:kind/instances/:name/generate" do
158
+ @kind = params["kind"]
159
+ @instance_name = params["name"]
160
+ @klass = Archsight::Resources[@kind]
161
+ halt 404, "Kind not found" unless @klass
162
+
163
+ # Get original instance for path_ref (for inline save)
164
+ original_instance = db.instance_by_kind(@kind, @instance_name)
165
+ @path_ref = original_instance&.path_ref
166
+
167
+ @editor_mode = true
168
+ @mode = :edit
169
+ @name = (params["name_field"] || @instance_name).strip
170
+ @annotations = extract_annotations(params)
171
+ @relations = parse_form_relations(params)
172
+ @fields = editor_fields(@kind)
173
+ @content_hash = params["content_hash"]
174
+
175
+ # Validate
176
+ validation = Archsight::Editor.validate(@kind, name: @name, annotations: @annotations)
177
+
178
+ unless validation[:valid]
179
+ @errors = validation[:errors]
180
+ return haml :index
181
+ end
182
+
183
+ # Build and render YAML
184
+ resource = Archsight::Editor.build_resource(
185
+ kind: @kind,
186
+ name: @name,
187
+ annotations: @annotations,
188
+ relations: @relations
189
+ )
190
+
191
+ @generated_yaml = Archsight::Editor.to_yaml(resource)
192
+ @errors = {}
193
+
194
+ haml :index
195
+ end
196
+
197
+ # Save YAML to source file (inline edit)
198
+ post "/api/v1/editor/kinds/:kind/instances/:name/save" do
199
+ content_type :json
200
+
201
+ # Check if inline edit is enabled
202
+ halt 403, JSON.generate({ success: false, error: "Inline edit is disabled. Start server with --inline-edit flag." }) unless settings.inline_edit_enabled
203
+
204
+ kind = params["kind"]
205
+ name = params["name"]
206
+
207
+ begin
208
+ body = JSON.parse(request.body.read)
209
+ yaml_content = body["yaml"]
210
+ expected_hash = body["content_hash"]
211
+ rescue JSON::ParserError
212
+ halt 400, JSON.generate({ success: false, error: "Invalid JSON" })
213
+ end
214
+
215
+ instance = db.instance_by_kind(kind, name)
216
+ halt 404, JSON.generate({ success: false, error: "Instance not found" }) unless instance
217
+
218
+ # Optimistic locking: verify content hasn't changed since edit started
219
+ if (conflict = validate_content_hash(instance, expected_hash))
220
+ status 409
221
+ return JSON.generate({ success: false }.merge(conflict))
222
+ end
223
+
224
+ begin
225
+ Archsight::Editor::FileWriter.replace_document(
226
+ path: instance.path_ref.path,
227
+ start_line: instance.path_ref.line_no,
228
+ new_yaml: yaml_content
229
+ )
230
+ db.reload!
231
+ JSON.generate({ success: true, message: "Saved to #{instance.path_ref}" })
232
+ rescue Archsight::Editor::FileWriter::WriteError => e
233
+ status 400
234
+ JSON.generate({ success: false, error: e.message })
235
+ end
236
+ end
237
+
238
+ # HTMX API - Get instance names for a kind (for relation dropdown)
239
+ get "/api/v1/editor/kinds/:kind/instances" do
240
+ kind = params["kind"]
241
+ klass = Archsight::Resources[kind]
242
+ halt 404, "Kind not found" unless klass
243
+
244
+ instances = db.instances_by_kind(kind).keys.sort
245
+
246
+ content_type :json
247
+ JSON.generate(instances)
248
+ end
249
+
250
+ # HTMX API - Get valid target kinds for a verb
251
+ get "/api/v1/editor/relation-kinds" do
252
+ kind = params["kind"]
253
+ verb = params["verb"]
254
+ halt 400, "Kind and verb required" unless kind && verb
255
+
256
+ target_kinds = Archsight::Editor.target_kinds_for_verb(kind, verb)
257
+
258
+ content_type :json
259
+ JSON.generate(target_kinds)
260
+ end
261
+
262
+ helpers do
263
+ # Extract relations from an existing instance into form format
264
+ # Returns relations with target class names (e.g., "BusinessActor")
265
+ # not relation names (e.g., "businessActors")
266
+ def extract_instance_relations(instance)
267
+ relations = []
268
+
269
+ instance.spec.each do |verb, relation_groups|
270
+ next unless relation_groups.is_a?(Hash)
271
+
272
+ relation_groups.each do |relation_name, targets|
273
+ next unless targets.is_a?(Array)
274
+
275
+ # Look up the target class name from the relation definition
276
+ target_class = Archsight::Editor.target_class_for_relation(instance.kind, verb, relation_name)
277
+ next unless target_class
278
+
279
+ targets.each do |target|
280
+ # Target could be an instance object or a string
281
+ target_name = target.respond_to?(:name) ? target.name : target.to_s
282
+ relations << { verb: verb, kind: target_class, name: target_name }
283
+ end
284
+ end
285
+ end
286
+
287
+ relations
288
+ end
289
+ end
290
+ end
291
+ end
292
+ end
293
+ end