hames 0.1.0 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a98e47c0878e75c0da270c193f407260acc23f58614f81500c26095b837ebd9
4
- data.tar.gz: 855c87d80768f619edd593bcfd98a176263a74fe1889aa70eb0b3b7ee021574e
3
+ metadata.gz: 9f376372b744ef6dac48af0a0577f32e8bb82d3834c13f0d2416927f2558c22a
4
+ data.tar.gz: a554420a27aa0850da074e9d9bb5a9069b75060d57a68b56e2fd064119548e64
5
5
  SHA512:
6
- metadata.gz: 7446507cced8c24120f7b76b0a8e8c3bf2ad75e286c0deed5e392058bcefcd04887e5ecc7391fd70d97b8a7e7fab12dacf0f67338f71a57a17b965b19477faaa
7
- data.tar.gz: 69a3b325769844792ee3f71bfffe9e5a364b813722c3874b35c7333f7e4846f586271b4906fbbecb30277c170e28ebb3b3b26c69fccca37f5afbc9f2c623f6a3
6
+ metadata.gz: 273414c4bc70159631dfb9c9d7239c97f8fab9fe0d370e1c4aec29ae89c4c550e88e06933ddb9d63c0e17bf5345051060bf9fd910b7fe81f195706a957622dfb
7
+ data.tar.gz: 659bd79c726c33ba9764df5b04fd76efeff316f1f157e1901ddc8980a6224e74778e08bdd418f51f1469054f93649bb03c93ff12bddb402221628ad59eeaadfb
data/lib/hames/context.rb CHANGED
@@ -49,10 +49,28 @@ module Hames
49
49
 
50
50
  # Runs the block now; the block must return a disposer callable (or nil).
51
51
  # Disposal happens in reverse registration order, per owner, on unload.
52
+ # The disposer handed back is self-removing and idempotent: calling it
53
+ # runs the teardown once and drops its own entry from @effects, so a
54
+ # long-lived context does not pin every disposed registration (and
55
+ # whatever its closure captured). The done flag lives on the frame (not
56
+ # presence in @effects) because dispose_owner! removes frames from
57
+ # @effects BEFORE running them — a presence check would silently skip
58
+ # the real teardown there.
52
59
  def effect(&block)
53
60
  disposer = block.call
54
- @effects << [@owner, disposer] if disposer
55
- disposer
61
+ return disposer unless disposer
62
+
63
+ frame = [@owner, nil, false]
64
+ wrapped = lambda do
65
+ next if frame[2]
66
+
67
+ frame[2] = true
68
+ @effects.delete(frame)
69
+ disposer.call
70
+ end
71
+ frame[1] = wrapped
72
+ @effects << frame
73
+ wrapped
56
74
  end
57
75
 
58
76
  def with_owner(owner)
@@ -68,12 +86,16 @@ module Hames
68
86
  doomed = []
69
87
  @effects.each { |fr| (fr[0] == owner ? doomed : kept) << fr }
70
88
  @effects = kept
89
+ # each wrapped disposer re-scans @effects for a frame that the
90
+ # partition above already removed — an O(doomed·kept) miss. Negligible
91
+ # at current roster sizes; if owner disposal ever gets hot, carry the
92
+ # original disposer on the frame and call it directly here.
71
93
  doomed.reverse_each { |(_o, d)| d.call }
72
94
  end
73
95
 
74
96
  # Dispose the whole context (child scopes call this when they end).
75
97
  def dispose!
76
- @effects.reverse_each { |(_o, d)| d.call }
98
+ @effects.dup.reverse_each { |(_o, d)| d.call }
77
99
  @effects.clear
78
100
  end
79
101
 
@@ -88,9 +110,7 @@ module Hames
88
110
  l = Listener.new(name:, block:, prepend:, owner: @owner)
89
111
  bucket = @listeners[name]
90
112
  prepend ? bucket.unshift(l) : bucket.push(l)
91
- disposer = -> { bucket.delete(l) }
92
- @effects << [@owner, disposer]
93
- disposer
113
+ effect { -> { bucket.delete(l) } }
94
114
  end
