hecks 3.1.2 → 3.2.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/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +36 -0
- data/examples/pizzas/bluebook/pizzas.behaviors +70 -0
- data/examples/pizzas/bluebook/pizzas.bluebook +138 -0
- data/examples/pizzas/bluebook/pizzas.hecksagon +21 -0
- data/examples/pizzas/bluebook/pizzas.world +3 -0
- data/examples/pizzas/bluebook/translations/2-77625c.bluebook +6 -0
- data/examples/pizzas/oidc.json +24 -0
- data/examples/pizzas/pizzas_behaviors.hecksagon +19 -0
- data/lib/hecks/cli/console.rb +5 -2
- 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/cli_runner.rb +6 -0
- data/lib/hecks/hecks/adapters/cost_explorer.adapter +7 -0
- data/lib/hecks/hecks/adapters/cost_explorer.rb +102 -0
- data/lib/hecks/hecks/hecks.hecksagon +8 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
- data/lib/hecks/projector/cli_projector.rb +51 -22
- data/lib/hecks/quality_control/adapters/agent.rb +63 -7
- data/lib/hecks/quality_control/adapters/agent_profile.rb +165 -0
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +23 -9
- data/lib/hecks/tools/deploy_recipe.rb +1 -0
- data/lib/hecks/version.rb +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/auth.rs +25 -1
- data/rust/host/src/web.rs +9 -5
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c79ef6cb53e23b9faa0f452f116af208c659512f00716e47c7f3cdaad3d51e86
|
|
4
|
+
data.tar.gz: b693ea21f847607ca2964759f9e0cec9dc7c4f8e1ea341b6636e99f8b4b1d294
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 38d1467d95484bcf093d6671982bf5e4edc4fc1be8907d849a88df9b85ab2b9810f88ce9533a9d1d125187fd47c26191fbe3e104e90307ee6fb19b3cffa37546
|
|
7
|
+
data.tar.gz: 329631cf0d425b281c4a4770318d2956ae95ca7d2e0b12e2e0054bd2f0d72a3310a92d1ce2fa125d5a056c76026c27472230a0710335790267db173ada8e48db
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# Driving adapter standing in for a Stripe webhook handler: boots Pizzas and calls
|
|
4
|
+
# Dispatcher#dispatch_port, which translates the payment into PizzaPaymentReceived.
|
|
5
|
+
#
|
|
6
|
+
# examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb
|
|
7
|
+
|
|
8
|
+
$LOAD_PATH.unshift File.expand_path("../../../../lib", __dir__)
|
|
9
|
+
|
|
10
|
+
require "hecks"
|
|
11
|
+
|
|
12
|
+
DOMAIN = File.expand_path("../..", __dir__)
|
|
13
|
+
RUNTIME = Hecks.boot(DOMAIN)
|
|
14
|
+
|
|
15
|
+
# Unique per run: identity is never minted, so a repeated name would be refused as AlreadyExists.
|
|
16
|
+
NAME = "StripeDemoMargherita-#{Process.pid}-#{rand(10_000)}".freeze
|
|
17
|
+
|
|
18
|
+
# Set the stage: an order Purchase's `given` rules will accept.
|
|
19
|
+
order = Order.create_pizza(name: { value: NAME }, pizza: { price_cents: { cents: 1200 }, size: { value: "large" } })
|
|
20
|
+
order.add_topping(topping: { value: "Basil" }, amount: { value: 3 })
|
|
21
|
+
|
|
22
|
+
puts "Before payment: #{order.status}, customer=#{order.customer_name.inspect}"
|
|
23
|
+
|
|
24
|
+
# The webhook: a real handler would verify a Stripe signature; a reference is always a bare id.
|
|
25
|
+
RUNTIME.dispatch_port(
|
|
26
|
+
"Pizzas", "Order", "PaymentGateway", "Receive",
|
|
27
|
+
flat: {
|
|
28
|
+
name: NAME,
|
|
29
|
+
customer_name: { value: "Chris" },
|
|
30
|
+
amount: { cents: 1200 }
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
sold = Order.find(NAME)
|
|
35
|
+
puts "After payment: #{sold.status}, customer=#{sold.customer_name.to_h}"
|
|
36
|
+
puts "Events: #{sold.events.map(&:name).join(', ')}"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Hecks.behaviors "Pizzas" do
|
|
2
|
+
vision "Examples of how a pizza is built, sold, and found through this domain."
|
|
3
|
+
|
|
4
|
+
# `pizzas_behaviors.hecksagon` is a Memory-persisted sibling of
|
|
5
|
+
# `pizzas.hecksagon` that lives OUTSIDE bluebook/, so an ordinary
|
|
6
|
+
# directory boot of examples/pizzas never globs it up alongside the
|
|
7
|
+
# real one.
|
|
8
|
+
loads "pizzas.bluebook", "../pizzas_behaviors.hecksagon"
|
|
9
|
+
|
|
10
|
+
test "CreatePizza puts a fresh pizza on the menu, available for sale" do
|
|
11
|
+
tests "CreatePizza", on: "Order"
|
|
12
|
+
input name: "Margherita", pizza: { price_cents: { cents: 1200 }, size: "large" }
|
|
13
|
+
expect status: "available", customer_name: nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
test "AddTopping refuses more than ten toppings" do
|
|
17
|
+
tests "AddTopping", on: "Order"
|
|
18
|
+
setup "CreatePizza", name: "Loaded", pizza: { price_cents: { cents: 1500 }, size: "large" }
|
|
19
|
+
setup "AddTopping", name: "Loaded", topping: "Basil", amount: 1
|
|
20
|
+
setup "AddTopping", name: "Loaded", topping: "Olives", amount: 1
|
|
21
|
+
setup "AddTopping", name: "Loaded", topping: "Mushroom", amount: 1
|
|
22
|
+
setup "AddTopping", name: "Loaded", topping: "Onion", amount: 1
|
|
23
|
+
setup "AddTopping", name: "Loaded", topping: "Pepper", amount: 1
|
|
24
|
+
setup "AddTopping", name: "Loaded", topping: "Sausage", amount: 1
|
|
25
|
+
setup "AddTopping", name: "Loaded", topping: "Bacon", amount: 1
|
|
26
|
+
setup "AddTopping", name: "Loaded", topping: "Pineapple", amount: 1
|
|
27
|
+
setup "AddTopping", name: "Loaded", topping: "Anchovy", amount: 1
|
|
28
|
+
setup "AddTopping", name: "Loaded", topping: "Jalapeno", amount: 1
|
|
29
|
+
input name: "Loaded", topping: "Extra Cheese", amount: 1
|
|
30
|
+
expect refused: "at most 10 toppings"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
test "Purchase refuses a pizza with no toppings on it" do
|
|
34
|
+
tests "Purchase", on: "Order"
|
|
35
|
+
setup "CreatePizza", name: "Bare", pizza: { price_cents: { cents: 900 }, size: "small" }
|
|
36
|
+
input name: "Bare", customer_name: "Chris", amount: { cents: 900 }
|
|
37
|
+
expect refused: "at least one topping"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
test "Purchase sells the pizza and closes its lifecycle" do
|
|
41
|
+
tests "Purchase", on: "Order"
|
|
42
|
+
setup "CreatePizza", name: "Sold Out", pizza: { price_cents: { cents: 1200 }, size: "large" }
|
|
43
|
+
setup "AddTopping", name: "Sold Out", topping: "Basil", amount: 2
|
|
44
|
+
input name: "Sold Out", customer_name: "Chris", amount: { cents: 1200 }
|
|
45
|
+
expect emits: ["PizzaPurchased"]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
test "a sold pizza is no longer Available" do
|
|
49
|
+
tests "Available", on: "Order", kind: :query
|
|
50
|
+
setup "CreatePizza", name: "Fresh", pizza: { price_cents: { cents: 1000 }, size: "small" }
|
|
51
|
+
setup "CreatePizza", name: "Also Sold", pizza: { price_cents: { cents: 1100 }, size: "small" }
|
|
52
|
+
setup "AddTopping", name: "Also Sold", topping: "Basil", amount: 1
|
|
53
|
+
setup "Purchase", name: "Also Sold", customer_name: "Chris", amount: { cents: 1100 }
|
|
54
|
+
expect count: 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
test "CostingLessThan finds a pizza under the ceiling named" do
|
|
58
|
+
tests "CostingLessThan", on: "Order", kind: :query
|
|
59
|
+
setup "CreatePizza", name: "Bargain", pizza: { price_cents: { cents: 800 }, size: "small" }
|
|
60
|
+
setup "CreatePizza", name: "Splurge", pizza: { price_cents: { cents: 1800 }, size: "large" }
|
|
61
|
+
input ceiling: { cents: 1000 }
|
|
62
|
+
expect count: 1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
test "Available's own row can be checked directly, not just counted" do
|
|
66
|
+
tests "Available", on: "Order", kind: :query
|
|
67
|
+
setup "CreatePizza", name: "Only One", pizza: { price_cents: { cents: 950 }, size: "small" }
|
|
68
|
+
expect status: "available", name: { value: "Only One" }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Hecks.bluebook "Pizzas" do
|
|
2
|
+
vision "Put toppings on a pizza and sell it to a customer."
|
|
3
|
+
supporting
|
|
4
|
+
|
|
5
|
+
aggregate "Order" do
|
|
6
|
+
description "An order that gathers toppings on a pizza and is eventually sold to a customer."
|
|
7
|
+
|
|
8
|
+
attribute :name, PizzaName
|
|
9
|
+
attribute :pizza, Pizza
|
|
10
|
+
attribute :toppings, list_of(Topping)
|
|
11
|
+
# Optional: unknown until `Purchase` sets it.
|
|
12
|
+
attribute :customer_name, CustomerName, optional: true
|
|
13
|
+
|
|
14
|
+
identified_by :name
|
|
15
|
+
|
|
16
|
+
value_object "PizzaName" do
|
|
17
|
+
attribute :value, String, pattern: '[^ \t\n\r]'
|
|
18
|
+
|
|
19
|
+
invariant("a pizza is named") { !value.to_s.empty? }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
value_object "Price" do
|
|
23
|
+
attribute :cents, Integer
|
|
24
|
+
|
|
25
|
+
invariant("a price is never negative") { cents >= 0 }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
value_object "CustomerName" do
|
|
29
|
+
attribute :value, String, pattern: '[^ \t\n\r]'
|
|
30
|
+
|
|
31
|
+
invariant("a customer is named") { !value.to_s.empty? }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
value_object "ToppingName" do
|
|
35
|
+
attribute :value, String, pattern: '[^ \t\n\r]'
|
|
36
|
+
|
|
37
|
+
invariant("a topping is named") { !value.to_s.empty? }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
value_object "ToppingAmount" do
|
|
41
|
+
attribute :value, Integer
|
|
42
|
+
|
|
43
|
+
invariant("an amount is positive") { value.positive? }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
value_object "Topping" do
|
|
47
|
+
attribute :name, String, pattern: '[^ \t\n\r]'
|
|
48
|
+
attribute :amount, Integer
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A named, reusable closed set as a sibling value object; the inline `one_of(...)` shorthand
|
|
52
|
+
# cannot be spelled inside a `value_object` block.
|
|
53
|
+
value_object "Size" do
|
|
54
|
+
attribute :value, String, one_of: ["small", "large"]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# What was ordered; who ordered it and payment state live on Order.
|
|
58
|
+
value_object "Pizza" do
|
|
59
|
+
attribute :price_cents, Price
|
|
60
|
+
attribute :size, Size
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
lifecycle :status, default: "available" do
|
|
64
|
+
transition "Purchase" => "sold", from: "available"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
query "Available" do
|
|
68
|
+
where(status: "available")
|
|
69
|
+
order_by :name
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Both price queries reach through the nested Pizza value object with a dotted path.
|
|
73
|
+
query "CostingLessThan" do
|
|
74
|
+
attribute :ceiling, Price
|
|
75
|
+
where(:"pizza.price_cents.cents" => { lt: :ceiling })
|
|
76
|
+
order_by :name
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A literal, not a kwarg: exercises the path where the where-clause number is stringified.
|
|
80
|
+
query "Expensive" do
|
|
81
|
+
where(:"pizza.price_cents.cents" => { gt: 1000 })
|
|
82
|
+
order_by :name
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
command "CreatePizza" do
|
|
86
|
+
role "Chef"
|
|
87
|
+
goal "Put a new pizza on the menu"
|
|
88
|
+
|
|
89
|
+
attribute :name, PizzaName
|
|
90
|
+
attribute :pizza, Pizza
|
|
91
|
+
|
|
92
|
+
sets :name
|
|
93
|
+
sets :pizza
|
|
94
|
+
|
|
95
|
+
emits PizzaCreated
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# AddTopping has no transition, so `from:` is its only lifecycle guard.
|
|
99
|
+
command "AddTopping", from: "available" do
|
|
100
|
+
role "Chef"
|
|
101
|
+
goal "Customize a pizza with an ingredient"
|
|
102
|
+
|
|
103
|
+
reference_to Order
|
|
104
|
+
attribute :topping, ToppingName
|
|
105
|
+
attribute :amount, ToppingAmount
|
|
106
|
+
|
|
107
|
+
given("at most 10 toppings") { toppings.size < 10 }
|
|
108
|
+
|
|
109
|
+
sets :toppings, append: { name: :topping, amount: :amount }
|
|
110
|
+
|
|
111
|
+
emits ToppingAdded
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
command "Purchase", from: "available" do
|
|
115
|
+
role "Customer"
|
|
116
|
+
goal "Buy the pizza"
|
|
117
|
+
|
|
118
|
+
reference_to Order
|
|
119
|
+
attribute :amount, Price
|
|
120
|
+
|
|
121
|
+
given("a pizza needs at least one topping") { toppings.size.positive? }
|
|
122
|
+
given("a payment was actually made") { amount.cents.positive? }
|
|
123
|
+
# Checks the payment itself is plausible (e.g. an underpayment), not whether one happened.
|
|
124
|
+
given("the payment covers the price") { amount.cents >= pizza.price_cents.cents }
|
|
125
|
+
|
|
126
|
+
# The lifecycle transition sets status; a `sets` on the lifecycle field is refused at build.
|
|
127
|
+
sets :customer_name
|
|
128
|
+
|
|
129
|
+
emits PizzaPurchased
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# A payment arriving is an external fact; the business rules stay on Purchase.
|
|
134
|
+
policy "OnPizzaPaymentReceived" do
|
|
135
|
+
on Order::PizzaPaymentReceived
|
|
136
|
+
trigger Order::Purchase
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Hecks.hecksagon "Pizzas" do
|
|
2
|
+
# Role declarations only enforce access once Governance can check them; this standalone
|
|
3
|
+
# deploy attaches and binds Governance itself.
|
|
4
|
+
attaches "Governance"
|
|
5
|
+
Pizzas::Order.persisted_by("Memory")
|
|
6
|
+
|
|
7
|
+
# The primary port: an outside adapter (a payment webhook) translates an external fact into
|
|
8
|
+
# our vocabulary; business rules stay on Purchase, reached via OnPizzaPaymentReceived.
|
|
9
|
+
Pizzas::Order.port "PaymentGateway" do
|
|
10
|
+
operation "Receive" do
|
|
11
|
+
attribute :customer_name, CustomerName
|
|
12
|
+
attribute :amount, Price
|
|
13
|
+
emits "PizzaPaymentReceived"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Hecks.hecksagon "Governance" do
|
|
19
|
+
Governance::RoleAssignment.persisted_by("Memory")
|
|
20
|
+
Governance::RoleTransition.persisted_by("Memory")
|
|
21
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"audience": "Pizzas",
|
|
3
|
+
"scopes": [
|
|
4
|
+
{
|
|
5
|
+
"scope": "pizzas:order.add_topping",
|
|
6
|
+
"verb": "Pizzas::Order.AddTopping",
|
|
7
|
+
"role": "Chef"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"scope": "pizzas:order.create_pizza",
|
|
11
|
+
"verb": "Pizzas::Order.CreatePizza",
|
|
12
|
+
"role": "Chef"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"scope": "pizzas:order.purchase",
|
|
16
|
+
"verb": "Pizzas::Order.Purchase",
|
|
17
|
+
"role": "Customer"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"roles": [
|
|
21
|
+
"Chef",
|
|
22
|
+
"Customer"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Memory-bound sibling of pizzas.hecksagon: `pizzas.behaviors` boots a fresh runtime per
|
|
2
|
+
# test, so it loads this sibling instead of the one beside the bluebook.
|
|
3
|
+
Hecks.hecksagon "Pizzas" do
|
|
4
|
+
attaches "Governance"
|
|
5
|
+
Pizzas::Order.persisted_by("Memory")
|
|
6
|
+
|
|
7
|
+
Pizzas::Order.port "PaymentGateway" do
|
|
8
|
+
operation "Receive" do
|
|
9
|
+
attribute :customer_name, CustomerName
|
|
10
|
+
attribute :amount, Price
|
|
11
|
+
emits "PizzaPaymentReceived"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Hecks.hecksagon "Governance" do
|
|
17
|
+
Governance::RoleAssignment.persisted_by("Memory")
|
|
18
|
+
Governance::RoleTransition.persisted_by("Memory")
|
|
19
|
+
end
|
data/lib/hecks/cli/console.rb
CHANGED
|
@@ -47,12 +47,15 @@ module Hecks
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
# @param runtime [Runtime] a booted domain
|
|
50
|
-
# @return [String] each chapter's vision and its aggregates' commands
|
|
50
|
+
# @return [String] each chapter's vision, and its aggregates' descriptions and commands
|
|
51
51
|
def overview(runtime)
|
|
52
52
|
runtime.registry.bluebooks.each_value.map do |bluebook|
|
|
53
53
|
aggregates = bluebook.aggregates.map do |aggregate|
|
|
54
54
|
commands = aggregate.commands.map { |command| "#{Naming.snake(command.hecks_name)}!" }.sort.join(", ")
|
|
55
|
-
|
|
55
|
+
description = aggregate.description.to_s.strip
|
|
56
|
+
lines = [" #{aggregate.name}: #{commands}"]
|
|
57
|
+
lines << " #{description}" unless description.empty?
|
|
58
|
+
lines.join("\n")
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
"\n #{bluebook.name} — #{bluebook.vision}\n#{aggregates.join("\n")}\n"
|
|
@@ -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",
|
|
@@ -120,6 +120,7 @@ module Hecks
|
|
|
120
120
|
|
|
121
121
|
name = argv.first
|
|
122
122
|
return { answer: [cli[:usage], 0] } if name.nil? || %w[--help -h help].include?(name)
|
|
123
|
+
return { answer: [all_usage(bluebook, options), 0] } if name == "--all"
|
|
123
124
|
|
|
124
125
|
asking, name, argv = entry_word(cli, argv)
|
|
125
126
|
return { answer: [cli[:usage], 1] } if name.nil?
|
|
@@ -141,6 +142,11 @@ module Hecks
|
|
|
141
142
|
launcher: launcher }
|
|
142
143
|
end
|
|
143
144
|
|
|
145
|
+
# The usage with the internal commands and queries listed too, which `--all` asks for.
|
|
146
|
+
def all_usage(bluebook, options)
|
|
147
|
+
Projector.call(:cli, bluebook: bluebook, options: options.merge(all: true))[:usage]
|
|
148
|
+
end
|
|
149
|
+
|
|
144
150
|
# Reads the entry words of a line: whether it asks a query, the bare name, and the words
|
|
145
151
|
# from the name on.
|
|
146
152
|
#
|
|
@@ -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
|
|
@@ -368,6 +368,14 @@ Hecks.hecksagon "Hecks" do
|
|
|
368
368
|
end
|
|
369
369
|
end
|
|
370
370
|
|
|
371
|
+
Deploy::CostCheck.port "CostExplorer" do
|
|
372
|
+
asks "Measure", to: CostCheck do
|
|
373
|
+
attribute :run, RunKey
|
|
374
|
+
answers "CostAnswered"
|
|
375
|
+
refuses "CostRefused"
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
371
379
|
Deploy::TemplateComparison.port "DeployToolchain" do
|
|
372
380
|
asks "Compare", to: TemplateComparison do
|
|
373
381
|
attribute :run, RunKey
|
|
@@ -62,7 +62,7 @@ ROLL=$(jq -n --arg compose "$(B64 "$WORK/compose.json")" --arg secrets "$(B64 "$
|
|
|
62
62
|
"aws ecr get-login-password --region '"$REGION"' | docker login --username AWS --password-stdin \($registry) >/dev/null",
|
|
63
63
|
"docker compose -f compose.json pull --quiet",
|
|
64
64
|
"docker compose -f compose.json up -d --remove-orphans",
|
|
65
|
-
"sleep
|
|
65
|
+
"for i in $(seq 1 40); do docker compose -f compose.json ps --format \"{{.Status}}\" | grep -qE \"^Up (Less than a second|[0-4] seconds?)|Restarting|Created|Exited\" || break; sleep 1; done; docker compose -f compose.json ps --format \"table {{.Service}}\\t{{.Status}}\""],
|
|
66
66
|
executionTimeout: ["900"]}')
|
|
67
67
|
|
|
68
68
|
echo "==> rolling $BOX_STACK ($BOX) at $(date -u +%H:%M:%SZ)"
|
|
@@ -403,41 +403,70 @@ module Hecks
|
|
|
403
403
|
out = ["#{bluebook.name} — #{bluebook.vision}", "",
|
|
404
404
|
" #{program} <command>! [name=value …] do something",
|
|
405
405
|
" #{program} query <query> [name=value …] read something", ""]
|
|
406
|
-
|
|
407
|
-
out << "commands:"
|
|
408
|
-
out.concat(listing(commands) { |spec| spec[:summary] })
|
|
409
|
-
out << ""
|
|
410
|
-
out << "queries (nothing here changes anything):"
|
|
411
|
-
out.concat(listing(questions) { |spec| first_sentence(spec[:summary]) })
|
|
406
|
+
out.concat(tables(commands, questions, all: options[:all], notes: aggregate_notes(bluebook)))
|
|
412
407
|
out << ""
|
|
413
408
|
out << " #{program} <command> --help what one command wants, and every way it refuses"
|
|
409
|
+
out.concat(all_hint(program, commands, questions)) unless options[:all]
|
|
414
410
|
out << " a command is called with its aggregate — #{example_qualified(commands)}"
|
|
415
411
|
out.join("\n")
|
|
416
412
|
end
|
|
417
413
|
|
|
414
|
+
# The commands table then the queries table.
|
|
415
|
+
def tables(commands, questions, all:, notes:)
|
|
416
|
+
["commands:", *listing(commands, all: all, notes: notes) { |spec| spec[:summary] }, "",
|
|
417
|
+
"queries (nothing here changes anything):",
|
|
418
|
+
*listing(questions, all: all, notes: notes) { |spec| first_sentence(spec[:summary]) }]
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# The line saying the internal commands and queries were left out and how to list them,
|
|
422
|
+
# or no line when there are none.
|
|
423
|
+
def all_hint(program, commands, questions)
|
|
424
|
+
hidden = (commands.values + questions.values).count { |spec| spec[:internal] }
|
|
425
|
+
return [] if hidden.zero?
|
|
426
|
+
|
|
427
|
+
[" #{program} --all also list the #{hidden} internal commands and queries"]
|
|
428
|
+
end
|
|
429
|
+
|
|
418
430
|
# The command or question lines of the help. A domain with more than one aggregate is listed
|
|
419
431
|
# under a heading per aggregate, so related commands sit together; the heading is the prefix
|
|
420
432
|
# every call to them carries, so the lines under it leave it out. The bookkeeping a run
|
|
421
|
-
# records about itself (`internal`: system-role commands and port operations) is
|
|
422
|
-
#
|
|
423
|
-
# list, each name in full.
|
|
424
|
-
|
|
433
|
+
# records about itself (`internal`: system-role commands and port operations) is left out,
|
|
434
|
+
# since a person never types them; `all` lists them as names only. A single-aggregate
|
|
435
|
+
# domain keeps the plain list, each name in full.
|
|
436
|
+
#
|
|
437
|
+
# @param notes [Hash{String => String}] a line of prose per aggregate, shown under its heading
|
|
438
|
+
def listing(specs, all: false, notes: {}, &description)
|
|
425
439
|
shown, internal = specs.values.partition { |spec| !spec[:internal] }
|
|
426
|
-
|
|
427
|
-
grouped = groups.length > 1
|
|
440
|
+
grouped = shown.map { |spec| spec[:group] }.uniq.length > 1
|
|
428
441
|
named = shown.to_h { |spec| [spec, entry_name(spec, grouped)] }
|
|
442
|
+
lines = listed_rows(shown, named, grouped, notes, &description)
|
|
443
|
+
lines.concat(internal_lines(internal)) if all && !internal.empty?
|
|
444
|
+
lines
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# The rows of one table: one line per spec, under a heading per aggregate when grouped.
|
|
448
|
+
def listed_rows(shown, named, grouped, notes, &description)
|
|
429
449
|
width = named.values.map(&:length).max.to_i
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
end
|
|
436
|
-
else
|
|
437
|
-
shown.each { |spec| lines << " #{named[spec].ljust(width)} #{yield(spec)}#{alias_note(spec)}" }
|
|
450
|
+
row = ->(spec, indent) { "#{indent}#{named[spec].ljust(width)} #{description.call(spec)}#{alias_note(spec)}" }
|
|
451
|
+
return shown.map { |spec| row.call(spec, " ") } unless grouped
|
|
452
|
+
|
|
453
|
+
shown.group_by { |spec| spec[:group] }.flat_map do |group, members|
|
|
454
|
+
[*heading_lines(group, notes), *members.map { |spec| row.call(spec, " ") }]
|
|
438
455
|
end
|
|
439
|
-
|
|
440
|
-
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# The lines that open an aggregate's group: its heading, then its note when it has one.
|
|
459
|
+
def heading_lines(group, notes)
|
|
460
|
+
return [] unless group
|
|
461
|
+
|
|
462
|
+
[" #{heading(group)}", *notes[group]&.then { |note| " #{note}" }]
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# The first sentence of each aggregate's description, keyed by aggregate name; an aggregate
|
|
466
|
+
# with no description has no entry.
|
|
467
|
+
def aggregate_notes(bluebook)
|
|
468
|
+
bluebook.aggregates.to_h { |aggregate| [aggregate.hecks_name, first_sentence(aggregate.description)] }
|
|
469
|
+
.reject { |_, note| note.empty? }
|
|
441
470
|
end
|
|
442
471
|
|
|
443
472
|
# The aggregate's own name as a heading: the prefix of every call to the lines under it.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require "shellwords"
|
|
5
|
+
require_relative "agent_profile"
|
|
5
6
|
|
|
6
7
|
module Hecks
|
|
7
8
|
module Adapters
|
|
@@ -27,10 +28,11 @@ module Hecks
|
|
|
27
28
|
# The command an ask runs.
|
|
28
29
|
#
|
|
29
30
|
# @param command [String, Array<String>, nil] a command line, or its words
|
|
30
|
-
# @
|
|
31
|
-
|
|
31
|
+
# @param profile [AgentProfile, nil] when given, the default command takes its tools, budget
|
|
32
|
+
# @return [Array<String>] `command` as words; `QA_MINER_AGENT` else the default when nil
|
|
33
|
+
def command_for(command = nil, profile = nil)
|
|
32
34
|
command ||= ENV.fetch("QA_MINER_AGENT", nil)
|
|
33
|
-
return
|
|
35
|
+
return default_command(profile) unless command
|
|
34
36
|
|
|
35
37
|
command.is_a?(String) ? Shellwords.split(command) : command
|
|
36
38
|
end
|
|
@@ -41,11 +43,13 @@ module Hecks
|
|
|
41
43
|
# @param command [String, Array<String>, nil] the agent's command; see `command_for`
|
|
42
44
|
# @param chdir [String] the directory the agent works in
|
|
43
45
|
# @param log [String, nil] a file the agent's output is appended to
|
|
46
|
+
# @param profile [AgentProfile, nil] what the run may do; nil runs it as the caller could
|
|
44
47
|
# @return [String] the agent's output
|
|
45
|
-
# @raise [Failed] when the agent cannot start or exits non-zero
|
|
46
|
-
def ask(prompt:, chdir:, command: nil, log: nil)
|
|
47
|
-
words = command_for(command)
|
|
48
|
-
|
|
48
|
+
# @raise [Failed] when the agent cannot start, runs past its timeout or exits non-zero
|
|
49
|
+
def ask(prompt:, chdir:, command: nil, log: nil, profile: nil)
|
|
50
|
+
words = command_for(command, profile)
|
|
51
|
+
refuse_unconfinable!(words, profile)
|
|
52
|
+
output, status = run(words, prompt, chdir, profile)
|
|
49
53
|
File.open(log, "a") { |file| file.puts(output) } if log
|
|
50
54
|
return output if status.success?
|
|
51
55
|
|
|
@@ -53,6 +57,58 @@ module Hecks
|
|
|
53
57
|
rescue SystemCallError => e
|
|
54
58
|
raise Failed, "agent could not start (#{words.first}): #{e.message}"
|
|
55
59
|
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Permission rules are a feature of `claude`: any other command would run unconfined.
|
|
64
|
+
def refuse_unconfinable!(words, profile)
|
|
65
|
+
return if profile.nil? || profile.sandboxed? || words == default_command(profile)
|
|
66
|
+
|
|
67
|
+
raise Failed, "permission confinement applies only to the default claude command"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def run(words, prompt, chdir, profile)
|
|
71
|
+
return run_confined(words, prompt, chdir, profile) if profile
|
|
72
|
+
|
|
73
|
+
Open3.capture2e(*words, stdin_data: prompt, chdir: chdir)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def default_command(profile)
|
|
77
|
+
return DEFAULT_COMMAND unless profile
|
|
78
|
+
|
|
79
|
+
%w[claude -p --permission-mode] + [profile.permission_mode] + profile.tool_flags
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Runs `words` under the profile's sandbox with only the environment it names, in its own
|
|
83
|
+
# process group so a timeout takes the agent's children with it.
|
|
84
|
+
def run_confined(words, prompt, chdir, profile)
|
|
85
|
+
raise Failed, "no sandbox here; refusing to run an agent unconfined" unless profile.available?
|
|
86
|
+
|
|
87
|
+
Open3.popen2e(profile.environment, *profile.confine(words),
|
|
88
|
+
chdir: chdir, unsetenv_others: true, pgroup: true) do |stdin, out, waiter|
|
|
89
|
+
feed(stdin, prompt)
|
|
90
|
+
reader = Thread.new { out.read }
|
|
91
|
+
finish(waiter, reader, profile.timeout)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def feed(stdin, prompt)
|
|
96
|
+
stdin.write(prompt)
|
|
97
|
+
rescue Errno::EPIPE
|
|
98
|
+
nil
|
|
99
|
+
ensure
|
|
100
|
+
stdin.close
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def finish(waiter, reader, timeout)
|
|
104
|
+
unless waiter.join(timeout)
|
|
105
|
+
Process.kill("KILL", -waiter.pid)
|
|
106
|
+
waiter.join
|
|
107
|
+
reader.join
|
|
108
|
+
raise Failed, "agent timed out after #{timeout}s"
|
|
109
|
+
end
|
|
110
|
+
[reader.value, waiter.value]
|
|
111
|
+
end
|
|
56
112
|
end
|
|
57
113
|
end
|
|
58
114
|
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Adapters
|
|
7
|
+
# What one `Agent` run may do: which tools it holds, where it may write, what it may reach on
|
|
8
|
+
# the network, which environment variables it sees and how long it may run.
|
|
9
|
+
#
|
|
10
|
+
# Confinement uses the macOS sandbox. Where there is none, a confined run refuses to start
|
|
11
|
+
# rather than run unconfined.
|
|
12
|
+
class AgentProfile
|
|
13
|
+
# What the network rule lets through: nothing, outbound HTTPS and name lookups, or anything.
|
|
14
|
+
NETWORKS = %i[none https any].freeze
|
|
15
|
+
|
|
16
|
+
# How a run is confined: under the operating system's sandbox, or by the permission rules
|
|
17
|
+
# of the `claude` command itself. Only the second leaves the user's own login usable, since
|
|
18
|
+
# the sandbox refuses the keychain and `~/.claude`.
|
|
19
|
+
CONFINEMENTS = %i[sandbox permissions].freeze
|
|
20
|
+
|
|
21
|
+
# The variables every confined agent sees besides the ones its profile names.
|
|
22
|
+
BASE_ENV = %w[PATH HOME USER LOGNAME SHELL LANG LC_ALL TERM TMPDIR].freeze
|
|
23
|
+
|
|
24
|
+
# The tools that write files; a permissions-confined run holds them only through path rules.
|
|
25
|
+
WRITING_TOOLS = %w[Write Edit MultiEdit NotebookEdit].freeze
|
|
26
|
+
|
|
27
|
+
# Home-relative places an agent may not read: credentials and the harness's own state.
|
|
28
|
+
CREDENTIAL_PATHS = %w[
|
|
29
|
+
.ssh .aws .gnupg .claude .config/gh .config/op .gem/credentials .netrc Library/Keychains
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
SANDBOX = "/usr/bin/sandbox-exec"
|
|
33
|
+
|
|
34
|
+
# @return [Array<String>] the tools the agent is given
|
|
35
|
+
attr_reader :tools
|
|
36
|
+
# @return [Array<String>] the directories the agent may write, besides the temp directory
|
|
37
|
+
attr_reader :writable
|
|
38
|
+
# @return [Symbol] one of `NETWORKS`
|
|
39
|
+
attr_reader :network
|
|
40
|
+
# @return [Array<String>] the environment variable names the agent may see besides `BASE_ENV`
|
|
41
|
+
attr_reader :env
|
|
42
|
+
# @return [Integer, nil] seconds the run may take; nil for no limit
|
|
43
|
+
attr_reader :timeout
|
|
44
|
+
# @return [Float, nil] the most the run may spend, in dollars, for an agent that takes one
|
|
45
|
+
attr_reader :budget
|
|
46
|
+
# @return [Symbol] one of `CONFINEMENTS`
|
|
47
|
+
attr_reader :confinement
|
|
48
|
+
|
|
49
|
+
# @param tools [Array<String>] the tools the agent is given
|
|
50
|
+
# @param writable [Array<String>] directories the agent may write
|
|
51
|
+
# @param network [Symbol] one of `NETWORKS`
|
|
52
|
+
# @param env [Array<String>] environment variable names to pass through
|
|
53
|
+
# @param timeout [Integer, nil] seconds the run may take
|
|
54
|
+
# @param budget [Float, nil] dollars the run may spend
|
|
55
|
+
# @param confinement [Symbol] one of `CONFINEMENTS`; `network` applies to the sandbox only
|
|
56
|
+
# @raise [ArgumentError] when `network` or `confinement` is not one it knows
|
|
57
|
+
def initialize(tools: [], writable: [], network: :none, env: [], timeout: nil, budget: nil,
|
|
58
|
+
confinement: :sandbox)
|
|
59
|
+
unless NETWORKS.include?(network)
|
|
60
|
+
raise ArgumentError, "network must be one of #{NETWORKS.inspect}, not #{network.inspect}"
|
|
61
|
+
end
|
|
62
|
+
unless CONFINEMENTS.include?(confinement)
|
|
63
|
+
raise ArgumentError, "confinement must be one of #{CONFINEMENTS.inspect}, not #{confinement.inspect}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
@tools = tools.dup.freeze
|
|
67
|
+
@writable = writable.dup.freeze
|
|
68
|
+
@network = network
|
|
69
|
+
@env = env.dup.freeze
|
|
70
|
+
@timeout = timeout
|
|
71
|
+
@budget = budget
|
|
72
|
+
@confinement = confinement
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Boolean] whether this profile is enforced by the operating system's sandbox
|
|
76
|
+
def sandboxed?
|
|
77
|
+
confinement == :sandbox
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Boolean] whether this machine can apply this profile's confinement
|
|
81
|
+
def available?
|
|
82
|
+
!sandboxed? || File.executable?(SANDBOX)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @param words [Array<String>] the agent's command
|
|
86
|
+
# @return [Array<String>] `words`, run under the sandbox with this profile's policy; the
|
|
87
|
+
# words unchanged when the confinement is the command's own permission rules
|
|
88
|
+
def confine(words)
|
|
89
|
+
sandboxed? ? [SANDBOX, "-p", policy, *words] : words
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [String] the `claude` permission mode this profile runs under
|
|
93
|
+
def permission_mode
|
|
94
|
+
sandboxed? ? "acceptEdits" : "dontAsk"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @param source [Hash{String => String}] the environment to take values from
|
|
98
|
+
# @return [Hash{String => String}] only the variables this profile lets the agent see
|
|
99
|
+
def environment(source = ENV)
|
|
100
|
+
source.to_h.slice(*BASE_ENV, *env)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The `claude` flags this profile's tools and budget become.
|
|
104
|
+
#
|
|
105
|
+
# @return [Array<String>]
|
|
106
|
+
def tool_flags
|
|
107
|
+
flags = tools.empty? ? [] : ["--tools", tools.join(","), "--allowedTools", *allowed_tools]
|
|
108
|
+
flags += ["--strict-mcp-config"] unless sandboxed?
|
|
109
|
+
flags + (budget ? ["--max-budget-usd", budget.to_s] : [])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [String] the sandbox policy: reads open but for credentials, writes where allowed
|
|
113
|
+
def policy
|
|
114
|
+
[
|
|
115
|
+
"(version 1)", "(allow default)",
|
|
116
|
+
"(deny file-write*)", write_rule, "(deny file-read*#{paths_clause(credential_paths)})",
|
|
117
|
+
*network_rules
|
|
118
|
+
].join("\n")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# The tools allowed without asking. A permissions-confined run holds the writing tools only
|
|
124
|
+
# through an `Edit` rule per writable directory, which every file-writing tool answers to.
|
|
125
|
+
def allowed_tools
|
|
126
|
+
return [tools.join(",")] if sandboxed?
|
|
127
|
+
|
|
128
|
+
tools - WRITING_TOOLS + writable.map { |path| "Edit(/#{real(path)}/**)" }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def write_rule
|
|
132
|
+
allowed = [real(Dir.tmpdir), *writable.map { |path| real(path) }]
|
|
133
|
+
devices = '(literal "/dev/null") (literal "/dev/tty") (regex #"^/dev/fd/")'
|
|
134
|
+
"(allow file-write*#{paths_clause(allowed)} #{devices})"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def credential_paths
|
|
138
|
+
CREDENTIAL_PATHS.map { |path| File.join(Dir.home, path) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def paths_clause(paths)
|
|
142
|
+
paths.map { |path| " (subpath #{quote(path)})" }.join
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def network_rules
|
|
146
|
+
case network
|
|
147
|
+
when :any then []
|
|
148
|
+
when :https then ["(deny network*)", '(allow network-outbound (remote tcp "*:443") (remote udp "*:53"))',
|
|
149
|
+
'(allow network-outbound (remote unix-socket (path-literal "/private/var/run/mDNSResponder")))']
|
|
150
|
+
else ["(deny network*)"]
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def real(path)
|
|
155
|
+
File.realpath(path)
|
|
156
|
+
rescue SystemCallError
|
|
157
|
+
File.expand_path(path)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def quote(path)
|
|
161
|
+
%("#{path.gsub('\\', '\\\\\\\\').gsub('"', '\\"')}")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -16,23 +16,28 @@ module Hecks
|
|
|
16
16
|
# The command behind `hecks quality_control target.mine_combinations`: asks an agent to mine the
|
|
17
17
|
# adversarial corpus for new domain combinations, then checks them. It is opt-in and never run
|
|
18
18
|
# by `hecks quality_control sweep.tick` (an agent call costs money and answers differently each
|
|
19
|
-
# run).
|
|
20
|
-
# `--
|
|
21
|
-
# `--
|
|
19
|
+
# run). `--brief` prints the agent's prompt and stops; `--from <dir>` re-checks an earlier run;
|
|
20
|
+
# `--against <domain>` narrows the census; `--agent "cmd"` reads the prompt on stdin;
|
|
21
|
+
# `--confine` limits the default agent to writing the run's candidates directory.
|
|
22
22
|
#
|
|
23
23
|
# The agent defaults to `claude -p` (`QA_MINER_AGENT` overrides); candidates that boot are
|
|
24
24
|
# checked by `hecks quality_control target.check_generated_domains --source`, whose report is
|
|
25
25
|
# this command's report. Exit 0: all clean. 2: a finding (see
|
|
26
|
-
# `.claude/skills/hecks_qa/SKILL.md`). 1: an operational
|
|
27
|
-
# error.
|
|
26
|
+
# `.claude/skills/hecks_qa/SKILL.md`). 1: an operational error.
|
|
28
27
|
class QaMineCombinations
|
|
29
28
|
EXIT_OK = 0
|
|
30
29
|
EXIT_ERROR = 1
|
|
31
30
|
RESULT_MARKER = "QA_GENERATED_RESULT "
|
|
32
31
|
|
|
32
|
+
# What `--confine` lets the default agent do: read anything, write the candidates directory,
|
|
33
|
+
# run for twenty minutes and spend two dollars.
|
|
34
|
+
CONFINED_TOOLS = %w[Read Glob Grep Write Edit].freeze
|
|
35
|
+
CONFINED_TIMEOUT = 1200
|
|
36
|
+
CONFINED_BUDGET = 2.0
|
|
37
|
+
|
|
33
38
|
USAGE = "usage: hecks quality_control mine_combinations [--candidates N] [--rust] [--seeds K] " \
|
|
34
39
|
"[--steps M] " \
|
|
35
|
-
"[--adversarial F] [--repair-rounds R] [--agent CMD] [--from <candidates-dir>] " \
|
|
40
|
+
"[--adversarial F] [--repair-rounds R] [--agent CMD] [--confine] [--from <candidates-dir>] " \
|
|
36
41
|
"[--against <domain> …] [--brief]"
|
|
37
42
|
|
|
38
43
|
Miner = Hecks::Fuzzing::CombinationMiner
|
|
@@ -65,7 +70,8 @@ module Hecks
|
|
|
65
70
|
@out_dir = @options[:from] || File.join(@run_dir, "candidates")
|
|
66
71
|
@agent_log = File.join(@run_dir, "agent.log")
|
|
67
72
|
@agent = Hecks::Adapters::Agent.new
|
|
68
|
-
@
|
|
73
|
+
@profile = confinement_profile if @options[:confine]
|
|
74
|
+
@command = @agent.command_for(@options[:agent], @profile)
|
|
69
75
|
corpus = @options[:against].empty? ? Miner.corpus_paths(@root) : @options[:against]
|
|
70
76
|
@brief = Miner.brief(corpus, root: @root, bug_titles: Miner.recent_bug_titles(@root))
|
|
71
77
|
prompt = Miner.prompt(@root, @brief, count: @options[:candidates], out_dir: @out_dir)
|
|
@@ -89,7 +95,7 @@ module Hecks
|
|
|
89
95
|
|
|
90
96
|
def parse(argv)
|
|
91
97
|
options = { candidates: 3, rust: false, seeds: 5, steps: 25, adversarial: 0.3, repair_rounds: 1,
|
|
92
|
-
agent: nil, from: nil, brief: false, against: [] }
|
|
98
|
+
agent: nil, from: nil, brief: false, confine: false, against: [] }
|
|
93
99
|
until argv.empty?
|
|
94
100
|
arg = argv.shift
|
|
95
101
|
if %w[-h --help].include?(arg)
|
|
@@ -108,6 +114,7 @@ module Hecks
|
|
|
108
114
|
options[key] = reader.call(argv.shift)
|
|
109
115
|
when "--rust" then options[:rust] = true
|
|
110
116
|
when "--brief" then options[:brief] = true
|
|
117
|
+
when "--confine" then options[:confine] = true
|
|
111
118
|
when "--against" then options[:against] << File.expand_path(argv.shift)
|
|
112
119
|
else abort "#{USAGE}\nunexpected argument: #{arg.inspect}"
|
|
113
120
|
end
|
|
@@ -124,12 +131,19 @@ module Hecks
|
|
|
124
131
|
|
|
125
132
|
# Returns nil when the agent finished, else why it did not.
|
|
126
133
|
def ask_agent(prompt)
|
|
127
|
-
@agent.ask(prompt: prompt, command: @command, chdir: @root, log: @agent_log)
|
|
134
|
+
@agent.ask(prompt: prompt, command: @command, chdir: @root, log: @agent_log, profile: @profile)
|
|
128
135
|
nil
|
|
129
136
|
rescue Hecks::Adapters::Agent::Failed => e
|
|
130
137
|
e.message
|
|
131
138
|
end
|
|
132
139
|
|
|
140
|
+
# The profile `--confine` runs the agent under: it writes only the candidates directory.
|
|
141
|
+
def confinement_profile
|
|
142
|
+
FileUtils.mkdir_p(@out_dir)
|
|
143
|
+
Hecks::Adapters::AgentProfile.new(confinement: :permissions, tools: CONFINED_TOOLS, writable: [@out_dir],
|
|
144
|
+
timeout: CONFINED_TIMEOUT, budget: CONFINED_BUDGET)
|
|
145
|
+
end
|
|
146
|
+
|
|
133
147
|
def boot_error(candidate, boot_root)
|
|
134
148
|
dir = Hecks::Fuzzing::DomainGenerator.write(
|
|
135
149
|
{ "source" => File.read(candidate[:bluebook]), "aggregates" => [], "policies" => [] },
|
|
@@ -252,6 +252,7 @@ module Hecks
|
|
|
252
252
|
# @return [void]
|
|
253
253
|
def write(artifact, out_dir)
|
|
254
254
|
written = Hecks::Projector.write(artifact, out_dir, as: :files)
|
|
255
|
+
written.each { |path| File.chmod(0o755, path) if path.end_with?(".sh") }
|
|
255
256
|
|
|
256
257
|
written.each { |path| puts "wrote #{path}" }
|
|
257
258
|
puts
|
data/lib/hecks/version.rb
CHANGED
data/rust/host/HECKS_RELEASE
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.2.0
|
data/rust/host/src/auth.rs
CHANGED
|
@@ -592,8 +592,25 @@ pub async fn active_role(client: &Mutex<Client>, domain_ir: &Value, email: &str)
|
|
|
592
592
|
.and_then(|(_, state)| role_of(state).map(String::from)))
|
|
593
593
|
}
|
|
594
594
|
|
|
595
|
+
/// The Governance identity id linked to the member with `email`, read from
|
|
596
|
+
/// the live membership head. `None` when the member has not signed in yet.
|
|
597
|
+
pub async fn member_identity_id(client: &Mutex<Client>, domain_ir: &Value, email: &str) -> anyhow::Result<Option<String>> {
|
|
598
|
+
let email = email.to_lowercase();
|
|
599
|
+
Ok(member_rows(client, domain_ir)
|
|
600
|
+
.await?
|
|
601
|
+
.iter()
|
|
602
|
+
.find(|(id, state)| id.to_lowercase() == email && !is_deleted(state))
|
|
603
|
+
.and_then(|(_, state)| linked_identity_id(state)))
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// The id the sign-in flow wrote onto a Member row, or `None` before then.
|
|
607
|
+
fn linked_identity_id(state: &Value) -> Option<String> {
|
|
608
|
+
state.get("identity_id").and_then(|v| v.get("value")).and_then(|v| v.as_str()).map(str::to_string)
|
|
609
|
+
}
|
|
610
|
+
|
|
595
611
|
/// Every admitted person as a JSON row: name, email, role, `linked`
|
|
596
|
-
/// (signed in), `granted` (has access), and `
|
|
612
|
+
/// (signed in), `granted` (has access), `disabled`, and `identity_id`
|
|
613
|
+
/// (the Governance identity id, `null` until the person has signed in).
|
|
597
614
|
pub async fn all_people(client: &Mutex<Client>, domain_ir: &Value) -> anyhow::Result<Vec<Value>> {
|
|
598
615
|
Ok(member_rows(client, domain_ir)
|
|
599
616
|
.await?
|
|
@@ -610,6 +627,7 @@ pub async fn all_people(client: &Mutex<Client>, domain_ir: &Value) -> anyhow::Re
|
|
|
610
627
|
"linked": linked,
|
|
611
628
|
"granted": role.is_some() && !disabled,
|
|
612
629
|
"disabled": disabled,
|
|
630
|
+
"identity_id": linked_identity_id(&state),
|
|
613
631
|
})
|
|
614
632
|
})
|
|
615
633
|
.collect())
|
|
@@ -1206,9 +1224,15 @@ mod tests {
|
|
|
1206
1224
|
let chris = people.iter().find(|p| p["email"] == "chris@example.com").unwrap();
|
|
1207
1225
|
assert_eq!(chris["linked"], true);
|
|
1208
1226
|
assert_eq!(chris["granted"], true);
|
|
1227
|
+
assert_eq!(chris["identity_id"], "id-1");
|
|
1209
1228
|
let angie = people.iter().find(|p| p["email"] == "angie@example.com").unwrap();
|
|
1210
1229
|
assert_eq!(angie["linked"], false);
|
|
1211
1230
|
assert_eq!(angie["granted"], false);
|
|
1231
|
+
assert!(angie["identity_id"].is_null());
|
|
1232
|
+
|
|
1233
|
+
assert_eq!(member_identity_id(&db, &domain_ir, "Chris@Example.com").await.unwrap().as_deref(), Some("id-1"));
|
|
1234
|
+
assert_eq!(member_identity_id(&db, &domain_ir, "angie@example.com").await.unwrap(), None);
|
|
1235
|
+
assert_eq!(member_identity_id(&db, &domain_ir, "nobody@example.com").await.unwrap(), None);
|
|
1212
1236
|
}
|
|
1213
1237
|
|
|
1214
1238
|
#[tokio::test]
|
data/rust/host/src/web.rs
CHANGED
|
@@ -369,7 +369,10 @@ async fn active_session_email(
|
|
|
369
369
|
|
|
370
370
|
async fn accounts_me_route(domain_ir: &Value, cookies: &HashMap<String, String>, secret: &str, client: &Mutex<Client>) -> Value {
|
|
371
371
|
match active_session_email(domain_ir, cookies, secret, client).await {
|
|
372
|
-
Ok(email) =>
|
|
372
|
+
Ok(email) => match auth::member_identity_id(client, domain_ir, &email).await {
|
|
373
|
+
Ok(identity_id) => respond(200, "application/json", &json!({"email": email, "identity_id": identity_id}).to_string()),
|
|
374
|
+
Err(e) => respond(500, "application/json", &json!({"error": format!("members lookup failed: {e}")}).to_string()),
|
|
375
|
+
},
|
|
373
376
|
Err(response) => response,
|
|
374
377
|
}
|
|
375
378
|
}
|
|
@@ -434,7 +437,7 @@ pub(crate) async fn add_member_route(
|
|
|
434
437
|
Ok(true) => respond(
|
|
435
438
|
201,
|
|
436
439
|
"application/json",
|
|
437
|
-
&json!({"name": name, "email": email, "role": role, "linked": false, "granted": true, "disabled": false}).to_string(),
|
|
440
|
+
&json!({"name": name, "email": email, "role": role, "linked": false, "granted": true, "disabled": false, "identity_id": null}).to_string(),
|
|
438
441
|
),
|
|
439
442
|
Ok(false) => json_error(500, &format!("the person was admitted but the {role} role was not granted")),
|
|
440
443
|
Err(e) => json_error(500, &format!("the person was admitted but the {role} role was not granted: {e}")),
|
|
@@ -2173,6 +2176,7 @@ mod tests {
|
|
|
2173
2176
|
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
2174
2177
|
let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
2175
2178
|
assert_eq!(body["email"], "zed@example.com");
|
|
2179
|
+
assert_eq!(body["identity_id"], "id-1", "the signed-in account's Governance identity id");
|
|
2176
2180
|
|
|
2177
2181
|
let empty = HashMap::new();
|
|
2178
2182
|
assert_eq!(accounts_me_route(&domain_ir, &empty, secret, &client).await["statusCode"], 401);
|
|
@@ -2273,11 +2277,11 @@ mod tests {
|
|
|
2273
2277
|
assert_eq!(names, ["amy", "Zed"], "sorted case-insensitively by name");
|
|
2274
2278
|
assert_eq!(
|
|
2275
2279
|
people[1],
|
|
2276
|
-
json!({"name": "Zed", "email": "zed@example.com", "role": "Admin", "linked": true, "granted": true, "disabled": false})
|
|
2280
|
+
json!({"name": "Zed", "email": "zed@example.com", "role": "Admin", "linked": true, "granted": true, "disabled": false, "identity_id": "id-1"})
|
|
2277
2281
|
);
|
|
2278
2282
|
assert_eq!(
|
|
2279
2283
|
people[0],
|
|
2280
|
-
json!({"name": "amy", "email": "amy@example.com", "role": null, "linked": false, "granted": false, "disabled": false})
|
|
2284
|
+
json!({"name": "amy", "email": "amy@example.com", "role": null, "linked": false, "granted": false, "disabled": false, "identity_id": null})
|
|
2281
2285
|
);
|
|
2282
2286
|
}
|
|
2283
2287
|
|
|
@@ -2465,7 +2469,7 @@ mod tests {
|
|
|
2465
2469
|
let created: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
2466
2470
|
assert_eq!(
|
|
2467
2471
|
created,
|
|
2468
|
-
json!({"name": "New Person", "email": "new@example.com", "role": "Admin", "linked": false, "granted": true, "disabled": false})
|
|
2472
|
+
json!({"name": "New Person", "email": "new@example.com", "role": "Admin", "linked": false, "granted": true, "disabled": false, "identity_id": null})
|
|
2469
2473
|
);
|
|
2470
2474
|
|
|
2471
2475
|
let listing = members_route(&domain_ir, &cookies, secret, &client).await;
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hecks
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chris Young
|
|
@@ -35,6 +35,14 @@ executables:
|
|
|
35
35
|
extensions: []
|
|
36
36
|
extra_rdoc_files: []
|
|
37
37
|
files:
|
|
38
|
+
- examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb
|
|
39
|
+
- examples/pizzas/bluebook/pizzas.behaviors
|
|
40
|
+
- examples/pizzas/bluebook/pizzas.bluebook
|
|
41
|
+
- examples/pizzas/bluebook/pizzas.hecksagon
|
|
42
|
+
- examples/pizzas/bluebook/pizzas.world
|
|
43
|
+
- examples/pizzas/bluebook/translations/2-77625c.bluebook
|
|
44
|
+
- examples/pizzas/oidc.json
|
|
45
|
+
- examples/pizzas/pizzas_behaviors.hecksagon
|
|
38
46
|
- exe/hecks
|
|
39
47
|
- lib/hecks.rb
|
|
40
48
|
- lib/hecks/adapters.rb
|
|
@@ -372,6 +380,8 @@ files:
|
|
|
372
380
|
- lib/hecks/hecks/adapters/codebase/test_suite.rb
|
|
373
381
|
- lib/hecks/hecks/adapters/codebase/tree.rb
|
|
374
382
|
- lib/hecks/hecks/adapters/console_capture.rb
|
|
383
|
+
- lib/hecks/hecks/adapters/cost_explorer.adapter
|
|
384
|
+
- lib/hecks/hecks/adapters/cost_explorer.rb
|
|
375
385
|
- lib/hecks/hecks/adapters/deploy_toolchain.adapter
|
|
376
386
|
- lib/hecks/hecks/adapters/deploy_toolchain.rb
|
|
377
387
|
- lib/hecks/hecks/adapters/finding_github.adapter
|
|
@@ -618,6 +628,7 @@ files:
|
|
|
618
628
|
- lib/hecks/projector/target.rb
|
|
619
629
|
- lib/hecks/quality_control/adapters/agent.adapter
|
|
620
630
|
- lib/hecks/quality_control/adapters/agent.rb
|
|
631
|
+
- lib/hecks/quality_control/adapters/agent_profile.rb
|
|
621
632
|
- lib/hecks/quality_control/adapters/angle_tools.adapter
|
|
622
633
|
- lib/hecks/quality_control/adapters/angle_tools.rb
|
|
623
634
|
- lib/hecks/quality_control/adapters/clearance_recorder.rb
|