spurline-core 0.3.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.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +177 -0
  4. data/exe/spur +6 -0
  5. data/lib/CLAUDE.md +11 -0
  6. data/lib/spurline/CLAUDE.md +16 -0
  7. data/lib/spurline/adapters/CLAUDE.md +12 -0
  8. data/lib/spurline/adapters/base.rb +17 -0
  9. data/lib/spurline/adapters/claude.rb +208 -0
  10. data/lib/spurline/adapters/open_ai.rb +213 -0
  11. data/lib/spurline/adapters/registry.rb +33 -0
  12. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  13. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  14. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  15. data/lib/spurline/agent.rb +433 -0
  16. data/lib/spurline/audit/log.rb +156 -0
  17. data/lib/spurline/audit/secret_filter.rb +121 -0
  18. data/lib/spurline/base.rb +130 -0
  19. data/lib/spurline/cartographer/CLAUDE.md +12 -0
  20. data/lib/spurline/cartographer/analyzer.rb +71 -0
  21. data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -0
  22. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  23. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  24. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  25. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  26. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  27. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  28. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  29. data/lib/spurline/cartographer/runner.rb +88 -0
  30. data/lib/spurline/cartographer.rb +6 -0
  31. data/lib/spurline/channels/base.rb +41 -0
  32. data/lib/spurline/channels/event.rb +136 -0
  33. data/lib/spurline/channels/github.rb +205 -0
  34. data/lib/spurline/channels/router.rb +103 -0
  35. data/lib/spurline/cli/check.rb +88 -0
  36. data/lib/spurline/cli/checks/CLAUDE.md +11 -0
  37. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  38. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  39. data/lib/spurline/cli/checks/base.rb +35 -0
  40. data/lib/spurline/cli/checks/credentials.rb +43 -0
  41. data/lib/spurline/cli/checks/permissions.rb +22 -0
  42. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  43. data/lib/spurline/cli/checks/session_store.rb +97 -0
  44. data/lib/spurline/cli/console.rb +73 -0
  45. data/lib/spurline/cli/credentials.rb +181 -0
  46. data/lib/spurline/cli/generators/CLAUDE.md +11 -0
  47. data/lib/spurline/cli/generators/agent.rb +123 -0
  48. data/lib/spurline/cli/generators/migration.rb +62 -0
  49. data/lib/spurline/cli/generators/project.rb +331 -0
  50. data/lib/spurline/cli/generators/tool.rb +98 -0
  51. data/lib/spurline/cli/router.rb +121 -0
  52. data/lib/spurline/configuration.rb +23 -0
  53. data/lib/spurline/dsl/CLAUDE.md +11 -0
  54. data/lib/spurline/dsl/guardrails.rb +108 -0
  55. data/lib/spurline/dsl/hooks.rb +51 -0
  56. data/lib/spurline/dsl/memory.rb +39 -0
  57. data/lib/spurline/dsl/model.rb +23 -0
  58. data/lib/spurline/dsl/persona.rb +74 -0
  59. data/lib/spurline/dsl/suspend_until.rb +53 -0
  60. data/lib/spurline/dsl/tools.rb +176 -0
  61. data/lib/spurline/errors.rb +109 -0
  62. data/lib/spurline/lifecycle/CLAUDE.md +18 -0
  63. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  64. data/lib/spurline/lifecycle/runner.rb +456 -0
  65. data/lib/spurline/lifecycle/states.rb +47 -0
  66. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  67. data/lib/spurline/memory/CLAUDE.md +12 -0
  68. data/lib/spurline/memory/context_assembler.rb +100 -0
  69. data/lib/spurline/memory/embedder/CLAUDE.md +11 -0
  70. data/lib/spurline/memory/embedder/base.rb +17 -0
  71. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  72. data/lib/spurline/memory/episode.rb +56 -0
  73. data/lib/spurline/memory/episodic_store.rb +147 -0
  74. data/lib/spurline/memory/long_term/CLAUDE.md +11 -0
  75. data/lib/spurline/memory/long_term/base.rb +22 -0
  76. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  77. data/lib/spurline/memory/manager.rb +147 -0
  78. data/lib/spurline/memory/short_term.rb +57 -0
  79. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  80. data/lib/spurline/orchestration/judge.rb +109 -0
  81. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  82. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  83. data/lib/spurline/orchestration/ledger.rb +339 -0
  84. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  85. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  86. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  87. data/lib/spurline/persona/base.rb +42 -0
  88. data/lib/spurline/persona/registry.rb +42 -0
  89. data/lib/spurline/secrets/resolver.rb +65 -0
  90. data/lib/spurline/secrets/vault.rb +42 -0
  91. data/lib/spurline/security/content.rb +76 -0
  92. data/lib/spurline/security/context_pipeline.rb +58 -0
  93. data/lib/spurline/security/gates/base.rb +36 -0
  94. data/lib/spurline/security/gates/operator_config.rb +22 -0
  95. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  96. data/lib/spurline/security/gates/tool_result.rb +23 -0
  97. data/lib/spurline/security/gates/user_input.rb +22 -0
  98. data/lib/spurline/security/injection_scanner.rb +109 -0
  99. data/lib/spurline/security/pii_filter.rb +104 -0
  100. data/lib/spurline/session/CLAUDE.md +11 -0
  101. data/lib/spurline/session/resumption.rb +36 -0
  102. data/lib/spurline/session/serializer.rb +169 -0
  103. data/lib/spurline/session/session.rb +154 -0
  104. data/lib/spurline/session/store/CLAUDE.md +12 -0
  105. data/lib/spurline/session/store/base.rb +27 -0
  106. data/lib/spurline/session/store/memory.rb +45 -0
  107. data/lib/spurline/session/store/postgres.rb +123 -0
  108. data/lib/spurline/session/store/sqlite.rb +139 -0
  109. data/lib/spurline/session/suspension.rb +93 -0
  110. data/lib/spurline/session/turn.rb +98 -0
  111. data/lib/spurline/spur.rb +213 -0
  112. data/lib/spurline/streaming/CLAUDE.md +12 -0
  113. data/lib/spurline/streaming/buffer.rb +77 -0
  114. data/lib/spurline/streaming/chunk.rb +62 -0
  115. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  116. data/lib/spurline/testing.rb +245 -0
  117. data/lib/spurline/toolkit.rb +110 -0
  118. data/lib/spurline/tools/base.rb +209 -0
  119. data/lib/spurline/tools/idempotency.rb +220 -0
  120. data/lib/spurline/tools/permissions.rb +44 -0
  121. data/lib/spurline/tools/registry.rb +43 -0
  122. data/lib/spurline/tools/runner.rb +255 -0
  123. data/lib/spurline/tools/scope.rb +309 -0
  124. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  125. data/lib/spurline/version.rb +5 -0
  126. data/lib/spurline.rb +56 -0
  127. metadata +333 -0
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Orchestration
5
+ # Creates and runs child agents with permission-safe delegation.
6
+ #
7
+ # The setuid rule: child permissions are always <= parent permissions.
8
+ # Child scope inherits from the parent unless explicitly narrowed.
9
+ class AgentSpawner
10
+ def initialize(parent_agent:)
11
+ @parent_agent = parent_agent
12
+ @parent_session = parent_agent.session
13
+ @parent_scope = extract_scope(parent_agent)
14
+ @parent_permissions = extract_permissions(parent_agent)
15
+ end
16
+
17
+ # ASYNC-READY: spawns and runs a child agent, which is a blocking operation
18
+ def spawn(agent_class, input:, permissions: nil, scope: nil, &block)
19
+ validate_agent_class!(agent_class)
20
+
21
+ effective_permissions = compute_effective_permissions(permissions)
22
+ effective_scope = compute_effective_scope(scope)
23
+
24
+ child_agent = build_child_agent(
25
+ agent_class: agent_class,
26
+ permissions: effective_permissions,
27
+ scope: effective_scope
28
+ )
29
+
30
+ child_agent.session.metadata[:parent_session_id] = @parent_session.id
31
+ child_agent.session.metadata[:parent_agent_class] = @parent_agent.class.name
32
+
33
+ fire_parent_hook(:on_child_spawn, child_agent, agent_class)
34
+
35
+ begin
36
+ child_agent.run(input) do |chunk|
37
+ block&.call(chunk)
38
+ end
39
+
40
+ fire_parent_hook(:on_child_complete, child_agent, child_agent.session)
41
+ rescue Spurline::AgentError => e
42
+ fire_parent_hook(:on_child_error, child_agent, e)
43
+ raise Spurline::SpawnError,
44
+ "Child agent #{agent_class.name || agent_class} failed: #{e.message}. " \
45
+ "Parent session: #{@parent_session.id}, child session: #{child_agent.session.id}."
46
+ end
47
+
48
+ child_agent.session
49
+ end
50
+
51
+ private
52
+
53
+ def validate_agent_class!(agent_class)
54
+ unless agent_class.is_a?(Class) && agent_class <= Spurline::Agent
55
+ raise Spurline::ConfigurationError,
56
+ "spawn_agent requires a class that inherits from Spurline::Agent. " \
57
+ "Got: #{agent_class.inspect}"
58
+ end
59
+ end
60
+
61
+ def compute_effective_permissions(child_permissions)
62
+ return deep_copy(@parent_permissions) if child_permissions.nil?
63
+
64
+ PermissionIntersection.validate_no_escalation!(
65
+ @parent_permissions,
66
+ child_permissions
67
+ )
68
+
69
+ PermissionIntersection.compute(
70
+ @parent_permissions,
71
+ child_permissions
72
+ )
73
+ end
74
+
75
+ def compute_effective_scope(child_scope)
76
+ return @parent_scope if child_scope.nil?
77
+ return child_scope if @parent_scope.nil?
78
+
79
+ if child_scope.is_a?(Spurline::Tools::Scope)
80
+ validate_scope_subset!(child_scope) if @parent_scope
81
+ child_scope
82
+ elsif child_scope.is_a?(Hash)
83
+ @parent_scope.narrow(child_scope)
84
+ else
85
+ raise Spurline::ConfigurationError,
86
+ "scope must be a Spurline::Tools::Scope or a Hash of constraints. " \
87
+ "Got: #{child_scope.class} (#{child_scope.inspect})"
88
+ end
89
+ end
90
+
91
+ def validate_scope_subset!(child_scope)
92
+ return if child_scope.subset_of?(@parent_scope)
93
+
94
+ raise Spurline::ScopeViolationError,
95
+ "Child scope '#{child_scope.id}' is wider than parent scope '#{@parent_scope.id}'. " \
96
+ "A spawned agent cannot access resources outside the parent's scope. " \
97
+ "Narrow the child scope or widen the parent scope."
98
+ end
99
+
100
+ def build_child_agent(agent_class:, permissions:, scope:)
101
+ child_agent = agent_class.new(
102
+ user: @parent_session.user,
103
+ scope: scope
104
+ )
105
+
106
+ inject_effective_permissions!(child_agent, permissions)
107
+ child_agent
108
+ end
109
+
110
+ def inject_effective_permissions!(child_agent, permissions)
111
+ return if permissions.nil?
112
+
113
+ tool_runner = child_agent.instance_variable_get(:@tool_runner)
114
+ return unless tool_runner
115
+
116
+ existing_permissions = tool_runner.instance_variable_get(:@permissions) || {}
117
+ merged_permissions = existing_permissions.merge(permissions)
118
+ tool_runner.instance_variable_set(:@permissions, merged_permissions)
119
+ end
120
+
121
+ def extract_scope(agent)
122
+ agent.instance_variable_get(:@scope)
123
+ end
124
+
125
+ def extract_permissions(agent)
126
+ klass = agent.class
127
+ return {} unless klass.respond_to?(:permissions_config)
128
+
129
+ klass.permissions_config
130
+ end
131
+
132
+ def fire_parent_hook(hook_type, *args)
133
+ hooks = @parent_agent.class.hooks_config[hook_type] || []
134
+ hooks.each { |hook_block| hook_block.call(*args) }
135
+ end
136
+
137
+ def deep_copy(value)
138
+ case value
139
+ when Hash
140
+ value.each_with_object({}) do |(key, item), copy|
141
+ copy[key] = deep_copy(item)
142
+ end
143
+ when Array
144
+ value.map { |item| deep_copy(item) }
145
+ else
146
+ value
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Orchestration
5
+ # Stateless evaluator that decides whether worker output satisfies a task.
6
+ class Judge
7
+ STRATEGIES = %i[structured llm_eval custom].freeze
8
+
9
+ Verdict = Struct.new(:decision, :reason, :feedback, keyword_init: true) do
10
+ def accepted?
11
+ decision == :accept
12
+ end
13
+
14
+ def rejected?
15
+ decision == :reject
16
+ end
17
+
18
+ def needs_revision?
19
+ decision == :revise
20
+ end
21
+ end
22
+
23
+ def initialize(strategy: :structured)
24
+ @strategy = strategy.to_sym
25
+ validate_strategy!(@strategy)
26
+ end
27
+
28
+ # ASYNC-READY: evaluate may call an LLM for :llm_eval strategy.
29
+ def evaluate(envelope:, output:, scheduler: Adapters::Scheduler::Sync.new, &custom_evaluator)
30
+ scheduler.run do
31
+ case @strategy
32
+ when :structured
33
+ evaluate_structured(envelope, output)
34
+ when :llm_eval
35
+ Verdict.new(
36
+ decision: :accept,
37
+ reason: "LLM evaluator stub",
38
+ feedback: "llm_eval strategy is a placeholder in M2.4"
39
+ )
40
+ when :custom
41
+ evaluate_custom(envelope, output, &custom_evaluator)
42
+ end
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def evaluate_structured(envelope, output)
49
+ output_text = normalize_output(output)
50
+ criteria = envelope.acceptance_criteria.map(&:to_s)
51
+
52
+ missing = criteria.reject do |criterion|
53
+ output_text.downcase.include?(criterion.downcase)
54
+ end
55
+
56
+ if missing.empty?
57
+ Verdict.new(decision: :accept, reason: "All acceptance criteria matched", feedback: nil)
58
+ else
59
+ Verdict.new(
60
+ decision: :reject,
61
+ reason: "Missing acceptance criteria",
62
+ feedback: "Missing: #{missing.join(", ")}"
63
+ )
64
+ end
65
+ end
66
+
67
+ def evaluate_custom(envelope, output, &block)
68
+ raise ArgumentError, "custom evaluator block is required" unless block
69
+
70
+ result = block.call(envelope, output)
71
+
72
+ case result
73
+ when Verdict
74
+ result
75
+ when true
76
+ Verdict.new(decision: :accept, reason: "custom evaluator accepted", feedback: nil)
77
+ when false
78
+ Verdict.new(decision: :reject, reason: "custom evaluator rejected", feedback: nil)
79
+ when Hash
80
+ decision = (result[:decision] || result["decision"] || :reject).to_sym
81
+ reason = result[:reason] || result["reason"] || "custom evaluator result"
82
+ feedback = result[:feedback] || result["feedback"]
83
+ Verdict.new(decision: decision, reason: reason, feedback: feedback)
84
+ else
85
+ raise ArgumentError, "custom evaluator must return Verdict, boolean, or hash"
86
+ end
87
+ end
88
+
89
+ def validate_strategy!(strategy)
90
+ return if STRATEGIES.include?(strategy)
91
+
92
+ raise Spurline::ConfigurationError, "invalid judge strategy: #{strategy.inspect}"
93
+ end
94
+
95
+ def normalize_output(output)
96
+ case output
97
+ when String
98
+ output
99
+ when Hash
100
+ output.map { |key, value| "#{key}: #{value}" }.join("\n")
101
+ when Array
102
+ output.join("\n")
103
+ else
104
+ output.to_s
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Orchestration
5
+ class Ledger
6
+ module Store
7
+ # Abstract interface for ledger storage adapters.
8
+ class Base
9
+ def save_ledger(_ledger)
10
+ raise NotImplementedError, "#{self.class.name} must implement #save_ledger"
11
+ end
12
+
13
+ def load_ledger(_id)
14
+ raise NotImplementedError, "#{self.class.name} must implement #load_ledger"
15
+ end
16
+
17
+ def exists?(_id)
18
+ raise NotImplementedError, "#{self.class.name} must implement #exists?"
19
+ end
20
+
21
+ def delete(_id)
22
+ raise NotImplementedError, "#{self.class.name} must implement #delete"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Orchestration
5
+ class Ledger
6
+ module Store
7
+ # In-memory ledger store for tests and local development.
8
+ class Memory < Base
9
+ def initialize
10
+ @ledgers = {}
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def save_ledger(ledger)
15
+ @mutex.synchronize do
16
+ @ledgers[ledger.id] = ledger.to_h
17
+ end
18
+ end
19
+
20
+ def load_ledger(id)
21
+ payload = @mutex.synchronize { @ledgers[id.to_s] }
22
+ raise Spurline::LedgerError, "ledger not found: #{id}" if payload.nil?
23
+
24
+ Spurline::Orchestration::Ledger.from_h(payload, store: self)
25
+ end
26
+
27
+ def exists?(id)
28
+ @mutex.synchronize { @ledgers.key?(id.to_s) }
29
+ end
30
+
31
+ def delete(id)
32
+ @mutex.synchronize { @ledgers.delete(id.to_s) }
33
+ end
34
+
35
+ def size
36
+ @mutex.synchronize { @ledgers.size }
37
+ end
38
+
39
+ def clear!
40
+ @mutex.synchronize { @ledgers.clear }
41
+ end
42
+
43
+ def ids
44
+ @mutex.synchronize { @ledgers.keys.dup }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Spurline
7
+ module Orchestration
8
+ # Workflow state machine for planner/worker/judge orchestration.
9
+ class Ledger
10
+ STATES = %i[planning executing merging complete error].freeze
11
+
12
+ VALID_TRANSITIONS = {
13
+ planning: [:executing, :error],
14
+ executing: [:merging, :error],
15
+ merging: [:complete, :executing, :error],
16
+ complete: [],
17
+ error: [],
18
+ }.freeze
19
+
20
+ TASK_STATES = %i[pending assigned running complete failed].freeze
21
+
22
+ attr_reader :id, :state, :plan, :tasks, :dependency_graph,
23
+ :merged_output, :metadata, :created_at
24
+
25
+ def initialize(id: SecureRandom.uuid, store: nil)
26
+ @id = id.to_s
27
+ @state = :planning
28
+ @plan = []
29
+ @tasks = {}
30
+ @dependency_graph = {}
31
+ @merged_output = {}
32
+ @metadata = {}
33
+ @created_at = Time.now.utc
34
+ @store = store
35
+ end
36
+
37
+ # @param envelope [TaskEnvelope]
38
+ # @return [TaskEnvelope]
39
+ def add_task(envelope)
40
+ assert_state!(:planning, "tasks can only be added during planning")
41
+
42
+ normalized = normalize_envelope(envelope)
43
+ task_id = normalized.task_id
44
+ raise Spurline::LedgerError, "task already exists: #{task_id}" if @tasks.key?(task_id)
45
+
46
+ @tasks[task_id] = {
47
+ envelope: normalized,
48
+ state: :pending,
49
+ worker_session_id: nil,
50
+ output: nil,
51
+ error: nil,
52
+ }
53
+ @dependency_graph[task_id] = []
54
+ @plan << task_id
55
+ persist!
56
+ normalized
57
+ end
58
+
59
+ def add_dependency(task_id, depends_on:)
60
+ task_id = task_id.to_s
61
+ depends_on = depends_on.to_s
62
+
63
+ fetch_task!(task_id)
64
+ fetch_task!(depends_on)
65
+
66
+ if task_id == depends_on
67
+ raise Spurline::LedgerError, "task cannot depend on itself: #{task_id}"
68
+ end
69
+
70
+ deps = (@dependency_graph[task_id] ||= [])
71
+ deps << depends_on unless deps.include?(depends_on)
72
+ persist!
73
+ deps
74
+ end
75
+
76
+ def assign_task(task_id, worker_session_id:)
77
+ task = fetch_task!(task_id)
78
+ ensure_task_state!(task_id, expected: :pending)
79
+
80
+ if worker_session_id.to_s.strip.empty?
81
+ raise Spurline::LedgerError, "worker_session_id is required"
82
+ end
83
+
84
+ task[:state] = :assigned
85
+ task[:worker_session_id] = worker_session_id.to_s
86
+ task[:error] = nil
87
+ persist!
88
+ task
89
+ end
90
+
91
+ def start_task(task_id)
92
+ task = fetch_task!(task_id)
93
+ ensure_task_state!(task_id, expected: :assigned)
94
+
95
+ task[:state] = :running
96
+ persist!
97
+ task
98
+ end
99
+
100
+ def complete_task(task_id, output:)
101
+ task = fetch_task!(task_id)
102
+ ensure_task_state_in!(task_id, expected: %i[running assigned])
103
+
104
+ task[:state] = :complete
105
+ task[:output] = deep_copy(output)
106
+ task[:error] = nil
107
+ persist!
108
+ task
109
+ end
110
+
111
+ def fail_task(task_id, error:)
112
+ task = fetch_task!(task_id)
113
+ ensure_task_state_in!(task_id, expected: %i[running assigned])
114
+
115
+ task[:state] = :failed
116
+ task[:error] = error.to_s
117
+ persist!
118
+ task
119
+ end
120
+
121
+ def task_status(task_id)
122
+ fetch_task!(task_id)[:state]
123
+ end
124
+
125
+ def all_tasks_complete?
126
+ @tasks.values.all? { |task| task[:state] == :complete }
127
+ end
128
+
129
+ def completed_tasks
130
+ select_tasks_by_state(:complete)
131
+ end
132
+
133
+ def pending_tasks
134
+ select_tasks_by_state(:pending)
135
+ end
136
+
137
+ # pending tasks whose dependencies are all complete
138
+ def unblocked_tasks
139
+ pending_tasks.select do |task_id, _task|
140
+ dependencies = @dependency_graph[task_id] || []
141
+ dependencies.all? { |dep_id| task_status(dep_id) == :complete }
142
+ end
143
+ end
144
+
145
+ def transition_to!(new_state)
146
+ target = new_state.to_sym
147
+
148
+ unless STATES.include?(target)
149
+ raise Spurline::LedgerError, "invalid ledger state: #{new_state.inspect}"
150
+ end
151
+
152
+ allowed = VALID_TRANSITIONS.fetch(@state)
153
+ unless allowed.include?(target)
154
+ raise Spurline::LedgerError, "invalid transition #{@state} -> #{target}"
155
+ end
156
+
157
+ @state = target
158
+ persist!
159
+ @state
160
+ end
161
+
162
+ def to_h
163
+ {
164
+ id: id,
165
+ state: state,
166
+ plan: deep_copy(plan),
167
+ tasks: serialized_tasks,
168
+ dependency_graph: deep_copy(dependency_graph),
169
+ merged_output: deep_copy(merged_output),
170
+ metadata: deep_copy(metadata),
171
+ created_at: created_at.utc.iso8601,
172
+ }
173
+ end
174
+
175
+ def self.from_h(data, store: nil)
176
+ hash = data || {}
177
+ ledger = new(id: fetch_key(hash, :id, required: true), store: store)
178
+
179
+ state = (fetch_key(hash, :state) || :planning).to_sym
180
+ unless STATES.include?(state)
181
+ raise Spurline::LedgerError, "invalid ledger state: #{state.inspect}"
182
+ end
183
+
184
+ plan = Array(fetch_key(hash, :plan) || []).map(&:to_s)
185
+ tasks = deserialize_tasks(fetch_key(hash, :tasks) || {})
186
+ dependency_graph = deserialize_dependency_graph(fetch_key(hash, :dependency_graph) || {})
187
+
188
+ ledger.instance_variable_set(:@state, state)
189
+ ledger.instance_variable_set(:@plan, plan)
190
+ ledger.instance_variable_set(:@tasks, tasks)
191
+ ledger.instance_variable_set(:@dependency_graph, dependency_graph)
192
+ ledger.instance_variable_set(:@merged_output, ledger.send(:deep_copy, fetch_key(hash, :merged_output) || {}))
193
+ ledger.instance_variable_set(:@metadata, ledger.send(:deep_copy, fetch_key(hash, :metadata) || {}))
194
+ ledger.instance_variable_set(:@created_at, parse_time(fetch_key(hash, :created_at)))
195
+
196
+ ledger
197
+ end
198
+
199
+ private
200
+
201
+ def persist!
202
+ @store&.save_ledger(self)
203
+ end
204
+
205
+ def normalize_envelope(envelope)
206
+ return envelope if envelope.is_a?(TaskEnvelope)
207
+
208
+ if envelope.is_a?(Hash)
209
+ return TaskEnvelope.from_h(envelope)
210
+ end
211
+
212
+ raise Spurline::LedgerError, "envelope must be a TaskEnvelope or Hash"
213
+ end
214
+
215
+ def fetch_task!(task_id)
216
+ id = task_id.to_s
217
+ @tasks.fetch(id) do
218
+ raise Spurline::LedgerError, "unknown task: #{id}"
219
+ end
220
+ end
221
+
222
+ def ensure_task_state!(task_id, expected:)
223
+ actual = task_status(task_id)
224
+ return if actual == expected
225
+
226
+ raise Spurline::LedgerError, "task #{task_id} must be #{expected}, got #{actual}"
227
+ end
228
+
229
+ def ensure_task_state_in!(task_id, expected:)
230
+ actual = task_status(task_id)
231
+ return if expected.include?(actual)
232
+
233
+ raise Spurline::LedgerError, "task #{task_id} must be one of #{expected.inspect}, got #{actual}"
234
+ end
235
+
236
+ def assert_state!(expected, message)
237
+ return if state == expected
238
+
239
+ raise Spurline::LedgerError, message
240
+ end
241
+
242
+ def select_tasks_by_state(target)
243
+ @tasks.each_with_object({}) do |(task_id, task), selected|
244
+ next unless task[:state] == target
245
+
246
+ selected[task_id] = snapshot_task(task)
247
+ end
248
+ end
249
+
250
+ def snapshot_task(task)
251
+ {
252
+ envelope: task[:envelope],
253
+ state: task[:state],
254
+ worker_session_id: task[:worker_session_id],
255
+ output: deep_copy(task[:output]),
256
+ error: task[:error],
257
+ }
258
+ end
259
+
260
+ def serialized_tasks
261
+ @tasks.each_with_object({}) do |(task_id, task), serialized|
262
+ serialized[task_id] = {
263
+ envelope: task[:envelope].to_h,
264
+ state: task[:state],
265
+ worker_session_id: task[:worker_session_id],
266
+ output: deep_copy(task[:output]),
267
+ error: task[:error],
268
+ }
269
+ end
270
+ end
271
+
272
+ def deep_copy(value)
273
+ case value
274
+ when Hash
275
+ value.each_with_object({}) do |(key, item), copy|
276
+ copy[key] = deep_copy(item)
277
+ end
278
+ when Array
279
+ value.map { |item| deep_copy(item) }
280
+ else
281
+ value
282
+ end
283
+ end
284
+
285
+ class << self
286
+ private
287
+
288
+ def parse_time(value)
289
+ return Time.now.utc if value.nil?
290
+ return value.utc if value.respond_to?(:utc)
291
+
292
+ Time.parse(value.to_s).utc
293
+ end
294
+
295
+ def deserialize_tasks(raw_tasks)
296
+ (raw_tasks || {}).each_with_object({}) do |(task_id, task_data), deserialized|
297
+ task_hash = task_data || {}
298
+ envelope_data = fetch_key(task_hash, :envelope, required: true) do
299
+ raise Spurline::LedgerError, "task #{task_id} missing envelope"
300
+ end
301
+
302
+ envelope = envelope_data.is_a?(TaskEnvelope) ? envelope_data : TaskEnvelope.from_h(envelope_data)
303
+ task_state = (fetch_key(task_hash, :state) || :pending).to_sym
304
+
305
+ unless TASK_STATES.include?(task_state)
306
+ raise Spurline::LedgerError, "invalid task state for #{task_id}: #{task_state.inspect}"
307
+ end
308
+
309
+ deserialized[task_id.to_s] = {
310
+ envelope: envelope,
311
+ state: task_state,
312
+ worker_session_id: fetch_key(task_hash, :worker_session_id),
313
+ output: fetch_key(task_hash, :output),
314
+ error: fetch_key(task_hash, :error),
315
+ }
316
+ end
317
+ end
318
+
319
+ def deserialize_dependency_graph(raw_graph)
320
+ (raw_graph || {}).each_with_object({}) do |(task_id, deps), graph|
321
+ graph[task_id.to_s] = Array(deps).map(&:to_s)
322
+ end
323
+ end
324
+
325
+ def fetch_key(hash, key, required: false, &block)
326
+ if hash.is_a?(Hash) && hash.key?(key)
327
+ hash[key]
328
+ elsif hash.is_a?(Hash) && hash.key?(key.to_s)
329
+ hash[key.to_s]
330
+ elsif required
331
+ return block.call if block
332
+
333
+ raise KeyError, "missing key: #{key}"
334
+ end
335
+ end
336
+ end
337
+ end
338
+ end
339
+ end