95
115
 
96
116
  # Listeners visible to this context: parent chain first (registration
@@ -100,10 +120,18 @@ module Hames
100
120
  parent ? parent.listeners_for(name) + own : own.dup
101
121
  end
102
122
 
103
- # emit: fire-and-forget, registration order, no return value.
123
+ # emit: fire-and-forget, registration order, no return value. Listener
124
+ # failures are isolated (warned, not raised): by the time listeners run,
125
+ # the producer's fact is already committed — a durable append emits after
126
+ # the store write — so a consumer bug must not un-happen it. The other
127
+ # modes raise through: their results are load-bearing.
104
128
  def emit(name, *args)
105
129
  Hames.assert_mode!(name, :emit)
106
- listeners_for(name).each { |l| l.block.call(*args) }
130
+ listeners_for(name).each do |l|
131
+ l.block.call(*args)
132
+ rescue StandardError => e
133
+ warn "hames: emit(#{name}): listener isolated: #{e.class}: #{e.message}"
134
+ end
107
135
  nil
108
136
  end
109
137
 
data/lib/hames/events.rb CHANGED
@@ -18,7 +18,11 @@ module Hames
18
18
  # Hames.event "tools/pre_execute", mode: :waterfall, payload: Tools::Call
19
19
  def event(name, mode: nil, durable: false, payload: nil, doc: nil)
20
20
  name = name.to_s
21
- return events.fetch(name) if mode.nil?
21
+ if mode.nil?
22
+ return events.fetch(name) do
23
+ raise Hames::ContractError, "lookup of undeclared event #{name}"
24
+ end
25
+ end
22
26
 
23
27
  raise ArgumentError, "unknown dispatch mode #{mode.inspect}" unless MODES.include?(mode)
24
28
  if (existing = events[name]) && existing.mode != mode
data/lib/hames/loader.rb CHANGED
@@ -4,16 +4,27 @@ module Hames
4
4
  # Base class for service plugins. A plugin may instead be any object that
5
5
  # responds to apply(ctx) (the functional form), with optional #inject.
6
6
  class Service
7
+ # config_schema (and its inheritance) comes from the DSL mixin, so a
8
+ # functional plugin that is not a Service can extend the same module and be
9
+ # schema'd identically (docs/composition.md §9).
10
+ extend Hames::Schema::DSL
11
+
7
12
  class << self
13
+ # Both class-level declarations are inherited: a subclass (a test
14
+ # double, a provider variant) mounts exactly like its parent unless it
15
+ # redeclares. inject accumulates down the chain; service_key overrides.
8
16
  def service_key(key = nil)
9
17
  @service_key = key.to_sym if key
10
- @service_key
18
+ return @service_key if @service_key
19
+
20
+ superclass <= Hames::Service ? superclass.service_key : nil
11
21
  end
12
22
 
13
23
  def inject(*keys)
14
24
  @inject ||= []
15
25
  @inject.concat(keys.map(&:to_sym)) unless keys.empty?
16
- @inject
26
+ inherited = superclass <= Hames::Service ? superclass.inject : []
27
+ inherited + @inject
17
28
  end
18
29
  end
19
30
 
@@ -32,6 +43,18 @@ module Hames
32
43
 
33
44
  def start(ctx); end
34
45
  def stop(ctx); end
46
+
47
+ # Hot-reload hook (plan §M6): the loader replaces config wholesale, then
48
+ # calls this. Override to re-derive whatever start captured. The default
49
+ # warns — a service running on stale knobs should say so, not hide it.
50
+ def reconfigure(_config)
51
+ warn "hames: #{self.class} does not support hot-reconfigure; remount the row to apply"
52
+ end
53
+
54
+ # Loader-only: swap the config object under the reader.
55
+ def replace_config!(config)
56
+ @config = config
57
+ end
35
58
  end
36
59
 
37
60
  Row = Data.define(:id, :plugin, :config, :disabled) do
@@ -46,7 +69,12 @@ module Hames
46
69
  # row with an existing id replaces that row's config wholesale (never a
47
70
  # deep merge); unknown ids append.
48
71
  class Loader
