hecks 3.1.3 → 3.2.1
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/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
- data/lib/hecks/cli/check_era.rb +3 -39
- data/lib/hecks/deploy/bluebook/deploy.bluebook +105 -0
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
- data/lib/hecks/deploy/oidc.json +15 -0
- data/lib/hecks/doors/mcp_door.rb +16 -2
- data/lib/hecks/doors/mcp_door_scope.rb +43 -4
- data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
- data/lib/hecks/hecks/adapters/cost_explorer.adapter +7 -0
- data/lib/hecks/hecks/adapters/cost_explorer.rb +102 -0
- data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
- data/lib/hecks/hecks/adapters/site_toolchain.rb +21 -11
- data/lib/hecks/hecks/hecks.hecksagon +8 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +7 -7
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
- data/lib/hecks/projections/deploy/box/settings.rb +23 -2
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -1
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
- data/lib/hecks/projections/deploy/box.rb +13 -2
- data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
- data/lib/hecks/projections/site/edge/checks.rb +31 -5
- data/lib/hecks/projections/site/edge.rb +25 -11
- data/lib/hecks/projections/site/routes_ts.rb +50 -27
- data/lib/hecks/projections/site/site_cdn.rb +7 -4
- data/lib/hecks/projections/site/table/checks.rb +53 -7
- data/lib/hecks/projections/site/table/link_builder.rb +64 -0
- data/lib/hecks/projections/site/table/row_builder.rb +21 -6
- data/lib/hecks/projections/site/table.rb +16 -5
- data/lib/hecks/rust_build/kernel_input.rb +18 -6
- data/lib/hecks/rust_build/project_rust.rb +6 -132
- data/lib/hecks/site/bluebook/site.bluebook +27 -13
- data/lib/hecks/storehouse.rb +35 -1
- data/lib/hecks/tools/comment_style.rb +4 -2
- data/lib/hecks/tools/deploy_recipe.rb +1 -0
- data/lib/hecks/tools/rust_comment_style.rb +1 -1
- data/lib/hecks/tools/site_routes.rb +69 -28
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +1 -1
- data/lib/hecks.rb +1 -0
- data/rust/build/src/lineage_pass.rs +1 -1
- data/rust/build/src/pipeline.rs +1 -1
- data/rust/codegen/src/attr.rs +1 -1
- data/rust/codegen/src/bridging.rs +1 -1
- data/rust/codegen/src/commands.rs +1 -1
- data/rust/codegen/src/constraints.rs +1 -1
- data/rust/codegen/src/dependency_planning.rs +1 -1
- data/rust/codegen/src/domain_generator.rs +2 -2
- data/rust/codegen/src/exemplar.rs +1 -1
- data/rust/codegen/src/expr_emitter.rs +1 -1
- data/rust/codegen/src/fielded.rs +1 -1
- data/rust/codegen/src/json_codec.rs +1 -1
- data/rust/codegen/src/main.rs +3 -4
- data/rust/codegen/src/mutations.rs +1 -1
- data/rust/codegen/src/naming.rs +1 -1
- data/rust/codegen/src/ports.rs +1 -1
- data/rust/codegen/src/queries.rs +3 -3
- data/rust/codegen/src/reactions.rs +1 -1
- data/rust/codegen/src/read_models.rs +2 -2
- data/rust/codegen/src/reference_specs.rs +1 -1
- data/rust/codegen/src/registry.rs +4 -4
- data/rust/codegen/src/shared.rs +1 -1
- data/rust/codegen/src/skip_reason.rs +1 -1
- data/rust/codegen/src/types.rs +4 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/auth.rs +25 -1
- data/rust/host/src/needs.rs +94 -27
- data/rust/host/src/web.rs +9 -5
- data/rust/src/exemplar/commands.rs +1 -1
- data/rust/src/exemplar/constraints.rs +1 -1
- data/rust/src/exemplar/fielded.rs +1 -1
- data/rust/src/exemplar/json.rs +1 -1
- data/rust/src/exemplar/mod.rs +1 -1
- data/rust/src/exemplar/mutations.rs +1 -1
- data/rust/src/exemplar/queries.rs +1 -1
- data/rust/src/exemplar/reactions.rs +1 -1
- data/rust/src/exemplar/read_models.rs +1 -1
- data/rust/src/exemplar/registry.rs +1 -1
- data/rust/src/exemplar/types.rs +1 -1
- data/rust/src/kernel/cli.rs +2 -2
- data/rust/src/kernel/read_model.rs +1 -1
- data/rust/src/lib.rs +1 -1
- metadata +5 -23
- data/rust/project/bridging.rb +0 -257
- data/rust/project/commands.rb +0 -877
- data/rust/project/constraints.rb +0 -86
- data/rust/project/dependency_planning.rb +0 -178
- data/rust/project/domain_generator.rb +0 -983
- data/rust/project/exemplar.rb +0 -148
- data/rust/project/expr_emitter.rb +0 -72
- data/rust/project/fielded.rb +0 -141
- data/rust/project/json_codec.rb +0 -641
- data/rust/project/mutations.rb +0 -695
- data/rust/project/naming.rb +0 -223
- data/rust/project/ports.rb +0 -65
- data/rust/project/queries.rb +0 -493
- data/rust/project/reactions.rb +0 -317
- data/rust/project/read_models.rb +0 -690
- data/rust/project/reference_specs.rb +0 -27
- data/rust/project/registry.rb +0 -396
- data/rust/project/skip_reason.rb +0 -29
- data/rust/project/types.rb +0 -225
- data/rust/project/write_if_changed.rb +0 -7
- data/rust/project.rb +0 -30
- data/rust/project_rust_pipeline.rb +0 -391
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b963870a34a1483bc0156042a2967a72f8fc1b8e907ba5d8cac5a5382d4999d
|
|
4
|
+
data.tar.gz: 26697af02778cda282a44c1ca099e36a9866bf2d204ab5abb23df8ac2a86283a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a81b0ffb4b0e04024518169e3c6dd51a3f55b5da39659a852c2a0c9528453fe0eb9facf07d7b18bd35d7924c5a08331b2f1ad82a7e5c4c43c61fae55490d46ed
|
|
7
|
+
data.tar.gz: f6780cb0c58069a5049081c7a319424787c9fea1fd94a5085153d7b99533c0e1974c23dfeb3515b66f3b2d8d3c7ca83a08dcd5f024df02da2b2fc9609c3ba6b7
|
|
@@ -57,9 +57,9 @@ module Hecks
|
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
# The release that drops the deprecated spellings of `attaches`;
|
|
61
|
-
# `Hecks::Doors::REMOVAL`, which
|
|
62
|
-
REMOVAL = "3.
|
|
60
|
+
# The release that drops the deprecated spellings of `attaches`; independent of
|
|
61
|
+
# `Hecks::Doors::REMOVAL`, which covers the older `Facade` aliases.
|
|
62
|
+
REMOVAL = "3.3.0".freeze
|
|
63
63
|
|
|
64
64
|
attr_reader :binds, :subscriptions, :attachments
|
|
65
65
|
|
data/lib/hecks/cli/check_era.rb
CHANGED
|
@@ -2,11 +2,9 @@ require_relative "../ports/persistence/plugins/era/expected_era"
|
|
|
2
2
|
|
|
3
3
|
module Hecks
|
|
4
4
|
module CLI
|
|
5
|
-
# The
|
|
6
|
-
#
|
|
5
|
+
# The check behind Custodian's `Host.CheckEra`: asks a running host which era it reports and
|
|
6
|
+
# compares it with an allow-list file. Read-only: one GET, no writes.
|
|
7
7
|
module CheckEra
|
|
8
|
-
USAGE = "usage: hecks check_era <url> <expected-era-file> [--timeout=<seconds>]".freeze
|
|
9
|
-
|
|
10
8
|
# What a check found.
|
|
11
9
|
#
|
|
12
10
|
# @!attribute [r] verdict
|
|
@@ -14,7 +12,7 @@ module Hecks
|
|
|
14
12
|
# @!attribute [r] version
|
|
15
13
|
# @return [String] the gem version the host reports, empty when it reports none
|
|
16
14
|
# @!attribute [r] line
|
|
17
|
-
# @return [String] the sentence
|
|
15
|
+
# @return [String] the sentence the check reports for this finding
|
|
18
16
|
Finding = Struct.new(:verdict, :version, :line)
|
|
19
17
|
|
|
20
18
|
module_function
|
|
@@ -36,35 +34,6 @@ module Hecks
|
|
|
36
34
|
Finding.new(verdict, document.fetch("version"), sentence(verdict, file))
|
|
37
35
|
end
|
|
38
36
|
|
|
39
|
-
# Runs `hecks check_era`: prints the finding and answers the exit status.
|
|
40
|
-
#
|
|
41
|
-
# @param argv [Array<String>] the url, the allow-list file and an optional `--timeout=N`
|
|
42
|
-
# @param out [IO] where a finding goes
|
|
43
|
-
# @param err [IO] where a refusal goes
|
|
44
|
-
# @return [Integer] 0 when the era is expected or unlisted, 1 on a mismatch, 2 on bad
|
|
45
|
-
# usage or an unreadable file, 3 when the host cannot be reached or answers no era
|
|
46
|
-
def run(argv, out: $stdout, err: $stderr)
|
|
47
|
-
timeout = 10
|
|
48
|
-
args = argv.reject do |arg|
|
|
49
|
-
next false unless arg.start_with?("--timeout=")
|
|
50
|
-
|
|
51
|
-
timeout = arg.delete_prefix("--timeout=").to_f
|
|
52
|
-
true
|
|
53
|
-
end
|
|
54
|
-
url, file = args
|
|
55
|
-
return usage(err) unless url && file && args.size == 2 && timeout.positive?
|
|
56
|
-
|
|
57
|
-
finding = assess(url, file, timeout: timeout)
|
|
58
|
-
(finding.verdict.ok? ? out : err).puts(finding.line)
|
|
59
|
-
finding.verdict.ok? ? 0 : 1
|
|
60
|
-
rescue Errno::ENOENT, Errno::EACCES => e
|
|
61
|
-
err.puts("cannot read #{file}: #{e.message}")
|
|
62
|
-
2
|
|
63
|
-
rescue Runtime::EraCheck::ExpectedEra::Unreachable, Runtime::EraCheck::ExpectedEra::BadResponse => e
|
|
64
|
-
err.puts("era check failed: #{e.message}")
|
|
65
|
-
3
|
|
66
|
-
end
|
|
67
|
-
|
|
68
37
|
# @param verdict [Runtime::EraCheck::ExpectedEra::Verdict] the comparison
|
|
69
38
|
# @param file [String] the allow-list file, named in the sentence
|
|
70
39
|
# @return [String] the one line that says what the comparison found
|
|
@@ -78,11 +47,6 @@ module Hecks
|
|
|
78
47
|
"host reports era #{verdict.era}, which is not in #{file} (expected: #{verdict.allowed.join(', ')})"
|
|
79
48
|
end
|
|
80
49
|
end
|
|
81
|
-
|
|
82
|
-
def usage(err)
|
|
83
|
-
err.puts(USAGE)
|
|
84
|
-
2
|
|
85
|
-
end
|
|
86
50
|
end
|
|
87
51
|
end
|
|
88
52
|
end
|
|
@@ -665,6 +665,111 @@ Hecks.bluebook "Deploy" do
|
|
|
665
665
|
trigger MakefileCheck::Flag, with: { refusal: :refusal }
|
|
666
666
|
end
|
|
667
667
|
|
|
668
|
+
aggregate "CostCheck" do
|
|
669
|
+
description "One reading of what hosting costs a month, against a budget."
|
|
670
|
+
|
|
671
|
+
attribute :run, RunKey
|
|
672
|
+
attribute :budget, Budget
|
|
673
|
+
attribute :since, Since
|
|
674
|
+
attribute :report, Report, optional: true
|
|
675
|
+
attribute :refusal, Report, optional: true
|
|
676
|
+
identified_by :run
|
|
677
|
+
|
|
678
|
+
value_object "RunKey" do
|
|
679
|
+
attribute :value, String, pattern: '[^ \t\n\r]'
|
|
680
|
+
|
|
681
|
+
invariant("a run is named") { !value.to_s.empty? }
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
# Whole dollars a month.
|
|
685
|
+
value_object "Budget" do
|
|
686
|
+
attribute :value, Integer
|
|
687
|
+
|
|
688
|
+
invariant("a budget is positive") { value.positive? }
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# The first day to count, as `YYYY-MM-DD`: the first complete day after the last change to
|
|
692
|
+
# what is running, so a bill that still carries the old setup is not read as the new one.
|
|
693
|
+
value_object "Since" do
|
|
694
|
+
attribute :value, String, pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2}'
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
value_object "Report" do
|
|
698
|
+
attribute :value, String
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
lifecycle :status, default: "requested" do
|
|
702
|
+
transition "Pass" => "within_budget", from: "requested"
|
|
703
|
+
transition "Flag" => "flagged", from: "requested"
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# The reading is the billed daily total from the first complete day since `since` up to
|
|
707
|
+
# yesterday, scaled to a month; today is left out because its bill is still partial.
|
|
708
|
+
command "Check" do
|
|
709
|
+
goal "Read what hosting has billed since a day and compare its monthly rate to a budget"
|
|
710
|
+
|
|
711
|
+
attribute :run, RunKey
|
|
712
|
+
attribute :budget, Budget
|
|
713
|
+
attribute :since, Since
|
|
714
|
+
|
|
715
|
+
sets :run
|
|
716
|
+
sets :budget
|
|
717
|
+
sets :since
|
|
718
|
+
|
|
719
|
+
emits "CostCheckRequested"
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
command "Pass" do
|
|
723
|
+
goal "Record that the monthly rate is within the budget, and what it was"
|
|
724
|
+
|
|
725
|
+
reference_to CostCheck
|
|
726
|
+
attribute :report, Report
|
|
727
|
+
|
|
728
|
+
sets :report
|
|
729
|
+
|
|
730
|
+
emits "CostWithinBudget"
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
command "Flag" do
|
|
734
|
+
goal "Record that the monthly rate is over the budget, or that it could not be read, and what was said"
|
|
735
|
+
|
|
736
|
+
reference_to CostCheck
|
|
737
|
+
attribute :refusal, Report
|
|
738
|
+
|
|
739
|
+
sets :refusal
|
|
740
|
+
|
|
741
|
+
emits "CostFlagged"
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
query "Verdict" do
|
|
745
|
+
description "What one reading found: its status, and the report or the reason it was flagged."
|
|
746
|
+
attribute :run, RunKey
|
|
747
|
+
where(run: :run)
|
|
748
|
+
order_by :run
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
query "Flagged" do
|
|
752
|
+
description "Every reading that was over its budget or could not be taken."
|
|
753
|
+
where(status: "flagged")
|
|
754
|
+
order_by :run
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
policy "MeasureWhenChecked" do
|
|
759
|
+
on "CostCheck.CostCheckRequested"
|
|
760
|
+
trigger CostCheck::CostExplorer::Measure, with: { run: :run }
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
policy "RecordTheCostWithinBudget" do
|
|
764
|
+
on "CostCheck.CostAnswered"
|
|
765
|
+
trigger CostCheck::Pass, with: { report: :report }
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
policy "RecordTheFlaggedCost" do
|
|
769
|
+
on "CostCheck.CostRefused"
|
|
770
|
+
trigger CostCheck::Flag, with: { refusal: :refusal }
|
|
771
|
+
end
|
|
772
|
+
|
|
668
773
|
aggregate "TemplateComparison" do
|
|
669
774
|
description "One offline comparison of two CloudFormation templates, by logical id."
|
|
670
775
|
|
|
@@ -35,6 +35,14 @@ Hecks.hecksagon "Deploy" do
|
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
Deploy::CostCheck.port "CostExplorer" do
|
|
39
|
+
asks "Measure", to: CostCheck do
|
|
40
|
+
attribute :run, RunKey
|
|
41
|
+
answers "CostAnswered"
|
|
42
|
+
refuses "CostRefused"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
38
46
|
Deploy::TemplateComparison.port "DeployToolchain" do
|
|
39
47
|
asks "Compare", to: TemplateComparison do
|
|
40
48
|
attribute :run, RunKey
|
data/lib/hecks/deploy/oidc.json
CHANGED
|
@@ -6,6 +6,21 @@
|
|
|
6
6
|
"verb": "Deploy::BoxTarget.Declare",
|
|
7
7
|
"role": null
|
|
8
8
|
},
|
|
9
|
+
{
|
|
10
|
+
"scope": "deploy:cost_check.check",
|
|
11
|
+
"verb": "Deploy::CostCheck.Check",
|
|
12
|
+
"role": null
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"scope": "deploy:cost_check.flag",
|
|
16
|
+
"verb": "Deploy::CostCheck.Flag",
|
|
17
|
+
"role": null
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"scope": "deploy:cost_check.pass",
|
|
21
|
+
"verb": "Deploy::CostCheck.Pass",
|
|
22
|
+
"role": null
|
|
23
|
+
},
|
|
9
24
|
{
|
|
10
25
|
"scope": "deploy:fargate_target.declare",
|
|
11
26
|
"verb": "Deploy::FargateTarget.Declare",
|
data/lib/hecks/doors/mcp_door.rb
CHANGED
|
@@ -343,7 +343,20 @@ module Hecks
|
|
|
343
343
|
def answer_tool(name, args)
|
|
344
344
|
return scope.refusal(name) unless scope.permits_tool?(name)
|
|
345
345
|
|
|
346
|
-
call_tool(name, args)
|
|
346
|
+
call_tool(name, scope.with_default_domain(args))
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# One line per allowed command: what it takes and the role it declares, read from the booted
|
|
350
|
+
# domain the door serves. Nil when the door serves no single domain or it will not boot, so
|
|
351
|
+
# `tools/list` still answers, with the commands as a bare list.
|
|
352
|
+
def command_guide
|
|
353
|
+
return unless scope.commands_mode? && scope.default_domain
|
|
354
|
+
|
|
355
|
+
McpGuideCache.remember(scope.default_domain, scope.allowed_commands) do
|
|
356
|
+
Storehouse.command_guide(boot(scope.default_domain), scope.allowed_commands)
|
|
357
|
+
end
|
|
358
|
+
rescue StandardError
|
|
359
|
+
nil
|
|
347
360
|
end
|
|
348
361
|
|
|
349
362
|
def tool_result(payload)
|
|
@@ -374,7 +387,8 @@ module Hecks
|
|
|
374
387
|
nil # a notification — no id, no response
|
|
375
388
|
when "tools/list"
|
|
376
389
|
served = TOOLS.select { |tool| scope.permits_tool?(tool[:name]) }
|
|
377
|
-
|
|
390
|
+
guide = command_guide
|
|
391
|
+
send_response(id, { tools: served.map { |tool| scope.present(tool, guide) } })
|
|
378
392
|
when "tools/call"
|
|
379
393
|
send_response(id, tool_result(answer_tool(params["name"], params["arguments"] || {})))
|
|
380
394
|
when "ping"
|
|
@@ -201,18 +201,40 @@ module Hecks
|
|
|
201
201
|
end
|
|
202
202
|
end
|
|
203
203
|
|
|
204
|
-
# The
|
|
205
|
-
#
|
|
204
|
+
# The one domain a restricted door serves, when it serves exactly one: a call may leave
|
|
205
|
+
# `domain:` out and get it. Nil for an unrestricted door and for one that names several.
|
|
206
|
+
#
|
|
207
|
+
# @return [String, nil] the domain path as `HECKS_DOOR_DOMAINS` named it, resolved
|
|
208
|
+
def default_domain
|
|
209
|
+
@allowed_domains.first if restricted? && @allowed_domains.size == 1
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The arguments with the default domain filled in when the caller left it out.
|
|
213
|
+
#
|
|
214
|
+
# @param args [Hash] a tool call's arguments
|
|
215
|
+
# @return [Hash] `args`, with `"domain"` set when the door has a default and none was given
|
|
216
|
+
def with_default_domain(args)
|
|
217
|
+
return args unless default_domain && args["domain"].to_s.strip.empty?
|
|
218
|
+
|
|
219
|
+
args.merge("domain" => default_domain)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The tool as `tools/list` shows it. A door that serves one domain makes `domain:` optional.
|
|
223
|
+
# On a commands door, `dispatch` also names the commands it serves as an enum, so a caller
|
|
224
|
+
# sees them as typed choices instead of finding out by being refused, and says what each
|
|
225
|
+
# takes and which role it declares.
|
|
206
226
|
#
|
|
207
227
|
# @param tool [Hash] a tool definition from `McpDoor::TOOLS`
|
|
228
|
+
# @param guide [Array<String>, nil] one line per allowed command (`Storehouse.command_guide`)
|
|
208
229
|
# @return [Hash] the definition, narrowed for this door
|
|
209
|
-
def present(tool)
|
|
230
|
+
def present(tool, guide = nil)
|
|
231
|
+
tool = domain_optional(tool) if default_domain
|
|
210
232
|
return tool unless commands_mode? && tool[:name] == "dispatch"
|
|
211
233
|
|
|
212
234
|
properties = tool[:inputSchema][:properties]
|
|
213
235
|
narrowed = properties.merge(command: with_enum(properties[:command]),
|
|
214
236
|
steps: properties[:steps].merge(items: step_items(properties[:steps][:items])))
|
|
215
|
-
tool.merge(description:
|
|
237
|
+
tool.merge(description: dispatch_description(tool[:description], guide),
|
|
216
238
|
inputSchema: tool[:inputSchema].merge(properties: narrowed))
|
|
217
239
|
end
|
|
218
240
|
|
|
@@ -287,6 +309,23 @@ module Hecks
|
|
|
287
309
|
File.join(File.realpath(current), *missing)
|
|
288
310
|
end
|
|
289
311
|
|
|
312
|
+
def domain_optional(tool)
|
|
313
|
+
schema = tool[:inputSchema]
|
|
314
|
+
return tool unless schema[:properties].key?(:domain)
|
|
315
|
+
|
|
316
|
+
note = "Optional: this door serves one domain, #{default_domain}, and uses it when you leave this out."
|
|
317
|
+
properties = schema[:properties].merge(domain: schema[:properties][:domain].merge(description: note))
|
|
318
|
+
tool.merge(inputSchema: schema.merge(properties: properties, required: schema[:required] - ["domain"]))
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def dispatch_description(base, guide)
|
|
322
|
+
intro = "#{base} This door dispatches only the commands below, each with the role it declares: pass " \
|
|
323
|
+
"that role as `role`. A command that takes `run` takes a key you choose, and the call answers " \
|
|
324
|
+
"the record as it stands once its reactions have run; an argument marked * is required."
|
|
325
|
+
entries = Array(guide).empty? ? allowed_commands : guide
|
|
326
|
+
([intro] + entries.map { |entry| "- #{entry}" }).join("\n")
|
|
327
|
+
end
|
|
328
|
+
|
|
290
329
|
def with_enum(property)
|
|
291
330
|
property.merge(enum: allowed_commands)
|
|
292
331
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../cache_dir"
|
|
5
|
+
require_relative "../storehouse"
|
|
6
|
+
require_relative "../bluebook/meta_validator/verdict_cache"
|
|
7
|
+
|
|
8
|
+
module Hecks
|
|
9
|
+
module Doors
|
|
10
|
+
# Remembers the command guide a commands door builds for `tools/list`, so a door that starts
|
|
11
|
+
# again does not boot its domain to describe its commands. A client waits only a moment for a
|
|
12
|
+
# server's tools, and a boot at that point costs more than the wait.
|
|
13
|
+
#
|
|
14
|
+
# An entry is keyed by the domain directory's files, a digest of the hecks code, and the
|
|
15
|
+
# allowed commands, so a change to any of them builds a new guide. The file is JSON, read only
|
|
16
|
+
# from a directory and a file that only this user can write (the verdict cache's own check),
|
|
17
|
+
# and only when it is a list of strings: a guide is text an agent reads as instructions.
|
|
18
|
+
module McpGuideCache
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# The guide for a domain and its allowed commands, from the cache or else from the block,
|
|
22
|
+
# which is then remembered. A cache that cannot be written is not an error: the next start
|
|
23
|
+
# builds the guide again.
|
|
24
|
+
#
|
|
25
|
+
# @param domain [String] the domain directory the guide describes
|
|
26
|
+
# @param commands [Array<String>] the allowed command names
|
|
27
|
+
# @yieldreturn [Array<String>, nil] the guide, built when none is remembered
|
|
28
|
+
# @return [Array<String>, nil] the guide
|
|
29
|
+
def remember(domain, commands)
|
|
30
|
+
file = File.join(dir, "#{key(domain, commands)}.json")
|
|
31
|
+
cached = read(file)
|
|
32
|
+
return cached if cached
|
|
33
|
+
|
|
34
|
+
guide = yield
|
|
35
|
+
write(file, guide) if guide.is_a?(Array) && guide.any?
|
|
36
|
+
guide
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [String] the directory entries live in
|
|
40
|
+
def dir = Hecks::CacheDir.path("mcp_door_guides")
|
|
41
|
+
|
|
42
|
+
# @param domain [String] the domain directory
|
|
43
|
+
# @param commands [Array<String>] the allowed command names
|
|
44
|
+
# @return [String] a hex SHA-256 over the domain's files, the hecks code and the commands
|
|
45
|
+
def key(domain, commands)
|
|
46
|
+
parts = [Storehouse.fingerprint(domain), Bluebook::MetaValidator::VerdictCache.code_digest,
|
|
47
|
+
commands.sort.join(",")]
|
|
48
|
+
Digest::SHA256.hexdigest(parts.join("\0"))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param file [String] an entry's path
|
|
52
|
+
# @return [Array<String>, nil] the guide, or nil when absent, untrusted or malformed
|
|
53
|
+
def read(file)
|
|
54
|
+
return unless File.file?(file) && Bluebook::MetaValidator::VerdictCache.trusted?(file)
|
|
55
|
+
|
|
56
|
+
guide = JSON.parse(File.read(file))
|
|
57
|
+
guide if guide.is_a?(Array) && guide.all?(String)
|
|
58
|
+
rescue StandardError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param file [String] an entry's path
|
|
63
|
+
# @param guide [Array<String>] the guide to remember
|
|
64
|
+
# @return [void]
|
|
65
|
+
def write(file, guide)
|
|
66
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
67
|
+
temp = "#{file}.#{Process.pid}.tmp"
|
|
68
|
+
File.write(temp, JSON.generate(guide), perm: 0o600)
|
|
69
|
+
File.rename(temp, file)
|
|
70
|
+
rescue StandardError
|
|
71
|
+
FileUtils.rm_f(temp) if temp
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -118,8 +118,9 @@ module Hecks
|
|
|
118
118
|
|
|
119
119
|
owner = owner_for_verb(bluebooks, entry[:verb]) || aggregate
|
|
120
120
|
|
|
121
|
+
args = with_declared_defaults(command, entry[:args])
|
|
121
122
|
command.mutations.select { |m| RECOMPUTABLE_MUTATION_OPS.include?(m.op) }.filter_map do |mutation|
|
|
122
|
-
expected = recompute_mutation(mutation, entry[:before][mutation.target],
|
|
123
|
+
expected = recompute_mutation(mutation, entry[:before][mutation.target], args, entry[:before],
|
|
123
124
|
aggregate, command, owner)
|
|
124
125
|
next if expected == :unrecomputable
|
|
125
126
|
|
|
@@ -134,6 +135,21 @@ module Hecks
|
|
|
134
135
|
offenders.empty? || offenders.join("; ")
|
|
135
136
|
end
|
|
136
137
|
|
|
138
|
+
# The arguments the command actually ran with: an argument the caller left out takes the
|
|
139
|
+
# default its attribute declares, as dispatch fills it, so the recomputation starts from
|
|
140
|
+
# the same facts.
|
|
141
|
+
#
|
|
142
|
+
# @param command [Object] the command the step dispatched
|
|
143
|
+
# @param args [Hash] the arguments the step offered
|
|
144
|
+
# @return [Hash] `args`, with each omitted defaulted argument filled
|
|
145
|
+
def with_declared_defaults(command, args)
|
|
146
|
+
command.attributes.each_with_object(args.dup) do |attribute, held|
|
|
147
|
+
next if attribute.default.nil? || held.key?(attribute.name.to_sym) || held.key?(attribute.name.to_s)
|
|
148
|
+
|
|
149
|
+
held[attribute.name.to_sym] = attribute.default
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
137
153
|
# Root aggregate for `verb`, derived from the verb alone because hand-built
|
|
138
154
|
# entries may lack `:domain`/`:aggregate`. Value-object types resolve against
|
|
139
155
|
# the root's namespace only.
|
|
@@ -38,7 +38,9 @@ module Hecks
|
|
|
38
38
|
status = Dir.chdir(@tree.root) { runner.call(["--example", example, path], out, out) }
|
|
39
39
|
raise ConsoleCapture::Failure, out.string.strip unless status.zero?
|
|
40
40
|
|
|
41
|
-
out.string.strip
|
|
41
|
+
out.string.strip.tap do |report|
|
|
42
|
+
raise ConsoleCapture::Failure, "the test runner printed nothing for #{file}" if report.empty?
|
|
43
|
+
end
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
private
|
|
@@ -47,7 +49,13 @@ module Hecks
|
|
|
47
49
|
return self.class.runner if self.class.runner
|
|
48
50
|
|
|
49
51
|
require "rspec/core"
|
|
50
|
-
|
|
52
|
+
# A process that runs one example after another, as a resident door does, has to reset
|
|
53
|
+
# RSpec between runs: its reporter keeps the first run's output stream, so a later run
|
|
54
|
+
# would write into that and answer an empty report.
|
|
55
|
+
lambda do |args, err, out|
|
|
56
|
+
RSpec.reset
|
|
57
|
+
RSpec::Core::Runner.run(args, err, out)
|
|
58
|
+
end
|
|
51
59
|
end
|
|
52
60
|
end
|
|
53
61
|
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Required by name, not relative to this file: a fuzz or model check boots a copy of this directory,
|
|
2
|
+
# and the implementation must load once, from `lib/`, not from the copy.
|
|
3
|
+
require "hecks/hecks/adapters/cost_explorer"
|
|
4
|
+
|
|
5
|
+
Hecks.adapter "CostExplorer" do
|
|
6
|
+
port "CostExplorer"
|
|
7
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "json"
|
|
5
|
+
require "date"
|
|
6
|
+
|
|
7
|
+
module Hecks
|
|
8
|
+
module Adapters
|
|
9
|
+
# The `CostExplorer` port's adapter: reads what an AWS account has billed, with the `aws`
|
|
10
|
+
# command, and compares the monthly rate to a budget.
|
|
11
|
+
#
|
|
12
|
+
# It reads the daily unblended cost from the check's `since` day up to yesterday. Today is left
|
|
13
|
+
# out because its bill is still partial, and a day is counted whatever Cost Explorer marks it
|
|
14
|
+
# (a day stays "estimated" for a while after it ends). The rate is the mean daily cost scaled
|
|
15
|
+
# to an average month. A rate within the budget is answered with a one-line report; a rate over
|
|
16
|
+
# it, a span with no complete day, or a failed `aws` call is refused with a reason, which the
|
|
17
|
+
# runtime records on the check.
|
|
18
|
+
class CostExplorer
|
|
19
|
+
# Days in an average month, for scaling a daily mean to a monthly rate.
|
|
20
|
+
DAYS_IN_MONTH = 30.4375
|
|
21
|
+
|
|
22
|
+
# How many of the biggest services the report names.
|
|
23
|
+
TOP_SERVICES = 3
|
|
24
|
+
|
|
25
|
+
# @param aggregate [Object, nil] unused
|
|
26
|
+
# @param settings [Hash] `today:` pins the day the span ends before, for tests
|
|
27
|
+
# @param root [String, nil] unused
|
|
28
|
+
def initialize(aggregate: nil, settings: {}, root: nil)
|
|
29
|
+
@today = settings[:today]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Reads the billed cost since a day and compares its monthly rate to the budget.
|
|
33
|
+
#
|
|
34
|
+
# @param held [Hash] the check's fields: `budget:` whole dollars a month and `since:` the
|
|
35
|
+
# first day to count (`YYYY-MM-DD`), each a value object or a plain value
|
|
36
|
+
# @return [Hash{Symbol => Hash}] `report:` the rate against the budget, the shape
|
|
37
|
+
# `CostCheck.Pass` takes
|
|
38
|
+
# @raise [RuntimeError] when no complete day has passed since `since`, `aws` fails, or the
|
|
39
|
+
# rate is over the budget
|
|
40
|
+
def measure(**held)
|
|
41
|
+
budget = Integer(plain(held[:budget]))
|
|
42
|
+
first = Date.iso8601(plain(held[:since]).to_s)
|
|
43
|
+
last = today
|
|
44
|
+
raise "no complete day since #{first}; today (#{last}) is still being billed" if first >= last
|
|
45
|
+
|
|
46
|
+
days = daily_costs(first, last)
|
|
47
|
+
line = report_line(first, last, days, budget)
|
|
48
|
+
raise "over budget: #{line}" if monthly_rate(days) > budget
|
|
49
|
+
|
|
50
|
+
{ report: { value: line } }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# @return [Date] the day the span ends before: today, in UTC
|
|
56
|
+
def today = @today ? Date.iso8601(@today.to_s) : Time.now.utc.to_date
|
|
57
|
+
|
|
58
|
+
# @return [Hash{String => Hash{String => Float}}] day => service => dollars
|
|
59
|
+
def daily_costs(first, last)
|
|
60
|
+
out, err, status = Open3.capture3(
|
|
61
|
+
"aws", "ce", "get-cost-and-usage", "--time-period", "Start=#{first},End=#{last}",
|
|
62
|
+
"--granularity", "DAILY", "--metrics", "UnblendedCost",
|
|
63
|
+
"--group-by", "Type=DIMENSION,Key=SERVICE", "--output", "json"
|
|
64
|
+
)
|
|
65
|
+
raise "aws ce get-cost-and-usage failed: #{err.strip.empty? ? out.strip : err.strip}" unless status.success?
|
|
66
|
+
|
|
67
|
+
rows = JSON.parse(out).fetch("ResultsByTime")
|
|
68
|
+
raise "Cost Explorer returned no days for #{first}..#{last}" if rows.empty?
|
|
69
|
+
|
|
70
|
+
rows.to_h { |row| [row.dig("TimePeriod", "Start"), by_service(row)] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def by_service(row)
|
|
74
|
+
row.fetch("Groups").to_h do |group|
|
|
75
|
+
[group.fetch("Keys").first, group.dig("Metrics", "UnblendedCost", "Amount").to_f]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Float] the mean daily total, scaled to an average month
|
|
80
|
+
def monthly_rate(days)
|
|
81
|
+
mean = days.values.sum { |services| services.values.sum } / days.size
|
|
82
|
+
mean * DAYS_IN_MONTH
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def report_line(first, last, days, budget)
|
|
86
|
+
mean = days.values.sum { |services| services.values.sum } / days.size
|
|
87
|
+
biggest = totals_by_service(days).max_by(TOP_SERVICES) { |_name, dollars| dollars }
|
|
88
|
+
named = biggest.map { |name, dollars| "#{name} $#{format('%.2f', dollars / days.size)}/day" }.join(", ")
|
|
89
|
+
"#{first}..#{last - 1} (#{days.size} days): $#{format('%.2f', mean)}/day, " \
|
|
90
|
+
"$#{format('%.2f', monthly_rate(days))}/month against $#{budget}; biggest: #{named}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def totals_by_service(days)
|
|
94
|
+
days.values.each_with_object(Hash.new(0.0)) do |services, totals|
|
|
95
|
+
services.each { |name, dollars| totals[name] += dollars }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def plain(argument) = argument.is_a?(Hash) ? argument[:value] : argument
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -22,7 +22,7 @@ module Hecks
|
|
|
22
22
|
CHECKOUT_MARKER = "hecks.gemspec"
|
|
23
23
|
|
|
24
24
|
# What a copy holds: the workspace manifest, the kernel and every crate a domain build uses.
|
|
25
|
-
PACKAGED = %w[Cargo.toml Cargo.lock
|
|
25
|
+
PACKAGED = %w[Cargo.toml Cargo.lock src codegen parser
|
|
26
26
|
host build web lsp].freeze
|
|
27
27
|
|
|
28
28
|
# Left out of a copy: build output anywhere, the corpus tests, and the corpus domains.
|
|
@@ -9,13 +9,16 @@ module Hecks
|
|
|
9
9
|
# The `SiteToolchain` port's adapter: projects a site's route table into its `routes.ts`, or
|
|
10
10
|
# checks the file on disk is current.
|
|
11
11
|
#
|
|
12
|
-
# The projection runs the `project_site` tool in
|
|
13
|
-
#
|
|
14
|
-
#
|
|
12
|
+
# The projection runs the `project_site` tool in this process, with its printing and exit status
|
|
13
|
+
# captured: a tool that ends non-zero is a refusal whose reason is what it printed. The tool
|
|
14
|
+
# ships in the gem and reads only the project it is given, so it needs no hecks checkout.
|
|
15
15
|
class SiteToolchain
|
|
16
16
|
# The `Hecks::Tools` tool the ask runs, by ask.
|
|
17
17
|
SCRIPTS = { project: "project_site" }.freeze
|
|
18
18
|
|
|
19
|
+
# The tool's flag for each `SiteProjection` field that takes a value.
|
|
20
|
+
FLAGS = { "out" => :out, "template" => :template, "extension" => :extension }.freeze
|
|
21
|
+
|
|
19
22
|
# Accepts the arguments every driven adapter is built with and keeps none of them.
|
|
20
23
|
#
|
|
21
24
|
# @param aggregate [Object, nil] unused
|
|
@@ -25,17 +28,17 @@ module Hecks
|
|
|
25
28
|
|
|
26
29
|
# Writes a project's `routes.ts` from its declared route table, or with `check` only compares.
|
|
27
30
|
#
|
|
28
|
-
# @param held [Hash] the `SiteProjection` record: `domain`, and `out`
|
|
31
|
+
# @param held [Hash] the `SiteProjection` record: `domain`, and `out`, `template`, `extension`
|
|
32
|
+
# and `check` when set
|
|
29
33
|
# @return [Hash{Symbol => Hash}] `output:` one line per file written or found current
|
|
30
|
-
# @raise [ConsoleCapture::Failure] when the
|
|
31
|
-
#
|
|
34
|
+
# @raise [ConsoleCapture::Failure] when the route table is refused (an unknown cache class,
|
|
35
|
+
# a duplicate path), a path or the extension is refused, or under `check` a file is out
|
|
36
|
+
# of date
|
|
32
37
|
def project(**held)
|
|
33
|
-
flags = []
|
|
34
|
-
flags << "--out=#{plain(held[:out])}" unless plain(held[:out]).nil?
|
|
38
|
+
flags = FLAGS.filter_map { |flag, key| "--#{flag}=#{located(key, held[key])}" unless plain(held[key]).nil? }
|
|
35
39
|
flags << "--check" if plain(held[:check]) == true
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
result = Codebase::RubyChild.new(tree).capture(SCRIPTS.fetch(:project), *flags, plain(held[:domain]))
|
|
40
|
+
result = Codebase::RubyChild.new(Codebase::Tree.new).capture(SCRIPTS.fetch(:project), *flags,
|
|
41
|
+
located(:domain, held[:domain]))
|
|
39
42
|
raise ConsoleCapture::Failure, message_of(result) unless result.ok?
|
|
40
43
|
|
|
41
44
|
{ output: { value: result.out } }
|
|
@@ -48,6 +51,13 @@ module Hecks
|
|
|
48
51
|
text.empty? ? "the tool ended with status #{result.status.exitstatus}" : text
|
|
49
52
|
end
|
|
50
53
|
|
|
54
|
+
# The tool runs from the tool's own root, so a path the caller gave relative to where they
|
|
55
|
+
# stand is made absolute first; the extension is not a path.
|
|
56
|
+
def located(key, argument)
|
|
57
|
+
value = plain(argument)
|
|
58
|
+
value.nil? || key == :extension ? value : File.expand_path(value)
|
|
59
|
+
end
|
|
60
|
+
|
|
51
61
|
def plain(argument) = argument.is_a?(Hash) ? argument[:value] : argument
|
|
52
62
|
end
|
|
53
63
|
end
|