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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +177 -0
- data/exe/spur +6 -0
- data/lib/CLAUDE.md +11 -0
- data/lib/spurline/CLAUDE.md +16 -0
- data/lib/spurline/adapters/CLAUDE.md +12 -0
- data/lib/spurline/adapters/base.rb +17 -0
- data/lib/spurline/adapters/claude.rb +208 -0
- data/lib/spurline/adapters/open_ai.rb +213 -0
- data/lib/spurline/adapters/registry.rb +33 -0
- data/lib/spurline/adapters/scheduler/base.rb +15 -0
- data/lib/spurline/adapters/scheduler/sync.rb +15 -0
- data/lib/spurline/adapters/stub_adapter.rb +54 -0
- data/lib/spurline/agent.rb +433 -0
- data/lib/spurline/audit/log.rb +156 -0
- data/lib/spurline/audit/secret_filter.rb +121 -0
- data/lib/spurline/base.rb +130 -0
- data/lib/spurline/cartographer/CLAUDE.md +12 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -0
- data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
- data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
- data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
- data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
- data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
- data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
- data/lib/spurline/cartographer/repo_profile.rb +140 -0
- data/lib/spurline/cartographer/runner.rb +88 -0
- data/lib/spurline/cartographer.rb +6 -0
- data/lib/spurline/channels/base.rb +41 -0
- data/lib/spurline/channels/event.rb +136 -0
- data/lib/spurline/channels/github.rb +205 -0
- data/lib/spurline/channels/router.rb +103 -0
- data/lib/spurline/cli/check.rb +88 -0
- data/lib/spurline/cli/checks/CLAUDE.md +11 -0
- data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
- data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
- data/lib/spurline/cli/checks/base.rb +35 -0
- data/lib/spurline/cli/checks/credentials.rb +43 -0
- data/lib/spurline/cli/checks/permissions.rb +22 -0
- data/lib/spurline/cli/checks/project_structure.rb +48 -0
- data/lib/spurline/cli/checks/session_store.rb +97 -0
- data/lib/spurline/cli/console.rb +73 -0
- data/lib/spurline/cli/credentials.rb +181 -0
- data/lib/spurline/cli/generators/CLAUDE.md +11 -0
- data/lib/spurline/cli/generators/agent.rb +123 -0
- data/lib/spurline/cli/generators/migration.rb +62 -0
- data/lib/spurline/cli/generators/project.rb +331 -0
- data/lib/spurline/cli/generators/tool.rb +98 -0
- data/lib/spurline/cli/router.rb +121 -0
- data/lib/spurline/configuration.rb +23 -0
- data/lib/spurline/dsl/CLAUDE.md +11 -0
- data/lib/spurline/dsl/guardrails.rb +108 -0
- data/lib/spurline/dsl/hooks.rb +51 -0
- data/lib/spurline/dsl/memory.rb +39 -0
- data/lib/spurline/dsl/model.rb +23 -0
- data/lib/spurline/dsl/persona.rb +74 -0
- data/lib/spurline/dsl/suspend_until.rb +53 -0
- data/lib/spurline/dsl/tools.rb +176 -0
- data/lib/spurline/errors.rb +109 -0
- data/lib/spurline/lifecycle/CLAUDE.md +18 -0
- data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
- data/lib/spurline/lifecycle/runner.rb +456 -0
- data/lib/spurline/lifecycle/states.rb +47 -0
- data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
- data/lib/spurline/memory/CLAUDE.md +12 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/CLAUDE.md +11 -0
- data/lib/spurline/memory/embedder/base.rb +17 -0
- data/lib/spurline/memory/embedder/open_ai.rb +70 -0
- data/lib/spurline/memory/episode.rb +56 -0
- data/lib/spurline/memory/episodic_store.rb +147 -0
- data/lib/spurline/memory/long_term/CLAUDE.md +11 -0
- data/lib/spurline/memory/long_term/base.rb +22 -0
- data/lib/spurline/memory/long_term/postgres.rb +106 -0
- data/lib/spurline/memory/manager.rb +147 -0
- data/lib/spurline/memory/short_term.rb +57 -0
- data/lib/spurline/orchestration/agent_spawner.rb +151 -0
- data/lib/spurline/orchestration/judge.rb +109 -0
- data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
- data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
- data/lib/spurline/orchestration/ledger.rb +339 -0
- data/lib/spurline/orchestration/merge_queue.rb +133 -0
- data/lib/spurline/orchestration/permission_intersection.rb +151 -0
- data/lib/spurline/orchestration/task_envelope.rb +201 -0
- data/lib/spurline/persona/base.rb +42 -0
- data/lib/spurline/persona/registry.rb +42 -0
- data/lib/spurline/secrets/resolver.rb +65 -0
- data/lib/spurline/secrets/vault.rb +42 -0
- data/lib/spurline/security/content.rb +76 -0
- data/lib/spurline/security/context_pipeline.rb +58 -0
- data/lib/spurline/security/gates/base.rb +36 -0
- data/lib/spurline/security/gates/operator_config.rb +22 -0
- data/lib/spurline/security/gates/system_prompt.rb +23 -0
- data/lib/spurline/security/gates/tool_result.rb +23 -0
- data/lib/spurline/security/gates/user_input.rb +22 -0
- data/lib/spurline/security/injection_scanner.rb +109 -0
- data/lib/spurline/security/pii_filter.rb +104 -0
- data/lib/spurline/session/CLAUDE.md +11 -0
- data/lib/spurline/session/resumption.rb +36 -0
- data/lib/spurline/session/serializer.rb +169 -0
- data/lib/spurline/session/session.rb +154 -0
- data/lib/spurline/session/store/CLAUDE.md +12 -0
- data/lib/spurline/session/store/base.rb +27 -0
- data/lib/spurline/session/store/memory.rb +45 -0
- data/lib/spurline/session/store/postgres.rb +123 -0
- data/lib/spurline/session/store/sqlite.rb +139 -0
- data/lib/spurline/session/suspension.rb +93 -0
- data/lib/spurline/session/turn.rb +98 -0
- data/lib/spurline/spur.rb +213 -0
- data/lib/spurline/streaming/CLAUDE.md +12 -0
- data/lib/spurline/streaming/buffer.rb +77 -0
- data/lib/spurline/streaming/chunk.rb +62 -0
- data/lib/spurline/streaming/stream_enumerator.rb +29 -0
- data/lib/spurline/testing.rb +245 -0
- data/lib/spurline/toolkit.rb +110 -0
- data/lib/spurline/tools/base.rb +209 -0
- data/lib/spurline/tools/idempotency.rb +220 -0
- data/lib/spurline/tools/permissions.rb +44 -0
- data/lib/spurline/tools/registry.rb +43 -0
- data/lib/spurline/tools/runner.rb +255 -0
- data/lib/spurline/tools/scope.rb +309 -0
- data/lib/spurline/tools/toolkit_registry.rb +63 -0
- data/lib/spurline/version.rb +5 -0
- data/lib/spurline.rb +56 -0
- 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
|