woods 1.5.0 → 1.6.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +160 -0
- data/CONTRIBUTING.md +30 -1
- data/README.md +31 -0
- data/lib/tasks/woods.rake +52 -2
- data/lib/woods/atomic_file.rb +43 -0
- data/lib/woods/cache/cache_middleware.rb +18 -1
- data/lib/woods/console/embedded_executor.rb +20 -2
- data/lib/woods/console/eval_guard.rb +8 -2
- data/lib/woods/console/sql_noise_stripper.rb +113 -0
- data/lib/woods/console/sql_table_scanner.rb +22 -9
- data/lib/woods/console/sql_validator.rb +1 -2
- data/lib/woods/coordination/pipeline_lock.rb +112 -7
- data/lib/woods/dependency_graph.rb +38 -1
- data/lib/woods/embedding/text_preparer.rb +6 -1
- data/lib/woods/export/unit_facts.rb +80 -0
- data/lib/woods/extractor.rb +138 -26
- data/lib/woods/extractors/concern_extractor.rb +3 -5
- data/lib/woods/extractors/configuration_extractor.rb +3 -5
- data/lib/woods/extractors/controller_extractor.rb +1 -1
- data/lib/woods/extractors/database_view_extractor.rb +1 -1
- data/lib/woods/extractors/decorator_extractor.rb +3 -5
- data/lib/woods/extractors/event_extractor.rb +2 -2
- data/lib/woods/extractors/factory_extractor.rb +2 -4
- data/lib/woods/extractors/graphql_extractor.rb +1 -1
- data/lib/woods/extractors/i18n_extractor.rb +6 -4
- data/lib/woods/extractors/job_extractor.rb +4 -6
- data/lib/woods/extractors/lib_extractor.rb +1 -1
- data/lib/woods/extractors/mailer_extractor.rb +1 -1
- data/lib/woods/extractors/manager_extractor.rb +3 -5
- data/lib/woods/extractors/migration_extractor.rb +1 -1
- data/lib/woods/extractors/model_extractor.rb +26 -10
- data/lib/woods/extractors/phlex_extractor.rb +1 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -5
- data/lib/woods/extractors/poro_extractor.rb +1 -1
- data/lib/woods/extractors/pundit_extractor.rb +3 -5
- data/lib/woods/extractors/rake_task_extractor.rb +2 -4
- data/lib/woods/extractors/serializer_extractor.rb +4 -6
- data/lib/woods/extractors/service_extractor.rb +3 -5
- data/lib/woods/extractors/shared_dependency_scanner.rb +88 -16
- data/lib/woods/extractors/shared_utility_methods.rb +14 -0
- data/lib/woods/extractors/state_machine_extractor.rb +2 -4
- data/lib/woods/extractors/validator_extractor.rb +3 -5
- data/lib/woods/extractors/view_component_extractor.rb +1 -1
- data/lib/woods/filename_utils.rb +31 -2
- data/lib/woods/flow_assembler.rb +45 -28
- data/lib/woods/flow_precomputer.rb +2 -1
- data/lib/woods/index_artifact.rb +5 -1
- data/lib/woods/mcp/server.rb +68 -15
- data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
- data/lib/woods/model_name_cache.rb +6 -1
- data/lib/woods/notion/client.rb +4 -1
- data/lib/woods/notion/exporter.rb +7 -1
- data/lib/woods/obsidian/errors.rb +11 -0
- data/lib/woods/obsidian/name_mapper.rb +132 -0
- data/lib/woods/obsidian/note_builder.rb +260 -0
- data/lib/woods/obsidian/vault_assets.rb +104 -0
- data/lib/woods/obsidian/vault_exporter.rb +412 -0
- data/lib/woods/operator/error_escalator.rb +15 -4
- data/lib/woods/resilience/circuit_breaker.rb +98 -24
- data/lib/woods/resolved_config.rb +4 -1
- data/lib/woods/retrieval/ranker.rb +45 -7
- data/lib/woods/retry_after.rb +36 -0
- data/lib/woods/unblocked/client.rb +4 -1
- data/lib/woods/unblocked/document_builder.rb +21 -27
- data/lib/woods/update_check.rb +190 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods.rb +24 -0
- metadata +12 -2
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
require 'pathname'
|
|
7
|
+
require 'woods'
|
|
8
|
+
require 'woods/atomic_file'
|
|
9
|
+
require_relative 'errors'
|
|
10
|
+
require_relative 'name_mapper'
|
|
11
|
+
require_relative 'note_builder'
|
|
12
|
+
require_relative 'vault_assets'
|
|
13
|
+
|
|
14
|
+
module Woods
|
|
15
|
+
module Obsidian
|
|
16
|
+
# Orchestrates exporting Woods extraction output to a self-contained
|
|
17
|
+
# Obsidian vault.
|
|
18
|
+
#
|
|
19
|
+
# Reads the flat extraction directory via IndexReader (using raw_graph_data,
|
|
20
|
+
# so string keys + persisted pagerank), then writes:
|
|
21
|
+
# - one Markdown note per exported unit (NoteBuilder),
|
|
22
|
+
# - per-type `_index.md` MOCs + a root `_Overview.md`,
|
|
23
|
+
# - a `_woods/` machine sidecar (id<->path manifest + verbatim graph copies),
|
|
24
|
+
# - `.obsidian/` config + `Units.base` (only into a woods-owned vault),
|
|
25
|
+
# - a `.woods-vault` ownership sentinel.
|
|
26
|
+
# Finally it sweeps stale woods-managed notes — guarded so it can never
|
|
27
|
+
# delete a user's own notes or run against a foreign vault.
|
|
28
|
+
#
|
|
29
|
+
# All dependency edges come from one in-memory edge_map (the graph's
|
|
30
|
+
# edges/reverse); per-unit JSON is read only for note bodies.
|
|
31
|
+
#
|
|
32
|
+
# @example
|
|
33
|
+
# Woods::Obsidian::VaultExporter.new(
|
|
34
|
+
# index_dir: "tmp/woods", vault_path: "tmp/woods/obsidian_vault"
|
|
35
|
+
# ).export_all
|
|
36
|
+
# # => { exported: 412, indexes: 19, swept: 3, skipped: 0, errors: [] }
|
|
37
|
+
class VaultExporter
|
|
38
|
+
MAX_ERRORS = 100
|
|
39
|
+
PURGE_GUARD_FRACTION = 0.30
|
|
40
|
+
PURGE_GUARD_MIN_DOCS = 10
|
|
41
|
+
SENTINEL = '.woods-vault'
|
|
42
|
+
SIDECAR_DIR = '_woods'
|
|
43
|
+
FRAMEWORK_TYPES = %w[rails_source gem_source].freeze
|
|
44
|
+
# Upper bound on frontmatter lines scanned when identifying a managed note
|
|
45
|
+
# during the sweep — our frontmatter is ~12 lines; this just bounds work on
|
|
46
|
+
# a malformed file that opens with "---" but never closes the fence.
|
|
47
|
+
MAX_FRONTMATTER_LINES = 200
|
|
48
|
+
|
|
49
|
+
# @param index_dir [String] extraction output directory to read
|
|
50
|
+
# @param vault_path [String] directory to write the vault into
|
|
51
|
+
# @param config [Configuration] Woods configuration (default: global)
|
|
52
|
+
# @param reader [Object, nil] IndexReader (auto-created if nil)
|
|
53
|
+
# @param include_source [Boolean] embed credential-scrubbed source in notes
|
|
54
|
+
# @param include_framework [Boolean] include rails_source units
|
|
55
|
+
# @param force_purge [Boolean] bypass the mass-deletion sweep guard
|
|
56
|
+
# @param output [IO] progress stream
|
|
57
|
+
def initialize(index_dir:, vault_path:, config: Woods.configuration, reader: nil,
|
|
58
|
+
include_source: false, include_framework: false, force_purge: false, output: $stdout)
|
|
59
|
+
@config = config
|
|
60
|
+
@vault = Pathname.new(vault_path.to_s)
|
|
61
|
+
@reader = reader || build_reader(index_dir)
|
|
62
|
+
@include_source = include_source
|
|
63
|
+
@include_framework = include_framework
|
|
64
|
+
@force_purge = force_purge
|
|
65
|
+
@output = output
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Generate the full vault. Idempotent: re-running against an unchanged
|
|
69
|
+
# extraction produces byte-identical output.
|
|
70
|
+
#
|
|
71
|
+
# @return [Hash] { exported:, indexes:, swept:, skipped:, errors: }
|
|
72
|
+
def export_all
|
|
73
|
+
graph = @reader.raw_graph_data || {}
|
|
74
|
+
@nodes = graph['nodes'] || {}
|
|
75
|
+
@pagerank = graph['pagerank'] || {}
|
|
76
|
+
analysis = safe_graph_analysis
|
|
77
|
+
owned = vault_owned_at_start?
|
|
78
|
+
|
|
79
|
+
emitted, skipped = partition_emitted
|
|
80
|
+
mapper = build_mapper(emitted)
|
|
81
|
+
edge_map = build_edge_map(emitted.to_set, graph)
|
|
82
|
+
builder = build_note_builder(mapper, analysis)
|
|
83
|
+
|
|
84
|
+
written = Set.new
|
|
85
|
+
errors = []
|
|
86
|
+
exported = write_notes(emitted, mapper, edge_map, builder, written, errors)
|
|
87
|
+
indexes = write_indexes(emitted, mapper, written)
|
|
88
|
+
write_sidecar(emitted, mapper, graph, analysis, written)
|
|
89
|
+
write_owned_assets(emitted) if owned
|
|
90
|
+
|
|
91
|
+
swept = owned && errors.empty? ? sweep(written) : 0
|
|
92
|
+
warn_foreign unless owned
|
|
93
|
+
{ exported: exported, indexes: indexes, swept: swept, skipped: skipped, errors: cap_errors(errors) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# ── Selection / mapping ──────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
# The emitted set = graph nodes of an exportable type that have a loadable
|
|
101
|
+
# unit file. Confirming the file loads (not just that the index lists it)
|
|
102
|
+
# guarantees every wikilink target is backed by a real note — a node listed
|
|
103
|
+
# in the index but with no readable unit is dropped (counted as skipped).
|
|
104
|
+
#
|
|
105
|
+
# @return [Array(Array<String>, Integer)] [sorted emitted ids, skipped count]
|
|
106
|
+
def partition_emitted
|
|
107
|
+
known = known_ids
|
|
108
|
+
candidates = @nodes.keys.select do |id|
|
|
109
|
+
type = @nodes[id]['type']
|
|
110
|
+
next false if !@include_framework && FRAMEWORK_TYPES.include?(type)
|
|
111
|
+
|
|
112
|
+
known.include?(id)
|
|
113
|
+
end.sort
|
|
114
|
+
|
|
115
|
+
# Load each candidate once and keep it: confirms the unit is readable
|
|
116
|
+
# (a corrupt/missing file is skipped, not fatal) and lets Pass 2 render
|
|
117
|
+
# from this cache instead of re-reading every file past the reader's
|
|
118
|
+
# small LRU — avoiding ~2x disk reads + JSON parses on a large vault.
|
|
119
|
+
# Holding the units for one batch export is an acceptable memory trade.
|
|
120
|
+
@units = {}
|
|
121
|
+
skipped = 0
|
|
122
|
+
candidates.each do |id|
|
|
123
|
+
unit = load_unit(id)
|
|
124
|
+
unit ? (@units[id] = unit) : (skipped += 1)
|
|
125
|
+
end
|
|
126
|
+
[@units.keys, skipped]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Load a unit, treating an unreadable/corrupt file as absent rather than
|
|
130
|
+
# letting one bad JSON parse abort the whole export.
|
|
131
|
+
def load_unit(id)
|
|
132
|
+
@reader.find_unit(id)
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
log " skipped #{id}: #{e.class}: #{e.message}"
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def known_ids
|
|
139
|
+
@known_ids ||= @reader.list_units.to_set { |entry| entry['identifier'] }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def build_mapper(emitted)
|
|
143
|
+
NameMapper.new(emitted.to_h { |id| [id, dir_for(@nodes[id]['type'])] })
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def dir_for(type)
|
|
147
|
+
require 'woods/mcp/index_reader'
|
|
148
|
+
Woods::MCP::IndexReader::TYPE_TO_DIR[type] || "#{type}s"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def build_note_builder(mapper, analysis)
|
|
152
|
+
NoteBuilder.new(name_mapper: mapper, nodes: @nodes, pagerank: @pagerank,
|
|
153
|
+
analysis: analysis, include_source: @include_source, scanner: scanner)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The single edge source: per-id forward (depends_on) and reverse (used_by)
|
|
157
|
+
# edges from the graph, filtered to the emitted set, self-edges dropped.
|
|
158
|
+
def build_edge_map(emitted, graph)
|
|
159
|
+
edges = graph['edges'] || {}
|
|
160
|
+
reverse = graph['reverse'] || {}
|
|
161
|
+
emitted.each_with_object({}) do |id, map|
|
|
162
|
+
deps = Array(edges[id]).filter_map { |edge| forward_edge(edge, id, emitted) }
|
|
163
|
+
.uniq { |edge| [edge[:target], edge[:via]] }
|
|
164
|
+
used = Array(reverse[id]).select { |sid| sid != id && emitted.include?(sid) }.uniq
|
|
165
|
+
map[id] = { depends_on: deps, used_by: used }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def forward_edge(edge, source_id, emitted)
|
|
170
|
+
edge = { 'target' => edge } unless edge.is_a?(Hash)
|
|
171
|
+
target = edge['target']
|
|
172
|
+
return nil if target == source_id || !emitted.include?(target)
|
|
173
|
+
|
|
174
|
+
{ target: target, via: edge['via'] }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# ── Pass 2: notes ────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
def write_notes(emitted, mapper, edge_map, builder, written, errors)
|
|
180
|
+
exported = 0
|
|
181
|
+
emitted.each do |id|
|
|
182
|
+
unit = @units[id]
|
|
183
|
+
next unless unit # defensive — partition_emitted already cached loadable units
|
|
184
|
+
|
|
185
|
+
edges = edge_map[id] || { depends_on: [], used_by: [] }
|
|
186
|
+
note = builder.build(id: id, unit: unit, depends_on: edges[:depends_on], used_by: edges[:used_by])
|
|
187
|
+
next if note.nil?
|
|
188
|
+
|
|
189
|
+
rel = mapper.path_for(id)
|
|
190
|
+
AtomicFile.write(@vault.join(rel), note)
|
|
191
|
+
written << rel
|
|
192
|
+
exported += 1
|
|
193
|
+
rescue StandardError => e
|
|
194
|
+
errors << "#{id}: #{e.class}: #{e.message}"
|
|
195
|
+
end
|
|
196
|
+
exported
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# ── Pass 3a: MOC + overview ──────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
def write_indexes(emitted, mapper, written)
|
|
202
|
+
by_dir = emitted.group_by { |id| File.dirname(mapper.path_for(id)) }
|
|
203
|
+
by_dir.sort.each do |dir, ids|
|
|
204
|
+
write_managed(written, "#{dir}/_index.md", moc_note(dir, ids, mapper))
|
|
205
|
+
end
|
|
206
|
+
write_managed(written, '_Overview.md', overview_note(by_dir))
|
|
207
|
+
by_dir.size + 1
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def moc_note(dir, ids, mapper)
|
|
211
|
+
lines = [managed_frontmatter, "# #{dir} (#{ids.size})", '']
|
|
212
|
+
ids.sort.each { |id| lines << "- #{mapper.wikilink(id)}" }
|
|
213
|
+
"#{lines.join("\n")}\n"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def overview_note(by_dir)
|
|
217
|
+
total = by_dir.values.sum(&:size)
|
|
218
|
+
lines = [managed_frontmatter, '# Woods Vault Overview', '',
|
|
219
|
+
"#{total} units across #{by_dir.size} types.", '', '## Units by type']
|
|
220
|
+
by_dir.sort.each { |dir, ids| lines << "- [[#{dir}/_index|#{dir}]] — #{ids.size}" }
|
|
221
|
+
lines.push('', '## Machine-readable',
|
|
222
|
+
'- `_woods/manifest.json` — id ↔ path map + per-note metadata',
|
|
223
|
+
'- `_woods/dependency_graph.json` / `_woods/graph_analysis.json` — full topology')
|
|
224
|
+
"#{lines.join("\n")}\n"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def managed_frontmatter
|
|
228
|
+
"#{{ 'woods_managed' => true, 'tags' => ['woods/moc'] }.to_yaml}---"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# ── Pass 3b: machine sidecar ─────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
def write_sidecar(emitted, mapper, graph, analysis, written)
|
|
234
|
+
notes = emitted.to_h do |id|
|
|
235
|
+
[id, { 'path' => mapper.path_for(id), 'type' => @nodes[id]['type'],
|
|
236
|
+
'pagerank' => @pagerank[id]&.round(6) }.compact]
|
|
237
|
+
end
|
|
238
|
+
manifest = { 'schema_version' => 1, 'notes' => sort_hash(notes), 'paths' => sort_hash(mapper.paths_to_ids) }
|
|
239
|
+
write_json(written, "#{SIDECAR_DIR}/manifest.json", manifest)
|
|
240
|
+
write_json(written, "#{SIDECAR_DIR}/dependency_graph.json", graph)
|
|
241
|
+
write_json(written, "#{SIDECAR_DIR}/graph_analysis.json", analysis) if analysis
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# ── Pass 3c: owned-only assets (.obsidian config, Units.base, sentinel) ──
|
|
245
|
+
|
|
246
|
+
def write_owned_assets(emitted)
|
|
247
|
+
if can_write_obsidian_config?
|
|
248
|
+
AtomicFile.write(@vault.join('.obsidian', 'app.json'), VaultAssets.app_json)
|
|
249
|
+
AtomicFile.write(@vault.join('.obsidian', 'types.json'), VaultAssets.types_json)
|
|
250
|
+
AtomicFile.write(@vault.join('.obsidian', 'graph.json'), VaultAssets.graph_json(present_types(emitted)))
|
|
251
|
+
AtomicFile.write(@vault.join('Units.base'), VaultAssets.units_base)
|
|
252
|
+
end
|
|
253
|
+
AtomicFile.write(@vault.join(SENTINEL),
|
|
254
|
+
"woods-managed vault — regenerated by `rake woods:obsidian`. Safe to delete.\n")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def can_write_obsidian_config?
|
|
258
|
+
obsidian = @vault.join('.obsidian')
|
|
259
|
+
return true unless obsidian.exist?
|
|
260
|
+
return true if @vault.join(SENTINEL).exist?
|
|
261
|
+
|
|
262
|
+
log ' existing .obsidian/ left untouched (not a woods-owned vault)'
|
|
263
|
+
false
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def present_types(emitted)
|
|
267
|
+
emitted.map { |id| @nodes[id]['type'] }.uniq
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# ── Pass 4: guarded sweep ────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
def sweep(written)
|
|
273
|
+
managed = managed_notes
|
|
274
|
+
stale = managed.reject { |rel| written.include?(rel) }
|
|
275
|
+
return 0 if stale.empty?
|
|
276
|
+
return 0 if guard_blocks_purge?(stale, managed)
|
|
277
|
+
|
|
278
|
+
stale.sort.count do |rel|
|
|
279
|
+
path = @vault.join(rel)
|
|
280
|
+
next false unless safe_inside_vault?(path) && path.file?
|
|
281
|
+
|
|
282
|
+
File.delete(path)
|
|
283
|
+
true
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def managed_notes
|
|
288
|
+
Dir.glob(@vault.join('**', '*.md')).filter_map do |abs|
|
|
289
|
+
next unless managed_marker?(abs)
|
|
290
|
+
|
|
291
|
+
Pathname.new(abs).relative_path_from(@vault).to_s
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Only our notes carry this frontmatter flag. Reads just the frontmatter
|
|
296
|
+
# head (notes can be large when include_source is on) rather than the whole
|
|
297
|
+
# file, and bails on anything that isn't a leading "---" fence.
|
|
298
|
+
def managed_marker?(abs)
|
|
299
|
+
fm = frontmatter_head(abs)
|
|
300
|
+
fm.is_a?(Hash) && fm['woods_managed'] == true
|
|
301
|
+
rescue StandardError
|
|
302
|
+
false
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Recognizes only a canonical fence: the file must open with exactly "---\n"
|
|
306
|
+
# and close with exactly "---\n" (what woods emits). Anything looser — a
|
|
307
|
+
# fence with trailing characters, CRLF, or no close within the cap — reads
|
|
308
|
+
# as unmanaged. Deliberately conservative: the sweep deletes managed notes,
|
|
309
|
+
# so when in doubt we must not claim a file as ours.
|
|
310
|
+
def frontmatter_head(abs)
|
|
311
|
+
File.open(abs, 'r:UTF-8') do |file|
|
|
312
|
+
return nil unless file.gets == "---\n"
|
|
313
|
+
|
|
314
|
+
lines = []
|
|
315
|
+
MAX_FRONTMATTER_LINES.times do
|
|
316
|
+
line = file.gets
|
|
317
|
+
break if line.nil?
|
|
318
|
+
return YAML.safe_load(lines.join) if line == "---\n"
|
|
319
|
+
|
|
320
|
+
lines << line
|
|
321
|
+
end
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def guard_blocks_purge?(stale, managed)
|
|
327
|
+
return false if @force_purge
|
|
328
|
+
|
|
329
|
+
total = managed.size
|
|
330
|
+
return false if total < PURGE_GUARD_MIN_DOCS
|
|
331
|
+
|
|
332
|
+
fraction = stale.size.to_f / total
|
|
333
|
+
return false unless fraction > PURGE_GUARD_FRACTION
|
|
334
|
+
|
|
335
|
+
log " WARNING: refusing to delete #{stale.size}/#{total} managed notes " \
|
|
336
|
+
"(#{(fraction * 100).round}% > #{(PURGE_GUARD_FRACTION * 100).to_i}%). " \
|
|
337
|
+
'Set WOODS_OBSIDIAN_FORCE_PURGE=1 to override.'
|
|
338
|
+
true
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def safe_inside_vault?(path)
|
|
342
|
+
root = @vault.realpath.to_s
|
|
343
|
+
target = path.exist? ? path.realpath.to_s : path.expand_path.to_s
|
|
344
|
+
target == root || target.start_with?(root + File::SEPARATOR)
|
|
345
|
+
rescue StandardError
|
|
346
|
+
false
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# ── Ownership ────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
def vault_owned_at_start?
|
|
352
|
+
return true unless @vault.exist?
|
|
353
|
+
return true if @vault.join(SENTINEL).exist?
|
|
354
|
+
|
|
355
|
+
(@vault.children.map { |c| c.basename.to_s } - [SENTINEL]).empty?
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def warn_foreign
|
|
359
|
+
log " WARNING: #{@vault} is not a woods-owned vault (no #{SENTINEL} sentinel and it " \
|
|
360
|
+
'already has content). Wrote notes additively; skipped config and the stale-note sweep.'
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# ── Helpers ──────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
def write_managed(written, rel, content)
|
|
366
|
+
AtomicFile.write(@vault.join(rel), content)
|
|
367
|
+
written << rel
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def write_json(written, rel, data)
|
|
371
|
+
AtomicFile.write(@vault.join(rel), "#{JSON.pretty_generate(data)}\n")
|
|
372
|
+
written << rel
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def sort_hash(hash)
|
|
376
|
+
hash.sort.to_h
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def scanner
|
|
380
|
+
return nil unless @include_source
|
|
381
|
+
|
|
382
|
+
@scanner ||= begin
|
|
383
|
+
require 'woods/console/credential_scanner'
|
|
384
|
+
Woods::Console::CredentialScanner.new
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def safe_graph_analysis
|
|
389
|
+
@reader.graph_analysis
|
|
390
|
+
rescue StandardError
|
|
391
|
+
nil
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def build_reader(index_dir)
|
|
395
|
+
require 'woods/mcp/index_reader'
|
|
396
|
+
Woods::MCP::IndexReader.new(index_dir)
|
|
397
|
+
rescue ArgumentError => e
|
|
398
|
+
raise ExportError, "#{e.message} — run `bundle exec rake woods:extract` first"
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def cap_errors(errors)
|
|
402
|
+
return errors if errors.size <= MAX_ERRORS
|
|
403
|
+
|
|
404
|
+
errors.first(MAX_ERRORS) + ["... and #{errors.size - MAX_ERRORS} more errors"]
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def log(message)
|
|
408
|
+
@output&.puts(message)
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
@@ -37,22 +37,33 @@ module Woods
|
|
|
37
37
|
|
|
38
38
|
# Classify an error by severity and suggest remediation.
|
|
39
39
|
#
|
|
40
|
+
# Most patterns key on the error's class name, so a caller that only
|
|
41
|
+
# has the class name as a string (e.g. the pipeline_diagnose MCP tool,
|
|
42
|
+
# which receives error_class over the wire and can't reconstruct the
|
|
43
|
+
# real exception class) must pass +class_name:+ — otherwise the
|
|
44
|
+
# synthesized StandardError makes every class-keyed pattern miss and
|
|
45
|
+
# the error falls through to :unknown.
|
|
46
|
+
#
|
|
40
47
|
# @param error [StandardError] The error to classify
|
|
48
|
+
# @param class_name [String, nil] Override for the class name used in
|
|
49
|
+
# pattern matching and reported as :error_class (defaults to
|
|
50
|
+
# error.class.name)
|
|
41
51
|
# @return [Hash] :severity (:transient or :permanent), :category, :remediation, :error_class, :message
|
|
42
|
-
def classify(error)
|
|
43
|
-
|
|
52
|
+
def classify(error, class_name: nil)
|
|
53
|
+
resolved_class = class_name || error.class.name
|
|
54
|
+
error_string = "#{resolved_class} #{error.message}"
|
|
44
55
|
|
|
45
56
|
match = find_match(error_string, TRANSIENT_PATTERNS, :transient) ||
|
|
46
57
|
find_match(error_string, PERMANENT_PATTERNS, :permanent)
|
|
47
58
|
|
|
48
59
|
if match
|
|
49
|
-
match.merge(error_class:
|
|
60
|
+
match.merge(error_class: resolved_class, message: error.message)
|
|
50
61
|
else
|
|
51
62
|
{
|
|
52
63
|
severity: :unknown,
|
|
53
64
|
category: 'unclassified',
|
|
54
65
|
remediation: 'Investigate error details and check logs',
|
|
55
|
-
error_class:
|
|
66
|
+
error_class: resolved_class,
|
|
56
67
|
message: error.message
|
|
57
68
|
}
|
|
58
69
|
end
|
|
@@ -36,50 +36,93 @@ module Woods
|
|
|
36
36
|
|
|
37
37
|
# @param threshold [Integer] Number of consecutive failures before opening the circuit
|
|
38
38
|
# @param reset_timeout [Numeric] Seconds to wait before transitioning from open to half_open
|
|
39
|
-
|
|
39
|
+
# @param success_threshold [Integer] Number of consecutive successful probes required to
|
|
40
|
+
# close the circuit from half_open (default 1). Higher values reduce flapping when a
|
|
41
|
+
# recovering service is still intermittently failing.
|
|
42
|
+
def initialize(threshold: 5, reset_timeout: 60, success_threshold: 1)
|
|
40
43
|
@threshold = threshold
|
|
41
44
|
@reset_timeout = reset_timeout
|
|
45
|
+
@success_threshold = success_threshold
|
|
42
46
|
@state = :closed
|
|
43
47
|
@failure_count = 0
|
|
48
|
+
@success_count = 0
|
|
44
49
|
@last_failure_time = nil
|
|
50
|
+
@half_open_probe_in_flight = false
|
|
45
51
|
@mutex = Mutex.new
|
|
46
52
|
end
|
|
47
53
|
|
|
48
54
|
# Execute a block through the circuit breaker.
|
|
49
55
|
#
|
|
56
|
+
# In half_open, only a SINGLE probe is admitted at a time: concurrent
|
|
57
|
+
# calls are rejected with {CircuitOpenError} until the probe resolves.
|
|
58
|
+
# This prevents a thundering herd of probes against a still-recovering
|
|
59
|
+
# service, and — because probes can't overlap — removes the race where a
|
|
60
|
+
# slow probe's success would wipe failures recorded by a concurrent one.
|
|
61
|
+
#
|
|
50
62
|
# @yield The block to execute
|
|
51
63
|
# @return [Object] The return value of the block
|
|
52
|
-
# @raise [CircuitOpenError] if the circuit is open
|
|
64
|
+
# @raise [CircuitOpenError] if the circuit is open, or half_open with a probe already in flight
|
|
53
65
|
# @raise [StandardError] re-raises any error from the block
|
|
54
66
|
def call(&block)
|
|
55
|
-
|
|
67
|
+
probing = admit_call!
|
|
68
|
+
|
|
69
|
+
begin
|
|
70
|
+
result = block.call
|
|
71
|
+
rescue CircuitOpenError
|
|
72
|
+
# A nested breaker tripped — release our probe slot but don't count
|
|
73
|
+
# it as this breaker's own failure.
|
|
74
|
+
@mutex.synchronize { @half_open_probe_in_flight = false if probing }
|
|
75
|
+
raise
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
@half_open_probe_in_flight = false if probing
|
|
79
|
+
record_failure(probing)
|
|
80
|
+
end
|
|
81
|
+
raise e
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
@mutex.synchronize do
|
|
85
|
+
@half_open_probe_in_flight = false if probing
|
|
86
|
+
record_success(probing)
|
|
87
|
+
end
|
|
88
|
+
result
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Decide whether this call may proceed, transitioning open→half_open when
|
|
94
|
+
# the reset timeout has elapsed. Runs entirely under the mutex.
|
|
95
|
+
#
|
|
96
|
+
# @return [Boolean] true when this call is the half_open probe (so the
|
|
97
|
+
# caller knows to release the probe slot when it finishes)
|
|
98
|
+
# @raise [CircuitOpenError] when the circuit is open, or half_open with a
|
|
99
|
+
# probe already in flight
|
|
100
|
+
def admit_call!
|
|
56
101
|
@mutex.synchronize do
|
|
57
102
|
case @state
|
|
58
103
|
when :open
|
|
59
|
-
|
|
60
|
-
raise CircuitOpenError, "Circuit breaker is open (#{@failure_count} failures)"
|
|
61
|
-
end
|
|
104
|
+
raise CircuitOpenError, "Circuit breaker is open (#{@failure_count} failures)" unless reset_timeout_elapsed?
|
|
62
105
|
|
|
106
|
+
# First caller after the timeout becomes the single half_open probe.
|
|
63
107
|
@state = :half_open
|
|
108
|
+
@half_open_probe_in_flight = true
|
|
109
|
+
true
|
|
110
|
+
when :half_open
|
|
111
|
+
raise CircuitOpenError, 'Circuit breaker is half-open (probe in flight)' if @half_open_probe_in_flight
|
|
112
|
+
|
|
113
|
+
@half_open_probe_in_flight = true
|
|
114
|
+
true
|
|
115
|
+
else
|
|
116
|
+
false
|
|
64
117
|
end
|
|
65
118
|
end
|
|
66
|
-
|
|
67
|
-
# Phase 2: Execute outside mutex
|
|
68
|
-
result = block.call
|
|
69
|
-
|
|
70
|
-
# Phase 3: Record success under mutex
|
|
71
|
-
@mutex.synchronize { reset! }
|
|
72
|
-
|
|
73
|
-
result
|
|
74
|
-
rescue CircuitOpenError
|
|
75
|
-
raise
|
|
76
|
-
rescue StandardError => e
|
|
77
|
-
# Phase 4: Record failure under mutex
|
|
78
|
-
@mutex.synchronize { record_failure }
|
|
79
|
-
raise e
|
|
80
119
|
end
|
|
81
120
|
|
|
82
|
-
|
|
121
|
+
# @return [Boolean] whether enough time has passed since the last failure
|
|
122
|
+
# to attempt a recovery probe
|
|
123
|
+
def reset_timeout_elapsed?
|
|
124
|
+
@last_failure_time && (monotonic_now - @last_failure_time >= @reset_timeout)
|
|
125
|
+
end
|
|
83
126
|
|
|
84
127
|
# Monotonic clock reading — immune to NTP slews and DST adjustments.
|
|
85
128
|
#
|
|
@@ -88,18 +131,49 @@ module Woods
|
|
|
88
131
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
89
132
|
end
|
|
90
133
|
|
|
91
|
-
# Record a failure and potentially open the circuit.
|
|
92
|
-
|
|
134
|
+
# Record a failure and potentially open the circuit. A failure while
|
|
135
|
+
# half_open reopens immediately, regardless of the failure threshold —
|
|
136
|
+
# but only when it is the genuine in-flight probe. A stale call
|
|
137
|
+
# admitted while the circuit was still closed, completing after the
|
|
138
|
+
# transition to half_open, is not the probe and must not decide
|
|
139
|
+
# recovery (its outcome reflects the pre-outage attempt, not the
|
|
140
|
+
# current one).
|
|
141
|
+
#
|
|
142
|
+
# @param probing [Boolean] whether this call was admitted as the probe
|
|
143
|
+
def record_failure(probing)
|
|
144
|
+
return if @state == :half_open && !probing
|
|
145
|
+
|
|
93
146
|
@failure_count += 1
|
|
94
147
|
@last_failure_time = monotonic_now
|
|
95
|
-
@
|
|
148
|
+
@success_count = 0
|
|
149
|
+
@state = :open if @state == :half_open || @failure_count >= @threshold
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Record a success. In half_open, only the in-flight probe advances
|
|
153
|
+
# recovery — a stale non-probe success (a call admitted while closed
|
|
154
|
+
# that resolves after the circuit went half_open) must not count toward
|
|
155
|
+
# the success threshold or close the circuit. In closed, a success
|
|
156
|
+
# clears accumulated failures.
|
|
157
|
+
#
|
|
158
|
+
# @param probing [Boolean] whether this call was admitted as the probe
|
|
159
|
+
def record_success(probing)
|
|
160
|
+
if @state == :half_open
|
|
161
|
+
return unless probing
|
|
162
|
+
|
|
163
|
+
@success_count += 1
|
|
164
|
+
reset! if @success_count >= @success_threshold
|
|
165
|
+
elsif @state == :closed
|
|
166
|
+
@failure_count = 0
|
|
167
|
+
end
|
|
96
168
|
end
|
|
97
169
|
|
|
98
170
|
# Reset the circuit breaker to closed state with zero failures.
|
|
99
171
|
def reset!
|
|
100
172
|
@state = :closed
|
|
101
173
|
@failure_count = 0
|
|
174
|
+
@success_count = 0
|
|
102
175
|
@last_failure_time = nil
|
|
176
|
+
@half_open_probe_in_flight = false
|
|
103
177
|
end
|
|
104
178
|
end
|
|
105
179
|
end
|
|
@@ -203,7 +203,10 @@ module Woods
|
|
|
203
203
|
obj.each { |v| deep_freeze(v) }
|
|
204
204
|
obj.frozen? ? obj : obj.freeze
|
|
205
205
|
when String
|
|
206
|
-
|
|
206
|
+
# Freeze in place — returning a frozen dup would be discarded by the
|
|
207
|
+
# Hash/Array branches above (they recurse for side effects only),
|
|
208
|
+
# leaving every nested string mutable.
|
|
209
|
+
obj.frozen? ? obj : obj.freeze
|
|
207
210
|
else
|
|
208
211
|
obj
|
|
209
212
|
end
|