woods 2.0.0.beta1 → 2.0.0.beta3
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 +400 -1
- data/CONTRIBUTING.md +224 -9
- data/README.md +7 -3
- data/SECURITY.md +9 -6
- data/docs/AGENT_GUIDE.md +83 -4
- data/docs/AGENT_SETUP.md +82 -1
- data/docs/BACKEND_MATRIX.md +20 -0
- data/docs/CLIENT_HOOKS.md +111 -0
- data/docs/CONFIGURATION_REFERENCE.md +233 -13
- data/docs/CONSOLE_MCP_SETUP.md +35 -5
- data/docs/DOCKER_SETUP.md +21 -2
- data/docs/EVALUATION.md +464 -1
- data/docs/EXTRACTOR_REFERENCE.md +36 -5
- data/docs/FAQ.md +11 -12
- data/docs/GETTING_STARTED.md +17 -5
- data/docs/INCREMENTAL_EXTRACTION.md +158 -2
- data/docs/INDEX_LAYOUT.md +382 -0
- data/docs/INTERNALS.md +15 -7
- data/docs/MCP_SERVERS.md +221 -5
- data/docs/MCP_TOOL_COOKBOOK.md +33 -18
- data/docs/NOTION_INTEGRATION.md +13 -0
- data/docs/OBSIDIAN_INTEGRATION.md +57 -9
- data/docs/PUBLISHED_INDEX.md +71 -0
- data/docs/README.md +7 -0
- data/docs/RETRIEVAL_GUIDE.md +253 -11
- data/docs/RUNTIME_TRACING.md +71 -0
- data/docs/SOURCE_FRESHNESS.md +143 -0
- data/docs/TROUBLESHOOTING.md +117 -5
- data/docs/UNBLOCKED_INTEGRATION.md +25 -0
- data/docs/UPGRADING_TO_2.md +44 -22
- data/docs/WATCH_DAEMON.md +259 -59
- data/exe/woods-agent-config +6 -0
- data/exe/woods-extract +5 -0
- data/exe/woods-hook-context +6 -0
- data/lib/generators/woods/templates/woods.rb.tt +1 -3
- data/lib/tasks/woods.rake +47 -397
- data/lib/woods/agent_configuration/applier.rb +133 -0
- data/lib/woods/agent_configuration/cli.rb +101 -0
- data/lib/woods/agent_configuration/cli_options.rb +29 -0
- data/lib/woods/agent_configuration/document.rb +105 -0
- data/lib/woods/agent_configuration/error.rb +7 -0
- data/lib/woods/agent_configuration/launcher.rb +75 -0
- data/lib/woods/agent_configuration/layout.rb +59 -0
- data/lib/woods/agent_configuration/managed_section.rb +62 -0
- data/lib/woods/agent_configuration/plan.rb +98 -0
- data/lib/woods/agent_configuration/plan_diff.rb +38 -0
- data/lib/woods/agent_configuration/planned_files.rb +61 -0
- data/lib/woods/agent_configuration/planner.rb +63 -0
- data/lib/woods/agent_configuration/planner_validation.rb +77 -0
- data/lib/woods/agent_configuration/preflight.rb +100 -0
- data/lib/woods/agent_configuration/recovery.rb +49 -0
- data/lib/woods/ast/node.rb +2 -0
- data/lib/woods/ast/parser.rb +38 -5
- data/lib/woods/atomic_file.rb +133 -3
- data/lib/woods/builder.rb +21 -5
- data/lib/woods/cache/cache_middleware.rb +28 -7
- data/lib/woods/cache/cache_store.rb +4 -5
- data/lib/woods/change_set.rb +5 -4
- data/lib/woods/console/credential_index.rb +20 -2
- data/lib/woods/console/credential_scanner.rb +14 -14
- data/lib/woods/console/credential_scanner_registry.rb +36 -0
- data/lib/woods/console/embedded_executor.rb +1 -1
- data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
- data/lib/woods/console/rack_middleware.rb +22 -13
- data/lib/woods/console/server.rb +18 -16
- data/lib/woods/dependency_graph.rb +65 -13
- data/lib/woods/embedding/corpus.rb +94 -0
- data/lib/woods/embedding/indexer.rb +90 -46
- data/lib/woods/embedding/openai.rb +17 -6
- data/lib/woods/evaluation/ablation_executor.rb +6 -1
- data/lib/woods/evaluation/ablation_timed_executor.rb +22 -4
- data/lib/woods/export/typed_reader.rb +56 -0
- data/lib/woods/extractor.rb +557 -228
- data/lib/woods/extractors/action_cable_extractor.rb +3 -1
- data/lib/woods/extractors/behavioral_profile.rb +9 -7
- data/lib/woods/extractors/caching_extractor.rb +3 -1
- data/lib/woods/extractors/concern_extractor.rb +64 -6
- data/lib/woods/extractors/configuration_extractor.rb +7 -3
- data/lib/woods/extractors/controller_extractor.rb +13 -4
- data/lib/woods/extractors/database_view_extractor.rb +3 -1
- data/lib/woods/extractors/decorator_extractor.rb +3 -1
- data/lib/woods/extractors/engine_extractor.rb +3 -1
- data/lib/woods/extractors/event_extractor.rb +4 -2
- data/lib/woods/extractors/factory_extractor.rb +3 -1
- data/lib/woods/extractors/graphql_extractor.rb +8 -2
- data/lib/woods/extractors/i18n_extractor.rb +3 -1
- data/lib/woods/extractors/job_extractor.rb +6 -19
- data/lib/woods/extractors/lib_extractor.rb +3 -1
- data/lib/woods/extractors/mailer_extractor.rb +20 -5
- data/lib/woods/extractors/manager_extractor.rb +3 -1
- data/lib/woods/extractors/method_parameters.rb +53 -0
- data/lib/woods/extractors/middleware_argument.rb +65 -0
- data/lib/woods/extractors/middleware_extractor.rb +9 -3
- data/lib/woods/extractors/migration_extractor.rb +3 -1
- data/lib/woods/extractors/model_extractor.rb +39 -33
- data/lib/woods/extractors/package_extractor.rb +24 -4
- data/lib/woods/extractors/phlex_extractor.rb +3 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -1
- data/lib/woods/extractors/poro_extractor.rb +3 -1
- data/lib/woods/extractors/pundit_extractor.rb +3 -1
- data/lib/woods/extractors/rails_source_extractor.rb +4 -2
- data/lib/woods/extractors/rake_task_extractor.rb +4 -2
- data/lib/woods/extractors/route_extractor.rb +3 -1
- data/lib/woods/extractors/route_helper_resolver.rb +10 -33
- data/lib/woods/extractors/scheduled_job_extractor.rb +41 -15
- data/lib/woods/extractors/serializer_extractor.rb +4 -2
- data/lib/woods/extractors/service_extractor.rb +3 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +2 -2
- data/lib/woods/extractors/shared_utility_methods.rb +27 -15
- data/lib/woods/extractors/source_nesting.rb +1 -1
- data/lib/woods/extractors/state_machine_extractor.rb +3 -1
- data/lib/woods/extractors/test_mapping_extractor.rb +3 -1
- data/lib/woods/extractors/validator_extractor.rb +3 -1
- data/lib/woods/extractors/view_component_extractor.rb +3 -1
- data/lib/woods/extractors/view_template_extractor.rb +3 -1
- data/lib/woods/flow_assembler.rb +87 -8
- data/lib/woods/flow_precomputer.rb +44 -7
- data/lib/woods/gem_mapper.rb +2 -0
- data/lib/woods/git_history.rb +116 -0
- data/lib/woods/graph_analyzer.rb +195 -63
- data/lib/woods/hooks/context_cli.rb +54 -0
- data/lib/woods/hooks/context_event.rb +88 -0
- data/lib/woods/hooks/context_hint.rb +73 -0
- data/lib/woods/hooks/context_impact.rb +77 -0
- data/lib/woods/hooks/context_output.rb +47 -0
- data/lib/woods/hooks/context_state.rb +102 -0
- data/lib/woods/hooks/refresh.rb +79 -0
- data/lib/woods/hooks/rule_projection.rb +78 -0
- data/lib/woods/input_rules.rb +19 -0
- data/lib/woods/mcp/bearer_auth.rb +20 -12
- data/lib/woods/mcp/bootstrapper.rb +62 -0
- data/lib/woods/mcp/index_reader.rb +323 -160
- data/lib/woods/mcp/initialization_guidance.rb +27 -0
- data/lib/woods/mcp/origin_guard.rb +17 -9
- data/lib/woods/mcp/published_lexical_retriever.rb +115 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +8 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +7 -1
- data/lib/woods/mcp/search_results.rb +74 -0
- data/lib/woods/mcp/server.rb +158 -37
- data/lib/woods/mcp/tool_contract.rb +2 -0
- data/lib/woods/mcp/tool_response_renderer.rb +25 -0
- data/lib/woods/mcp/traversal_evidence.rb +113 -0
- data/lib/woods/mcp/traversal_evidence_index.rb +100 -0
- data/lib/woods/mcp/traversal_evidence_page.rb +41 -0
- data/lib/woods/mcp/traversal_evidence_text.rb +52 -0
- data/lib/woods/notion/exporter.rb +56 -17
- data/lib/woods/obsidian/destination_plan.rb +98 -0
- data/lib/woods/obsidian/name_mapper.rb +19 -3
- data/lib/woods/obsidian/note_builder.rb +19 -10
- data/lib/woods/obsidian/vault_exporter.rb +88 -32
- data/lib/woods/operator/pipeline_guard.rb +18 -13
- data/lib/woods/path_dispatcher.rb +7 -1
- data/lib/woods/payload_store.rb +29 -15
- data/lib/woods/railtie.rb +3 -3
- data/lib/woods/railtie_support.rb +12 -12
- data/lib/woods/rake_helpers.rb +392 -0
- data/lib/woods/resilience/graph_invariant_validator/membership_checks.rb +71 -0
- data/lib/woods/resilience/graph_invariant_validator/node_checks.rb +61 -0
- data/lib/woods/resilience/graph_invariant_validator/reverse_relationship_checks.rb +46 -0
- data/lib/woods/resilience/graph_invariant_validator.rb +119 -0
- data/lib/woods/resilience/index_validator/graph_checks.rb +80 -0
- data/lib/woods/resilience/index_validator.rb +112 -23
- data/lib/woods/retrieval/context_assembler.rb +50 -15
- data/lib/woods/retrieval/lexical_assembler.rb +73 -0
- data/lib/woods/retrieval/lexical_index.rb +119 -0
- data/lib/woods/retrieval/ranker.rb +4 -2
- data/lib/woods/retrieval/scope.rb +108 -0
- data/lib/woods/retrieval/scoped_graph_store.rb +32 -0
- data/lib/woods/retrieval/scoped_vector_store.rb +55 -0
- data/lib/woods/retrieval/search_executor.rb +86 -27
- data/lib/woods/retrieval/source_evidence.rb +200 -0
- data/lib/woods/retriever.rb +98 -22
- data/lib/woods/ruby_analyzer/trace_enricher.rb +80 -38
- data/lib/woods/session_tracer/middleware.rb +10 -12
- data/lib/woods/session_tracer/redis_store.rb +22 -6
- data/lib/woods/session_tracer/session_flow_assembler.rb +23 -17
- data/lib/woods/session_tracer/solid_cache_coordination.rb +6 -4
- data/lib/woods/session_tracer/unit_resolver.rb +63 -0
- data/lib/woods/source_inputs/consumer_errors.rb +27 -0
- data/lib/woods/source_inputs/handoff.rb +102 -0
- data/lib/woods/source_inputs/launcher.rb +157 -0
- data/lib/woods/source_inputs/manifest.rb +124 -0
- data/lib/woods/source_inputs/private_key.rb +55 -0
- data/lib/woods/source_inputs/scanner.rb +171 -0
- data/lib/woods/source_inputs/scopes.rb +71 -0
- data/lib/woods/source_inputs/session.rb +214 -0
- data/lib/woods/source_inputs/status.rb +84 -0
- data/lib/woods/source_inputs/verifier.rb +107 -0
- data/lib/woods/storage/metadata_store.rb +25 -25
- data/lib/woods/storage/pgvector.rb +29 -8
- data/lib/woods/storage/qdrant.rb +17 -7
- data/lib/woods/storage/vector_store.rb +18 -6
- data/lib/woods/tasks.rb +3 -2
- data/lib/woods/temporal/json_snapshot_store.rb +29 -8
- data/lib/woods/unblocked/exporter.rb +59 -70
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/boot_snapshot.rb +52 -0
- data/lib/woods/watch/daemon.rb +136 -28
- data/lib/woods/watch/listen_watcher.rb +4 -0
- data/lib/woods/watch/polling_watcher.rb +5 -1
- data/lib/woods/watch/status.rb +20 -15
- data/lib/woods/watch/tree_scan.rb +21 -13
- data/lib/woods/watch/watcher.rb +4 -1
- data/lib/woods.rb +135 -11
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.jq +15 -0
- data/plugin/hooks/adapters/normalize.rb +63 -0
- data/plugin/hooks/hooks.json +20 -0
- data/plugin/hooks/woods-context.sh +50 -0
- data/plugin/hooks/woods-input-rules.sh +159 -0
- data/plugin/hooks/woods-opencode.mjs +65 -0
- data/plugin/hooks/woods-post-edit.sh +2 -225
- data/plugin/hooks/woods-refresh.sh +260 -0
- data/plugin/hooks/woods-session-start.sh +47 -55
- data/plugin/skills/woods-agent-enable/SKILL.md +13 -0
- data/plugin/skills/woods-diagnose/SKILL.md +288 -1
- data/plugin/skills/woods-investigate/SKILL.md +106 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +89 -1
- data/plugin/skills/woods-setup/SKILL.md +107 -6
- metadata +84 -5
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require_relative '../atomic_file'
|
|
6
|
+
require_relative 'plan'
|
|
7
|
+
require_relative 'recovery'
|
|
8
|
+
|
|
9
|
+
module Woods
|
|
10
|
+
module AgentConfiguration
|
|
11
|
+
# Atomic per-file replacement with a private recovery journal. Multiple
|
|
12
|
+
# configuration files cannot be one filesystem transaction; interrupted
|
|
13
|
+
# writes retain enough original bytes to explicitly recover without
|
|
14
|
+
# overwriting concurrent edits.
|
|
15
|
+
class Applier
|
|
16
|
+
include Recovery
|
|
17
|
+
|
|
18
|
+
def initialize(layout:, writer: Woods::AtomicFile.method(:write))
|
|
19
|
+
@layout = layout
|
|
20
|
+
@writer = writer
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def apply(plan)
|
|
24
|
+
plan.validate!(@layout)
|
|
25
|
+
with_lock do
|
|
26
|
+
refuse_pending!
|
|
27
|
+
changes = plan.data.fetch('changes')
|
|
28
|
+
return 'already_applied' if changes.all? { |change| current(change) == Plan.after_fingerprint(change) }
|
|
29
|
+
|
|
30
|
+
journal = { 'schema_version' => 1, 'plan' => plan.data, 'originals' => originals(changes) }
|
|
31
|
+
write_journal(journal)
|
|
32
|
+
apply_changes(changes, journal)
|
|
33
|
+
'applied'
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def recover
|
|
38
|
+
with_lock do
|
|
39
|
+
document = Document.new(journal_path)
|
|
40
|
+
return 'nothing_to_recover' if document.content.nil?
|
|
41
|
+
|
|
42
|
+
recover_journal(document.json)
|
|
43
|
+
'recovered'
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def journal_path
|
|
50
|
+
"#{@layout.receipt_path}.pending"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def with_lock
|
|
54
|
+
path = "#{@layout.receipt_path}.lock"
|
|
55
|
+
Document.validate_path!(path)
|
|
56
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
57
|
+
File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW | File::NONBLOCK, 0o600) do |lock|
|
|
58
|
+
raise Conflict, "Not a regular lock file: #{path}" unless lock.stat.file?
|
|
59
|
+
unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
60
|
+
raise Conflict,
|
|
61
|
+
'Another Woods configuration operation is active; retry after it finishes'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
yield
|
|
65
|
+
ensure
|
|
66
|
+
lock&.flock(File::LOCK_UN)
|
|
67
|
+
end
|
|
68
|
+
rescue SystemCallError => e
|
|
69
|
+
raise Conflict, "Configuration write failed: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def write_journal(journal)
|
|
73
|
+
encoded = JSON.generate(journal)
|
|
74
|
+
if encoded.bytesize > Document::MAX_BYTES
|
|
75
|
+
raise Conflict, 'Recovery journal would exceed 8 MiB; reduce the selected configuration before applying'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@writer.call(journal_path, encoded, mode: 0o600)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def refuse_pending!
|
|
82
|
+
return if Document.new(journal_path).content.nil?
|
|
83
|
+
|
|
84
|
+
raise Conflict, "An interrupted operation needs explicit recovery first: #{journal_path}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def current(change)
|
|
88
|
+
Document.new(change.fetch('path')).fingerprint
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def assert_snapshot!(actual, expected, path)
|
|
92
|
+
return if actual == expected
|
|
93
|
+
|
|
94
|
+
raise Conflict, "Changed since preview: #{path}; create a new plan without discarding the user's edits"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def replace(path, content, mode)
|
|
98
|
+
Document.validate_path!(path)
|
|
99
|
+
if content.nil?
|
|
100
|
+
remove_file(path)
|
|
101
|
+
else
|
|
102
|
+
@writer.call(path, content, mode: mode)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def remove_file(path)
|
|
107
|
+
File.unlink(path)
|
|
108
|
+
rescue Errno::ENOENT
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def originals(changes)
|
|
113
|
+
changes.map do |change|
|
|
114
|
+
document = Document.new(change.fetch('path'))
|
|
115
|
+
assert_snapshot!(document.fingerprint, change.fetch('before'), document.path)
|
|
116
|
+
{ 'path' => document.path, 'content' => document.content && Base64.strict_encode64(document.content),
|
|
117
|
+
'mode' => document.mode }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def apply_changes(changes, journal)
|
|
122
|
+
changes.each do |change|
|
|
123
|
+
assert_snapshot!(current(change), change.fetch('before'), change.fetch('path'))
|
|
124
|
+
replace(change.fetch('path'), Plan.after_content(change), change.fetch('mode'))
|
|
125
|
+
end
|
|
126
|
+
File.unlink(journal_path)
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
recover_journal(journal)
|
|
129
|
+
raise Conflict, "Apply failed and original files were restored: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'cli_options'
|
|
4
|
+
require_relative 'plan_diff'
|
|
5
|
+
require_relative 'layout'
|
|
6
|
+
require_relative 'launcher'
|
|
7
|
+
require_relative 'preflight'
|
|
8
|
+
require_relative 'planner'
|
|
9
|
+
require_relative 'applier'
|
|
10
|
+
|
|
11
|
+
module Woods
|
|
12
|
+
module AgentConfiguration
|
|
13
|
+
class CLI
|
|
14
|
+
def initialize(stdout: $stdout, stderr: $stderr, preflight: Preflight.new)
|
|
15
|
+
@stdout = stdout
|
|
16
|
+
@stderr = stderr
|
|
17
|
+
@preflight = preflight
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run(argv)
|
|
21
|
+
options = { root: Dir.pwd, index: 'tmp/woods', mode: 'host', name: 'woods' }
|
|
22
|
+
command = argv.shift
|
|
23
|
+
parser = CLIOptions.build(options)
|
|
24
|
+
parser.parse!(argv)
|
|
25
|
+
if options[:help] || command.nil? || %w[-h --help].include?(command)
|
|
26
|
+
@stdout.puts(parser)
|
|
27
|
+
return 0
|
|
28
|
+
end
|
|
29
|
+
layout = Layout.new(root: options.fetch(:root), scope: options[:scope], client: options[:client])
|
|
30
|
+
result = dispatch(command, argv, options, layout)
|
|
31
|
+
@stdout.puts(JSON.pretty_generate(result))
|
|
32
|
+
0
|
|
33
|
+
rescue Conflict, OptionParser::ParseError, SystemCallError => e
|
|
34
|
+
@stderr.puts("woods-agent-config: #{e.message}")
|
|
35
|
+
1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def dispatch(command, argv, options, layout)
|
|
41
|
+
case command
|
|
42
|
+
when 'setup', 'update', 'remove'
|
|
43
|
+
raise Conflict, "Unexpected arguments: #{argv.join(' ')}" unless argv.empty?
|
|
44
|
+
|
|
45
|
+
create_plan(command, options, layout)
|
|
46
|
+
when 'show', 'apply'
|
|
47
|
+
consume_plan(command, argv, options, layout)
|
|
48
|
+
when 'recover'
|
|
49
|
+
raise Conflict, 'Recover does not accept positional arguments' unless argv.empty?
|
|
50
|
+
|
|
51
|
+
{ 'result' => Applier.new(layout: layout).recover }
|
|
52
|
+
else
|
|
53
|
+
raise Conflict, 'Select setup, update, remove, show, apply, or recover'
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def consume_plan(command, argv, options, layout)
|
|
58
|
+
raise Conflict, 'Select exactly one saved plan file' unless argv.size == 1
|
|
59
|
+
|
|
60
|
+
plan = Plan.load(argv.first).validate!(layout)
|
|
61
|
+
return { 'result' => Applier.new(layout: layout).apply(plan) } if command == 'apply'
|
|
62
|
+
|
|
63
|
+
PlanDiff.show(plan, @stderr) if options[:diff]
|
|
64
|
+
plan.summary
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def create_plan(operation, options, layout)
|
|
68
|
+
path = options[:plan]
|
|
69
|
+
raise Conflict, 'Choose --plan FILE for the private, reviewable write plan' unless path
|
|
70
|
+
|
|
71
|
+
launcher = if operation != 'remove'
|
|
72
|
+
Launcher.new(root: layout.root, **options.slice(:index, :mode, :service, :container_root))
|
|
73
|
+
end
|
|
74
|
+
evidence = launcher ? @preflight.call(launcher) : {}
|
|
75
|
+
plan = Planner.new(layout: layout, operation: operation, entry: launcher&.entry,
|
|
76
|
+
name: options.fetch(:name), instructions: options[:instructions],
|
|
77
|
+
evidence: evidence, intent: launcher ? launcher.intent : {}).call
|
|
78
|
+
write_plan(path, plan, layout)
|
|
79
|
+
PlanDiff.show(plan, @stderr) if options[:diff]
|
|
80
|
+
plan.summary.merge('plan_file' => File.expand_path(path),
|
|
81
|
+
'runtime_files' => ["#{layout.receipt_path}.lock", "#{layout.receipt_path}.pending"])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def write_plan(path, plan, layout)
|
|
85
|
+
target = File.expand_path(path)
|
|
86
|
+
protected_paths = layout.allowed_paths + ["#{layout.receipt_path}.lock", "#{layout.receipt_path}.pending"]
|
|
87
|
+
raise Conflict, 'Plan output must differ from every managed/runtime target' if protected_paths.include?(target)
|
|
88
|
+
|
|
89
|
+
Document.validate_path!(target)
|
|
90
|
+
content = "#{JSON.pretty_generate(plan.data)}\n"
|
|
91
|
+
raise Conflict, 'Plan exceeds the supported size' if content.bytesize > Document::MAX_BYTES
|
|
92
|
+
|
|
93
|
+
File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
|
|
94
|
+
file.write(content)
|
|
95
|
+
file.flush
|
|
96
|
+
file.fsync
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module AgentConfiguration
|
|
7
|
+
module CLIOptions
|
|
8
|
+
def self.build(options)
|
|
9
|
+
OptionParser.new do |parser|
|
|
10
|
+
parser.banner = <<~USAGE
|
|
11
|
+
Usage: woods-agent-config setup|update|remove --client claude --scope project|user --plan FILE [options]
|
|
12
|
+
woods-agent-config apply|show FILE --client claude --scope project|user [--root ROOT]
|
|
13
|
+
woods-agent-config recover --client claude --scope project|user [--root ROOT]
|
|
14
|
+
Setup/update/remove create a private plan without changing managed configuration.
|
|
15
|
+
Apply consumes that exact plan. Only Index MCP is configured; client trust remains separate.
|
|
16
|
+
USAGE
|
|
17
|
+
{ client: '--client NAME', scope: '--scope SCOPE', root: '--root DIRECTORY',
|
|
18
|
+
plan: '--plan FILE', name: '--name NAME', index: '--index PATH', mode: '--mode MODE',
|
|
19
|
+
service: '--service NAME', container_root: '--container-root PATH' }.each do |key, option|
|
|
20
|
+
parser.on(option) { |value| options[key] = value }
|
|
21
|
+
end
|
|
22
|
+
parser.on('--instructions LIST', Array) { |value| options[:instructions] = value }
|
|
23
|
+
parser.on('--diff') { options[:diff] = true }
|
|
24
|
+
parser.on('-h', '--help') { options[:help] = true }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative 'error'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
module AgentConfiguration
|
|
9
|
+
# Reads bounded regular files and rejects symlink components before writes.
|
|
10
|
+
# Preview/apply snapshots include exact bytes and permissions, so edits made
|
|
11
|
+
# after preview cannot be overwritten by a stale plan.
|
|
12
|
+
class Document
|
|
13
|
+
MAX_BYTES = 8 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
class UniqueObject < Hash
|
|
16
|
+
def []=(key, value)
|
|
17
|
+
raise Conflict, "Duplicate JSON key #{key.inspect}; resolve the ambiguous configuration" if key?(key)
|
|
18
|
+
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :path, :content, :mode
|
|
24
|
+
|
|
25
|
+
def initialize(path)
|
|
26
|
+
@path = File.expand_path(path)
|
|
27
|
+
self.class.validate_path!(@path)
|
|
28
|
+
@content = nil
|
|
29
|
+
@mode = 0o600
|
|
30
|
+
return unless File.exist?(@path)
|
|
31
|
+
|
|
32
|
+
File.open(@path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
33
|
+
stat = file.stat
|
|
34
|
+
raise Conflict, "Expected a regular file: #{@path}" unless stat.file?
|
|
35
|
+
raise Conflict, "Configuration file exceeds #{MAX_BYTES} bytes: #{@path}" if stat.size > MAX_BYTES
|
|
36
|
+
|
|
37
|
+
read_content(file)
|
|
38
|
+
@mode = stat.mode & 0o777
|
|
39
|
+
end
|
|
40
|
+
rescue SystemCallError => e
|
|
41
|
+
raise Conflict, "Cannot read configuration #{@path}: #{e.message}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.validate_path!(path)
|
|
45
|
+
current = File.expand_path(path)
|
|
46
|
+
loop do
|
|
47
|
+
raise Conflict, "Symlink target or parent is unsupported: #{current}" if File.symlink?(current)
|
|
48
|
+
|
|
49
|
+
parent = File.dirname(current)
|
|
50
|
+
break if parent == current
|
|
51
|
+
|
|
52
|
+
current = parent
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def fingerprint
|
|
57
|
+
{ 'sha256' => content && Digest::SHA256.hexdigest(content), 'mode' => mode, 'exists' => !content.nil? }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def json
|
|
61
|
+
return {} if content.nil?
|
|
62
|
+
|
|
63
|
+
value = JSON.parse(content, object_class: UniqueObject)
|
|
64
|
+
raise Conflict, "Expected a JSON object: #{path}" unless value.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
plain_value(value)
|
|
67
|
+
rescue JSON::ParserError => e
|
|
68
|
+
raise Conflict, "Malformed JSON in #{path}: #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def encode_json(value)
|
|
72
|
+
return content if !content.nil? && json == value
|
|
73
|
+
|
|
74
|
+
return JSON.generate(value) if content && !content.include?("\n")
|
|
75
|
+
|
|
76
|
+
JSON.pretty_generate(value, indent: json_indent).gsub("\n", newline) + newline
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def read_content(file)
|
|
82
|
+
@content = (file.read(MAX_BYTES + 1) || String.new).force_encoding(Encoding::UTF_8)
|
|
83
|
+
raise Conflict, "Invalid UTF-8 configuration: #{@path}" unless @content.valid_encoding?
|
|
84
|
+
raise Conflict, "Configuration file too large: #{@path}" if @content.bytesize > MAX_BYTES
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def newline
|
|
88
|
+
content&.include?("\r\n") ? "\r\n" : "\n"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def json_indent
|
|
92
|
+
match = content&.match(/\n([ \t]+)"/)
|
|
93
|
+
match ? match[1] : ' '
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def plain_value(value)
|
|
97
|
+
case value
|
|
98
|
+
when Hash then value.each_with_object({}) { |(key, item), hash| hash[key] = plain_value(item) }
|
|
99
|
+
when Array then value.map { |item| plain_value(item) }
|
|
100
|
+
else value
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative 'error'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module AgentConfiguration
|
|
8
|
+
# Supported command shapes are built as argv, never as shell snippets.
|
|
9
|
+
class Launcher
|
|
10
|
+
attr_reader :root, :index, :mode, :service, :container_root
|
|
11
|
+
|
|
12
|
+
def initialize(root:, index: 'tmp/woods', mode: 'host', service: nil, container_root: nil)
|
|
13
|
+
@root = File.realpath(root)
|
|
14
|
+
@index = Pathname.new(index).cleanpath.to_s
|
|
15
|
+
@mode = mode
|
|
16
|
+
@service = service
|
|
17
|
+
@container_root = container_root
|
|
18
|
+
unless !Pathname.new(@index).absolute? && @index != '..' && !@index.start_with?('../')
|
|
19
|
+
raise Conflict, 'Index must be an explicit path inside the selected application root'
|
|
20
|
+
end
|
|
21
|
+
raise Conflict, 'Application Gemfile is missing' unless File.file?(File.join(@root, 'Gemfile'))
|
|
22
|
+
raise Conflict, 'Supported launch modes: host, compose' unless %w[host compose].include?(mode)
|
|
23
|
+
|
|
24
|
+
validate_compose! if mode == 'compose'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def entry
|
|
28
|
+
argv = prefix + ['bundle', 'exec', 'woods-mcp-start', index_path]
|
|
29
|
+
{ 'type' => 'stdio', 'command' => argv.first, 'args' => argv.drop(1), 'env' => environment }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def probe_command(script)
|
|
33
|
+
prefix(frozen: true) + ['bundle', 'exec', 'ruby', '-e', script, index_path, process_root]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def environment
|
|
37
|
+
mode == 'host' ? { 'BUNDLE_GEMFILE' => File.join(root, 'Gemfile') } : {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def probe_environment
|
|
41
|
+
environment.merge('BUNDLE_FROZEN' => 'true')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def intent
|
|
45
|
+
{ 'mode' => mode, 'index' => index, 'service' => service, 'container_root' => container_root }.compact
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def process_root
|
|
51
|
+
mode == 'host' ? root : container_root
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def index_path
|
|
55
|
+
File.join(process_root, index)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def prefix(frozen: false)
|
|
59
|
+
return [] if mode == 'host'
|
|
60
|
+
|
|
61
|
+
['docker', 'compose', '--project-directory', root, 'exec', '-T', '-w', container_root] +
|
|
62
|
+
(frozen ? ['-e', 'BUNDLE_FROZEN=true'] : []) + [service]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate_compose!
|
|
66
|
+
unless service.is_a?(String) && service.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]*\z/)
|
|
67
|
+
raise Conflict, 'Compose mode requires an explicit service name'
|
|
68
|
+
end
|
|
69
|
+
return if container_root.is_a?(String) && Pathname.new(container_root).absolute?
|
|
70
|
+
|
|
71
|
+
raise Conflict, 'Compose mode requires the absolute application path visible inside the container'
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require_relative 'error'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
module AgentConfiguration
|
|
9
|
+
# Explicit client/scope selection; no discovery or fallback into a user home.
|
|
10
|
+
class Layout
|
|
11
|
+
attr_reader :root, :scope, :config_dir
|
|
12
|
+
|
|
13
|
+
def initialize(root:, scope:, client: 'claude', home: Dir.home, config_dir: ENV.fetch('CLAUDE_CONFIG_DIR', nil))
|
|
14
|
+
raise Conflict, 'Supported client: claude' unless client == 'claude'
|
|
15
|
+
raise Conflict, 'Select scope project or user explicitly' unless %w[project user].include?(scope)
|
|
16
|
+
|
|
17
|
+
@root = File.realpath(root)
|
|
18
|
+
raise Conflict, 'Application root must be a directory' unless File.directory?(@root)
|
|
19
|
+
|
|
20
|
+
@scope = scope
|
|
21
|
+
@home = File.expand_path(home)
|
|
22
|
+
@custom_config = config_dir && !config_dir.empty?
|
|
23
|
+
@config_dir = File.expand_path(@custom_config ? config_dir : File.join(@home, '.claude'))
|
|
24
|
+
rescue Errno::ENOENT => e
|
|
25
|
+
raise Conflict, "Application root unavailable: #{e.message}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def config_path
|
|
29
|
+
return File.join(root, '.mcp.json') if scope == 'project'
|
|
30
|
+
|
|
31
|
+
File.join(@custom_config ? config_dir : @home, '.claude.json')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def receipt_path
|
|
35
|
+
return File.join(root, '.woods-agent-config.json') if scope == 'project'
|
|
36
|
+
|
|
37
|
+
File.join(config_dir, "woods-agent-#{Digest::SHA256.hexdigest(root)[0, 16]}.json")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def instruction_path(name)
|
|
41
|
+
names = scope == 'project' ? %w[CLAUDE.md AGENTS.md] : ['CLAUDE.md']
|
|
42
|
+
raise Conflict, "Supported #{scope} instruction files: #{names.join(', ')}" unless names.include?(name)
|
|
43
|
+
|
|
44
|
+
File.join(scope == 'project' ? root : config_dir, name)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def allowed_paths
|
|
48
|
+
[config_path, receipt_path, instruction_path('CLAUDE.md')].tap do |paths|
|
|
49
|
+
paths << instruction_path('AGENTS.md') if scope == 'project'
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def identity
|
|
54
|
+
{ 'client' => 'claude', 'scope' => scope, 'root' => root, 'config_dir' => config_dir,
|
|
55
|
+
'config_path' => config_path, 'receipt_path' => receipt_path }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'error'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module AgentConfiguration
|
|
7
|
+
# Only a receipt-owned, byte-identical section may be replaced or removed.
|
|
8
|
+
class ManagedSection
|
|
9
|
+
START = '<!-- woods:managed:start -->'
|
|
10
|
+
FINISH = '<!-- woods:managed:end -->'
|
|
11
|
+
BODY = <<~MARKDOWN
|
|
12
|
+
## Codebase index (Woods)
|
|
13
|
+
|
|
14
|
+
Call `woods_status` before relying on the published index. For structural
|
|
15
|
+
questions use `search`, `lookup`, and `dependencies`/`dependents` as needed.
|
|
16
|
+
Check freshness and search completeness before treating missing evidence
|
|
17
|
+
as absent code. Read source and run relevant tests to verify changes.
|
|
18
|
+
The server's tool descriptions define its available capabilities.
|
|
19
|
+
MARKDOWN
|
|
20
|
+
|
|
21
|
+
def self.render(content)
|
|
22
|
+
newline = content&.include?("\r\n") ? "\r\n" : "\n"
|
|
23
|
+
"#{START}\n#{BODY}#{FINISH}\n".gsub("\n", newline)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.change(content, previous:, remove: false)
|
|
27
|
+
text = content || ''
|
|
28
|
+
starts = text.scan(START).size
|
|
29
|
+
finishes = text.scan(FINISH).size
|
|
30
|
+
return replace_owned(text, previous, starts, finishes, remove) if previous
|
|
31
|
+
|
|
32
|
+
unless starts.zero? && finishes.zero?
|
|
33
|
+
raise Conflict, 'Unowned or ambiguous Woods markers; resolve them before setup'
|
|
34
|
+
end
|
|
35
|
+
return [content, nil] if remove
|
|
36
|
+
|
|
37
|
+
prefix = separator(text)
|
|
38
|
+
block = prefix + render(text)
|
|
39
|
+
[text + block, { 'owned_text' => block, 'prefix' => prefix }]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.replace_owned(text, previous, starts, finishes, remove)
|
|
43
|
+
unless starts == 1 && finishes == 1 && text.include?(previous.fetch('owned_text'))
|
|
44
|
+
raise Conflict, 'Managed instruction section was edited or removed; restore it or resolve ownership manually'
|
|
45
|
+
end
|
|
46
|
+
return [text.sub(previous.fetch('owned_text'), ''), nil] if remove
|
|
47
|
+
|
|
48
|
+
block = previous.fetch('prefix') + render(text)
|
|
49
|
+
[text.sub(previous.fetch('owned_text'), block), { 'owned_text' => block, 'prefix' => previous.fetch('prefix') }]
|
|
50
|
+
end
|
|
51
|
+
private_class_method :replace_owned
|
|
52
|
+
|
|
53
|
+
def self.separator(text)
|
|
54
|
+
return '' if text.empty?
|
|
55
|
+
|
|
56
|
+
newline = text.include?("\r\n") ? "\r\n" : "\n"
|
|
57
|
+
text.end_with?(newline) ? newline : newline * 2
|
|
58
|
+
end
|
|
59
|
+
private_class_method :separator
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative 'document'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
module AgentConfiguration
|
|
9
|
+
# The serialized plan is the write set. Apply checks its original snapshots
|
|
10
|
+
# and never independently recomputes a newer set of intended edits.
|
|
11
|
+
class Plan
|
|
12
|
+
SCHEMA_VERSION = 1
|
|
13
|
+
|
|
14
|
+
attr_reader :data
|
|
15
|
+
|
|
16
|
+
def initialize(layout:, operation:, evidence: {})
|
|
17
|
+
@data = { 'schema_version' => SCHEMA_VERSION, 'layout' => layout.identity,
|
|
18
|
+
'operation' => operation, 'evidence' => evidence, 'changes' => [] }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.load(path)
|
|
22
|
+
object = allocate
|
|
23
|
+
object.instance_variable_set(:@data, Document.new(path).json)
|
|
24
|
+
object
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add(document, content, description:)
|
|
28
|
+
return if content == document.content
|
|
29
|
+
|
|
30
|
+
data.fetch('changes') << { 'path' => document.path, 'before' => document.fingerprint,
|
|
31
|
+
'after' => content && Base64.strict_encode64(content),
|
|
32
|
+
'mode' => document.mode, 'description' => description }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate!(layout)
|
|
36
|
+
validate_format!(layout)
|
|
37
|
+
validate_paths!(layout)
|
|
38
|
+
data.fetch('changes').each { |change| validate_change!(change) }
|
|
39
|
+
self
|
|
40
|
+
rescue KeyError, TypeError, ArgumentError => e
|
|
41
|
+
raise Conflict, "Invalid plan: #{e.message}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.after_content(change)
|
|
45
|
+
encoded = change.fetch('after')
|
|
46
|
+
encoded && Base64.strict_decode64(encoded).force_encoding(Encoding::UTF_8)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.after_fingerprint(change)
|
|
50
|
+
content = after_content(change)
|
|
51
|
+
{ 'sha256' => content && Digest::SHA256.hexdigest(content), 'mode' => content ? change.fetch('mode') : 0o600,
|
|
52
|
+
'exists' => !content.nil? }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def summary
|
|
56
|
+
data.slice('operation', 'layout', 'evidence').merge(
|
|
57
|
+
'changes' => data.fetch('changes').map do |change|
|
|
58
|
+
change.slice('path', 'description').merge('action' => change['after'] ? 'write' : 'remove')
|
|
59
|
+
end
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def to_json(*arguments)
|
|
64
|
+
data.to_json(*arguments)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def validate_format!(layout)
|
|
70
|
+
valid = data.is_a?(Hash) && data['schema_version'] == SCHEMA_VERSION && data['layout'] == layout.identity &&
|
|
71
|
+
%w[setup update remove].include?(data['operation']) && data['changes'].is_a?(Array) &&
|
|
72
|
+
data['changes'].all?(Hash)
|
|
73
|
+
return if valid
|
|
74
|
+
|
|
75
|
+
raise Conflict, 'Plan format or selected application/client/scope differs; create a fresh preview'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_paths!(layout)
|
|
79
|
+
paths = data.fetch('changes').map { |change| change.fetch('path') }
|
|
80
|
+
return if paths.uniq == paths && (paths - layout.allowed_paths).empty?
|
|
81
|
+
|
|
82
|
+
raise Conflict, 'Plan contains duplicate or out-of-scope targets'
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_change!(change)
|
|
86
|
+
unless change['before'].is_a?(Hash) && change['mode'].is_a?(Integer) && change['mode'].between?(0, 0o777)
|
|
87
|
+
raise Conflict, 'Invalid file snapshot in plan'
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
content = self.class.after_content(change)
|
|
91
|
+
return if content.nil?
|
|
92
|
+
|
|
93
|
+
raise Conflict, 'Planned file exceeds the supported size' if content.bytesize > Document::MAX_BYTES
|
|
94
|
+
raise Conflict, 'Planned file must contain valid UTF-8' unless content.valid_encoding?
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|