49
- attr_reader :ctx, :rows
72
+ # `mounted` is insertion-ordered by boot!, which mounts in dependency
73
+ # order — so its reverse is exact reverse-dependency order, and that is
74
+ # what a teardown wants: a consumer comes down before what it injects.
75
+ # Row order cannot stand in for it, since an inserted row's position in
76
+ # the tree need not match where its dependencies put it.
77
+ attr_reader :ctx, :rows, :mounted
50
78
 
51
79
  def initialize(ctx = Context.new)
52
80
  @ctx = ctx
@@ -88,10 +116,44 @@ module Hames
88
116
  ctx
89
117
  end
90
118
 
119
+ # Hot config swap — wholesale, like layering, never a merge. Replaces the
120
+ # row's config, swaps the mounted service's, invokes its reconfigure hook
121
+ # under with_owner(id) (so hook-registered effects are owned by the row,
122
+ # not left ownerless), and emits config/updated when the app vocabulary
123
+ # declares it (hames itself declares no events and stays app-agnostic). A
124
+ # raising hook rolls the row and the service's config back to what they
125
+ # were before the call and re-raises — the swap is atomic, never split-brain.
126
+ def reconfigure!(id, config)
127
+ id = id.to_s
128
+ old_row = @rows.fetch(id) { raise KeyError, "no row #{id.inspect}" }
129
+ service = @mounted.fetch(id) { raise KeyError, "no mounted plugin #{id.inspect}" }
130
+ old_config = old_row.config
131
+ @rows[id] = Hames::Row.new(id: old_row.id, plugin: old_row.plugin, config: config, disabled: old_row.disabled)
132
+ service.replace_config!(config)
133
+ begin
134
+ ctx.with_owner(id) { service.reconfigure(config) }
135
+ rescue StandardError
136
+ @rows[id] = old_row
137
+ service.replace_config!(old_config)
138
+ raise
139
+ end
140
+ ctx.emit("config/updated", id, config) if Hames.declared?("config/updated")
141
+ config
142
+ end
143
+
91
144
  def unload!(id)
92
- plugin = @mounted.delete(id) or raise ArgumentError, "no mounted plugin #{id}"
93
- plugin.stop(ctx) if plugin.respond_to?(:stop)
94
- ctx.dispose_owner!(id)
145
+ plugin = @mounted.fetch(id) { raise ArgumentError, "no mounted plugin #{id}" }
146
+ # Owner effects are disposed even when stop raises, so a wedged stop hook
147
+ # cannot strand this row's service registration and listeners; and the
148
+ # @mounted entry is dropped only AFTER a clean teardown, so a stop that
149
+ # raised keeps the row unloadable (a retry re-runs stop) rather than
150
+ # vanishing behind a misleading "no mounted plugin".
151
+ begin
152
+ plugin.stop(ctx) if plugin.respond_to?(:stop)
153
+ ensure
154
+ ctx.dispose_owner!(id)
155
+ end
156
+ @mounted.delete(id)
95
157
  end
96
158
 
97
159
  # Resolved tree, layer-agnostic view (for --dump-config).
@@ -118,6 +180,19 @@ module Hames
118
180
  def mount(row, plugin)
119
181
  ctx.with_owner(row.id) { plugin.apply(ctx) }
120
182
  @mounted[row.id] = plugin
183
+ rescue Exception # rubocop:disable Lint/RescueException
184
+ # apply raised partway through: whatever it registered before the raise is
185
+ # owned by this row, and this row is NOT in @mounted, so no unload! or
186
+ # shutdown could ever find those registrations to dispose them. Roll them
187
+ # back here so a failed mount leaves nothing live, then let the boot
188
+ # failure out unchanged. A raising disposer during rollback is warned
189
+ # rather than allowed to mask the failure that started it.
190
+ begin
191
+ ctx.dispose_owner!(row.id)
192
+ rescue StandardError => e
193
+ warn "hames: mount rollback for #{row.id}: a disposer raised: #{e.class}: #{e.message}"
194
+ end
195
+ raise
121
196
  end
122
197
  end
123
198
  end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hames
4
+ # The kernel's own tiny config validator (docs/composition.md §9): a plain
5
+ # description of a service's config keys — for each one a type:, whether it is
6
+ # required:, an optional enum: of legal values, a default:, and a doc: string
7
+ # — plus the code that checks a config hash against it and reports what does
8
+ # not fit.
9
+ #
10
+ # Pure stdlib rather than dry-schema, because the kernel's zero-runtime-
11
+ # dependency rule is a design constraint rather than a coincidence (CLAUDE.md).
12
+ # The plan's §10 dry-schema mention is superseded by that rule.
13
+ class Schema
14
+ # Distinguishes a read of config_schema from a declaration: with no argument
15
+ # config_schema() reads the stored schema, config_schema({}) declares an
16
+ # empty one (a service that takes no config), and config_schema(key: {...})
17
+ # declares a populated one.
18
+ UNSET = Object.new.freeze
19
+ private_constant :UNSET
20
+
21
+ # One key's rules. `type` is a Class or an array of Classes (a union);
22
+ # [TrueClass, FalseClass] is how a boolean is spelled, since Ruby has no
23
+ # Boolean class. `default` documents what the service reads when the key is
24
+ # missing — it fills a missing key at read time, it does NOT merge into the
25
+ # config, because config layering is wholesale-replace (CLAUDE.md).
26
+ KeySpec = Data.define(:name, :type, :required, :enum, :default, :doc)
27
+
28
+ Result = Data.define(:errors, :warnings) do
29
+ def ok? = errors.empty?
30
+ end
31
+
32
+ # Class-level DSL. Hames::Service extends it so every service is schema-able;
33
+ # a functional plugin (one that only responds to apply, not a Service) can
34
+ # `extend Hames::Schema::DSL` to be schema'd too — that is how the OpenRouter
35
+ # plugin, which is not a Service, still carries a schema doctor can read.
36
+ module DSL
37
+ def config_schema(specs = UNSET)
38
+ return stored_config_schema if specs.equal?(UNSET)
39
+
40
+ @config_schema = Hames::Schema.build(specs)
41
+ Hames::Schema.register(self, @config_schema)
42
+ @config_schema
43
+ end
44
+
45
+ # Inherited like service_key and inject: a subclass that does not redeclare
46
+ # validates exactly like its parent.
47
+ def stored_config_schema
48
+ return @config_schema if defined?(@config_schema) && @config_schema
49
+
50
+ parent = superclass if respond_to?(:superclass)
51
+ parent.respond_to?(:config_schema) ? parent.config_schema : nil
52
+ end
53
+ end
54
+
55
+ # Every declared schema, class => schema, in declaration order. The catalog
56
+ # generator (rake config:catalog) walks this once the code is required, the
57
+ # way events:catalog walks Hames.catalog.
58
+ def self.declared = (@declared ||= {})
59
+ def self.register(klass, schema) = declared[klass] = schema
60
+ def self.reset_declared! = declared.clear # test hook
61
+
62
+ def self.build(specs)
63
+ keys = (specs || {}).to_h do |name, opts|
64
+ opts ||= {}
65
+ key = name.to_sym
66
+ [key, KeySpec.new(name: key, type: opts[:type], required: opts.fetch(:required, false),
67
+ enum: opts[:enum], default: opts[:default], doc: opts[:doc])]
68
+ end
69
+ new(keys)
70
+ end
71
+
72
+ attr_reader :keys
73
+
74
+ def initialize(keys)
75
+ @keys = keys
76
+ end
77
+
78
+ # Validates a config hash without mutating it. `subject` names the row (or
79
+ # class) so an error reads as "sandbox: image is required but was not set" —
80
+ # a refusal that cannot say which of thirty rows it is about is one an
81
+ # operator cannot act on.
82
+ #
83
+ # A key that is absent OR present-but-nil counts as UNSET: a required unset
84
+ # key is an error, a non-required one is fine — its default applies, and an
85
+ # unset !env resolving to nil is an ordinary state (§5) rather than a fault.
86
+ # A non-nil value is checked against `type` and `enum`. Extra keys WARN
87
+ # rather than fail, because config rows grow.
88
+ #
89
+ # `redact:` governs whether a rejected value's CONTENT may appear in the
90
+ # message. It stays false for programmatic callers (which hold plain config,
91
+ # and a message naming the value is more useful), and doctor passes true
92
+ # because by the time it validates, a value is a materialized !env/!setting/
93
+ # !ruby result and may be a secret — so its message names the type only,
94
+ # never the content.
95
+ def validate(config, subject: nil, redact: false)
96
+ config ||= {}
97
+ prefix = subject ? "#{subject}: " : ""
98
+ errors = []
99
+
100
+ @keys.each_value do |spec|
101
+ value = config[spec.name]
102
+ if value.nil?
103
+ errors << "#{prefix}#{spec.name} is required but was not set" if spec.required
104
+ next
105
+ end
106
+ if spec.type && Array(spec.type).none? { |t| value.is_a?(t) }
107
+ got = redact ? Schema.article(value.class.name) : Schema.describe(value)
108
+ errors << "#{prefix}#{spec.name} must be #{Schema.type_desc(spec.type)}, got #{got}"
109
+ end
110
+ if spec.enum && !spec.enum.include?(value)
111
+ allowed = spec.enum.map(&:inspect).join(", ")
112
+ # Redacted: the allowed set is schema-defined and safe to print; the
113
+ # configured value is not, so it is named as out-of-set, not echoed.
114
+ errors << if redact
115
+ "#{prefix}#{spec.name} must be one of #{allowed} (the configured value is not)"
116
+ else
117
+ "#{prefix}#{spec.name} must be one of #{allowed}, got #{value.inspect}"
118
+ end
119
+ end
120
+ end
121
+
122
+ warnings = (config.keys - @keys.keys).map { |extra| "#{prefix}#{extra} is not a known config key" }
123
+ Result.new(errors: errors, warnings: warnings)
124
+ end
125
+
126
+ # A boolean is the TrueClass/FalseClass union, so it reads as "a boolean"
127
+ # rather than naming two classes nobody wrote in a config.
128
+ def self.type_desc(type)
129
+ types = Array(type)
130
+ return "a boolean" if types.sort_by(&:name) == [FalseClass, TrueClass]
131
+ return article(types.first.name) if types.length == 1
132
+
133
+ "one of #{types.map(&:name).join(', ')}"
134
+ end
135
+
136
+ # "an Integer", "a String" — the article chosen by the leading sound, close
137
+ # enough on class names, so a message never reads "a Integer".
138
+ def self.article(word)
139
+ word = word.to_s
140
+ /\A[aeiou]/i.match?(word) ? "an #{word}" : "a #{word}"
141
+ end
142
+
143
+ # A scalar renders as itself ("got 5", "got nil"); anything larger renders
144
+ # as its class, so a wrong Hash where a String was wanted does not paste a
145
+ # whole tree into a one-line status. Only reached when redact is false —
146
+ # doctor never lets a rejected value's content into a message.
147
+ def self.describe(value)
148
+ case value
149
+ when String, Numeric, Symbol, true, false then value.inspect
150
+ else value.class.to_s
151
+ end
152
+ end
153
+ end
154
+ end
data/lib/hames.rb CHANGED
@@ -4,9 +4,10 @@
4
4
  # four dispatch modes, reversible effects, and dependency-driven boot. It has
5
5
  # no knowledge of LLMs and is reusable for any plugin-composed application.
6
6
  module Hames
7
- VERSION = "0.1.0"
7
+ VERSION = "0.1.1"
8
8
  end
9
9
 
10
10
  require_relative "hames/events"
11
11
  require_relative "hames/context"
12
+ require_relative "hames/schema"
12
13
  require_relative "hames/loader"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hames
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Obie Fernandez
@@ -24,6 +24,7 @@ files:
24
24
  - lib/hames/context.rb
25
25
  - lib/hames/events.rb
26
26
  - lib/hames/loader.rb
27
+ - lib/hames/schema.rb
27
28
  homepage: https://terret.org
28
29
  licenses:
29
30
  - MIT