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,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
module Session
|
|
7
|
+
# The running record of an agent conversation. Framework-owned (ADR-004).
|
|
8
|
+
# Sessions are created via .load_or_create — never call .new directly in agent code.
|
|
9
|
+
#
|
|
10
|
+
# State transitions are enforced via Lifecycle::States.
|
|
11
|
+
class Session
|
|
12
|
+
attr_reader :id, :agent_class, :user, :turns, :state,
|
|
13
|
+
:started_at, :finished_at, :metadata
|
|
14
|
+
|
|
15
|
+
def initialize(id:, store:, agent_class: nil, user: nil)
|
|
16
|
+
@id = id
|
|
17
|
+
@store = store
|
|
18
|
+
@agent_class = agent_class
|
|
19
|
+
@user = user
|
|
20
|
+
@turns = []
|
|
21
|
+
@state = :ready
|
|
22
|
+
@started_at = Time.now
|
|
23
|
+
@finished_at = nil
|
|
24
|
+
@metadata = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The only way to get a session. Loads an existing session by ID,
|
|
28
|
+
# or creates a new one if it doesn't exist.
|
|
29
|
+
def self.load_or_create(id: nil, store:, **opts)
|
|
30
|
+
id ||= SecureRandom.uuid
|
|
31
|
+
|
|
32
|
+
if store.exists?(id)
|
|
33
|
+
store.load(id)
|
|
34
|
+
else
|
|
35
|
+
session = new(id: id, store: store, **opts)
|
|
36
|
+
store.save(session)
|
|
37
|
+
session
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Rebuilds a session from persisted attributes without running initialize.
|
|
42
|
+
def self.restore(data, store:)
|
|
43
|
+
session = allocate
|
|
44
|
+
session.instance_variable_set(:@id, data[:id])
|
|
45
|
+
session.instance_variable_set(:@store, store)
|
|
46
|
+
session.instance_variable_set(:@agent_class, data[:agent_class])
|
|
47
|
+
session.instance_variable_set(:@user, data[:user])
|
|
48
|
+
session.instance_variable_set(:@turns, data[:turns])
|
|
49
|
+
session.instance_variable_set(:@state, data[:state])
|
|
50
|
+
session.instance_variable_set(:@started_at, data[:started_at])
|
|
51
|
+
session.instance_variable_set(:@finished_at, data[:finished_at])
|
|
52
|
+
session.instance_variable_set(:@metadata, data[:metadata] || {})
|
|
53
|
+
session
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def start_turn(input:)
|
|
57
|
+
turn = Turn.new(input: input, number: turns.length + 1)
|
|
58
|
+
@turns << turn
|
|
59
|
+
@metadata[:last_turn_started_at] = turn.started_at
|
|
60
|
+
turn
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def current_turn
|
|
64
|
+
@turns.last
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def finish_turn!(output:)
|
|
68
|
+
current_turn&.finish!(output: output)
|
|
69
|
+
save!
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def tool_calls
|
|
73
|
+
turns.flat_map(&:tool_calls)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def tool_call_count
|
|
77
|
+
tool_calls.length
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def turn_count
|
|
81
|
+
turns.length
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Enforces valid state transitions via Lifecycle::States.
|
|
85
|
+
def transition_to!(new_state)
|
|
86
|
+
Lifecycle::States.validate_transition!(@state, new_state)
|
|
87
|
+
@state = new_state
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def complete!
|
|
91
|
+
@state = :complete
|
|
92
|
+
@finished_at = Time.now
|
|
93
|
+
@metadata[:total_turns] = turn_count
|
|
94
|
+
@metadata[:total_tool_calls] = tool_call_count
|
|
95
|
+
@metadata[:total_duration_ms] = total_duration_ms
|
|
96
|
+
save!
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def error!(error = nil)
|
|
100
|
+
@state = :error
|
|
101
|
+
@finished_at = Time.now
|
|
102
|
+
@metadata[:last_error] = error&.message
|
|
103
|
+
@metadata[:last_error_class] = error&.class&.name
|
|
104
|
+
save!
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Suspends the session and persists a checkpoint for later resumption.
|
|
108
|
+
def suspend!(checkpoint:)
|
|
109
|
+
Suspension.suspend!(self, checkpoint: checkpoint)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Resumes a suspended session and clears the persisted checkpoint.
|
|
113
|
+
def resume!
|
|
114
|
+
Suspension.resume!(self)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Whether the session is currently suspended.
|
|
118
|
+
def suspended?
|
|
119
|
+
Suspension.suspended?(self)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Duration in seconds (float).
|
|
123
|
+
def duration
|
|
124
|
+
return nil unless finished_at
|
|
125
|
+
|
|
126
|
+
finished_at - started_at
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Duration in milliseconds (integer).
|
|
130
|
+
def total_duration_ms
|
|
131
|
+
return nil unless finished_at
|
|
132
|
+
|
|
133
|
+
((finished_at - started_at) * 1000).round
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Compact summary for logging and debugging.
|
|
137
|
+
def summary
|
|
138
|
+
{
|
|
139
|
+
id: id,
|
|
140
|
+
state: state,
|
|
141
|
+
turns: turn_count,
|
|
142
|
+
tool_calls: tool_call_count,
|
|
143
|
+
duration_ms: total_duration_ms,
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def save!
|
|
150
|
+
@store.save(self)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<claude-mem-context>
|
|
2
|
+
# Recent Activity
|
|
3
|
+
|
|
4
|
+
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
|
5
|
+
|
|
6
|
+
### Feb 21, 2026
|
|
7
|
+
|
|
8
|
+
| ID | Time | T | Title | Read |
|
|
9
|
+
|----|------|---|-------|------|
|
|
10
|
+
| #3782 | 10:23 PM | 🔵 | Spurline session and state machine architecture analyzed for suspended sessions feature | ~700 |
|
|
11
|
+
| #3661 | 7:57 PM | 🔵 | Code quality review confirmed Plans 01-02 are production-ready with zero issues | ~791 |
|
|
12
|
+
</claude-mem-context>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Session
|
|
5
|
+
module Store
|
|
6
|
+
# Abstract interface for session storage adapters (ADR-004).
|
|
7
|
+
# The framework owns session persistence — developers do not manage it.
|
|
8
|
+
class Base
|
|
9
|
+
def save(session)
|
|
10
|
+
raise NotImplementedError, "#{self.class.name} must implement #save"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def load(id)
|
|
14
|
+
raise NotImplementedError, "#{self.class.name} must implement #load"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def delete(id)
|
|
18
|
+
raise NotImplementedError, "#{self.class.name} must implement #delete"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def exists?(id)
|
|
22
|
+
raise NotImplementedError, "#{self.class.name} must implement #exists?"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Session
|
|
5
|
+
module Store
|
|
6
|
+
# In-memory session store. Suitable for development and testing.
|
|
7
|
+
# Data does not persist across process restarts.
|
|
8
|
+
# Thread-safe via Mutex for concurrent access.
|
|
9
|
+
class Memory < Base
|
|
10
|
+
def initialize
|
|
11
|
+
@store = {}
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def save(session)
|
|
16
|
+
@mutex.synchronize { @store[session.id] = session }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def load(id)
|
|
20
|
+
@mutex.synchronize { @store[id] }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def delete(id)
|
|
24
|
+
@mutex.synchronize { @store.delete(id) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def exists?(id)
|
|
28
|
+
@mutex.synchronize { @store.key?(id) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def size
|
|
32
|
+
@mutex.synchronize { @store.size }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def clear!
|
|
36
|
+
@mutex.synchronize { @store.clear }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ids
|
|
40
|
+
@mutex.synchronize { @store.keys.dup }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
module Session
|
|
7
|
+
module Store
|
|
8
|
+
# PostgreSQL-backed session store. Persists sessions across process restarts.
|
|
9
|
+
# Thread-safe via a single connection guarded by a Mutex.
|
|
10
|
+
class Postgres < Base
|
|
11
|
+
TABLE_NAME = "spurline_sessions"
|
|
12
|
+
|
|
13
|
+
def initialize(url: Spurline.config.session_store_postgres_url, serializer: Spurline::Session::Serializer.new)
|
|
14
|
+
@url = url
|
|
15
|
+
@serializer = serializer
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@connection = nil
|
|
18
|
+
require_pg!
|
|
19
|
+
ensure_url!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def save(session)
|
|
23
|
+
now = Time.now.utc.iso8601(6)
|
|
24
|
+
payload = @serializer.to_json(session)
|
|
25
|
+
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
connection.exec_params(
|
|
28
|
+
<<~SQL,
|
|
29
|
+
INSERT INTO #{TABLE_NAME} (id, state, agent_class, created_at, updated_at, data)
|
|
30
|
+
VALUES ($1, $2, $3, COALESCE((SELECT created_at FROM #{TABLE_NAME} WHERE id = $1), $4), $5, $6::jsonb)
|
|
31
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
32
|
+
state = EXCLUDED.state,
|
|
33
|
+
agent_class = EXCLUDED.agent_class,
|
|
34
|
+
updated_at = EXCLUDED.updated_at,
|
|
35
|
+
data = EXCLUDED.data
|
|
36
|
+
SQL
|
|
37
|
+
[session.id, session.state.to_s, session.agent_class, now, now, payload]
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
session
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# ASYNC-READY: Keep read path isolated for future async driver swap.
|
|
45
|
+
def load(id)
|
|
46
|
+
row = @mutex.synchronize do
|
|
47
|
+
result = connection.exec_params(
|
|
48
|
+
"SELECT data::text AS data FROM #{TABLE_NAME} WHERE id = $1 LIMIT 1",
|
|
49
|
+
[id]
|
|
50
|
+
)
|
|
51
|
+
result.ntuples.positive? ? result[0] : nil
|
|
52
|
+
end
|
|
53
|
+
return nil unless row
|
|
54
|
+
|
|
55
|
+
payload = row.fetch("data")
|
|
56
|
+
@serializer.from_json(payload, store: self)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def delete(id)
|
|
60
|
+
@mutex.synchronize do
|
|
61
|
+
connection.exec_params("DELETE FROM #{TABLE_NAME} WHERE id = $1", [id])
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def exists?(id)
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
result = connection.exec_params("SELECT 1 FROM #{TABLE_NAME} WHERE id = $1 LIMIT 1", [id])
|
|
68
|
+
result.ntuples.positive?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def size
|
|
73
|
+
@mutex.synchronize do
|
|
74
|
+
connection.exec("SELECT COUNT(*) FROM #{TABLE_NAME}")[0]["count"].to_i
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def clear!
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
connection.exec("DELETE FROM #{TABLE_NAME}")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def ids
|
|
85
|
+
@mutex.synchronize do
|
|
86
|
+
connection.exec("SELECT id FROM #{TABLE_NAME} ORDER BY id").map { |row| row.fetch("id") }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def close
|
|
91
|
+
@mutex.synchronize do
|
|
92
|
+
@connection&.close
|
|
93
|
+
@connection = nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def ensure_url!
|
|
100
|
+
return if @url && !@url.strip.empty?
|
|
101
|
+
|
|
102
|
+
raise Spurline::ConfigurationError,
|
|
103
|
+
"session_store_postgres_url must be set when using :postgres session store."
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def require_pg!
|
|
107
|
+
return if defined?(::PG::Connection)
|
|
108
|
+
|
|
109
|
+
require "pg"
|
|
110
|
+
rescue LoadError
|
|
111
|
+
raise Spurline::PostgresUnavailableError,
|
|
112
|
+
"The 'pg' gem is required for the :postgres session store. Add gem \"pg\" to your Gemfile."
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ASYNC-READY: This connection boundary is where pooling/non-blocking
|
|
116
|
+
# clients can be introduced without changing the store contract.
|
|
117
|
+
def connection
|
|
118
|
+
@connection ||= PG.connect(@url)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Spurline
|
|
7
|
+
module Session
|
|
8
|
+
module Store
|
|
9
|
+
# SQLite-backed session store. Persists sessions across process restarts.
|
|
10
|
+
# Thread-safe via a single connection guarded by a Mutex.
|
|
11
|
+
class SQLite < Base
|
|
12
|
+
TABLE_NAME = "spurline_sessions"
|
|
13
|
+
|
|
14
|
+
def initialize(path: Spurline.config.session_store_path, serializer: Spurline::Session::Serializer.new)
|
|
15
|
+
@path = path
|
|
16
|
+
@serializer = serializer
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@db = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def save(session)
|
|
22
|
+
now = Time.now.utc.iso8601(6)
|
|
23
|
+
payload = @serializer.to_json(session)
|
|
24
|
+
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
db.execute(
|
|
27
|
+
<<~SQL,
|
|
28
|
+
INSERT OR REPLACE INTO #{TABLE_NAME}
|
|
29
|
+
(id, state, agent_class, created_at, updated_at, data)
|
|
30
|
+
VALUES (
|
|
31
|
+
?,
|
|
32
|
+
?,
|
|
33
|
+
?,
|
|
34
|
+
COALESCE((SELECT created_at FROM #{TABLE_NAME} WHERE id = ?), ?),
|
|
35
|
+
?,
|
|
36
|
+
?
|
|
37
|
+
)
|
|
38
|
+
SQL
|
|
39
|
+
[session.id, session.state.to_s, session.agent_class, session.id, now, now, payload]
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
session
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def load(id)
|
|
47
|
+
row = @mutex.synchronize do
|
|
48
|
+
db.get_first_row("SELECT data FROM #{TABLE_NAME} WHERE id = ? LIMIT 1", [id])
|
|
49
|
+
end
|
|
50
|
+
return nil unless row
|
|
51
|
+
|
|
52
|
+
payload = row.fetch("data")
|
|
53
|
+
@serializer.from_json(payload, store: self)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def delete(id)
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
db.execute("DELETE FROM #{TABLE_NAME} WHERE id = ?", [id])
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def exists?(id)
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
!db.get_first_value("SELECT 1 FROM #{TABLE_NAME} WHERE id = ? LIMIT 1", [id]).nil?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def size
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
db.get_first_value("SELECT COUNT(*) FROM #{TABLE_NAME}").to_i
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def clear!
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
db.execute("DELETE FROM #{TABLE_NAME}")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def ids
|
|
81
|
+
@mutex.synchronize do
|
|
82
|
+
db.execute("SELECT id FROM #{TABLE_NAME} ORDER BY id").map { |row| row.fetch("id") }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# ASYNC-READY: This connection boundary is where async adapters can
|
|
89
|
+
# introduce pooled or non-blocking persistence later.
|
|
90
|
+
def db
|
|
91
|
+
@db ||= begin
|
|
92
|
+
require_sqlite3!
|
|
93
|
+
ensure_parent_directory!
|
|
94
|
+
connection = ::SQLite3::Database.new(@path)
|
|
95
|
+
connection.results_as_hash = true
|
|
96
|
+
connection.busy_timeout(5_000)
|
|
97
|
+
connection.execute("PRAGMA journal_mode = WAL")
|
|
98
|
+
connection.execute("PRAGMA synchronous = NORMAL")
|
|
99
|
+
connection.execute(
|
|
100
|
+
<<~SQL
|
|
101
|
+
CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
|
|
102
|
+
id TEXT PRIMARY KEY,
|
|
103
|
+
state TEXT NOT NULL,
|
|
104
|
+
agent_class TEXT,
|
|
105
|
+
created_at TEXT NOT NULL,
|
|
106
|
+
updated_at TEXT NOT NULL,
|
|
107
|
+
data TEXT NOT NULL
|
|
108
|
+
)
|
|
109
|
+
SQL
|
|
110
|
+
)
|
|
111
|
+
connection.execute("CREATE INDEX IF NOT EXISTS idx_spurline_sessions_state ON #{TABLE_NAME}(state)")
|
|
112
|
+
connection.execute(
|
|
113
|
+
"CREATE INDEX IF NOT EXISTS idx_spurline_sessions_agent_class ON #{TABLE_NAME}(agent_class)"
|
|
114
|
+
)
|
|
115
|
+
connection
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def require_sqlite3!
|
|
120
|
+
return if defined?(::SQLite3::Database)
|
|
121
|
+
|
|
122
|
+
require "sqlite3"
|
|
123
|
+
rescue LoadError
|
|
124
|
+
raise Spurline::SQLiteUnavailableError,
|
|
125
|
+
"sqlite3 gem is required for the :sqlite session store. Add gem \"sqlite3\" to your Gemfile."
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def ensure_parent_directory!
|
|
129
|
+
return if @path == ":memory:"
|
|
130
|
+
|
|
131
|
+
parent = File.dirname(@path)
|
|
132
|
+
return if parent.nil? || parent == "." || parent.empty?
|
|
133
|
+
|
|
134
|
+
FileUtils.mkdir_p(parent)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
module Session
|
|
7
|
+
# Suspension logic for sessions.
|
|
8
|
+
# Kept as a standalone module so Session internals remain unchanged.
|
|
9
|
+
module Suspension
|
|
10
|
+
SUSPENSION_KEY = :suspension_checkpoint
|
|
11
|
+
SUSPENDABLE_STATES = %i[running waiting_for_tool processing].freeze
|
|
12
|
+
|
|
13
|
+
# Suspends a session, saving a checkpoint for later resumption.
|
|
14
|
+
def self.suspend!(session, checkpoint:)
|
|
15
|
+
raise Spurline::SuspensionError, "Session is already suspended" if suspended?(session)
|
|
16
|
+
|
|
17
|
+
unless suspendable?(session)
|
|
18
|
+
raise Spurline::SuspensionError,
|
|
19
|
+
"Session cannot be suspended from state #{session.state.inspect}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
session.metadata[SUSPENSION_KEY] = normalize_checkpoint(session, checkpoint)
|
|
23
|
+
session.transition_to!(:suspended)
|
|
24
|
+
persist!(session)
|
|
25
|
+
session
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Resumes a suspended session, clearing the checkpoint.
|
|
29
|
+
def self.resume!(session)
|
|
30
|
+
unless suspended?(session)
|
|
31
|
+
raise Spurline::InvalidResumeError,
|
|
32
|
+
"Session is not suspended (state=#{session.state.inspect})"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
session.metadata[SUSPENSION_KEY] = nil
|
|
36
|
+
session.transition_to!(:running)
|
|
37
|
+
persist!(session)
|
|
38
|
+
session
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns true if session is in :suspended state.
|
|
42
|
+
def self.suspended?(session)
|
|
43
|
+
session.state == :suspended
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns the checkpoint hash, or nil if not suspended.
|
|
47
|
+
def self.checkpoint_for(session)
|
|
48
|
+
return nil unless suspended?(session)
|
|
49
|
+
|
|
50
|
+
session.metadata[SUSPENSION_KEY]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Validates that a session can be suspended from its current state.
|
|
54
|
+
def self.suspendable?(session)
|
|
55
|
+
SUSPENDABLE_STATES.include?(session.state)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.normalize_checkpoint(session, checkpoint)
|
|
59
|
+
unless checkpoint.is_a?(Hash)
|
|
60
|
+
raise ArgumentError, "checkpoint must be a Hash"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
loop_iteration: fetch(checkpoint, :loop_iteration) || 0,
|
|
65
|
+
last_tool_result: fetch(checkpoint, :last_tool_result),
|
|
66
|
+
messages_so_far: Array(fetch(checkpoint, :messages_so_far)),
|
|
67
|
+
turn_number: fetch(checkpoint, :turn_number) || infer_turn_number(session),
|
|
68
|
+
suspended_at: fetch(checkpoint, :suspended_at) || Time.now.utc.iso8601,
|
|
69
|
+
suspension_reason: fetch(checkpoint, :suspension_reason),
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
private_class_method :normalize_checkpoint
|
|
73
|
+
|
|
74
|
+
def self.fetch(hash, key)
|
|
75
|
+
hash[key] || hash[key.to_s]
|
|
76
|
+
end
|
|
77
|
+
private_class_method :fetch
|
|
78
|
+
|
|
79
|
+
def self.infer_turn_number(session)
|
|
80
|
+
return session.current_turn.number if session.respond_to?(:current_turn) && session.current_turn
|
|
81
|
+
return session.turn_count if session.respond_to?(:turn_count)
|
|
82
|
+
|
|
83
|
+
0
|
|
84
|
+
end
|
|
85
|
+
private_class_method :infer_turn_number
|
|
86
|
+
|
|
87
|
+
def self.persist!(session)
|
|
88
|
+
session.send(:save!)
|
|
89
|
+
end
|
|
90
|
+
private_class_method :persist!
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Session
|
|
5
|
+
# A single turn in a conversation. Holds the input, output, tool calls,
|
|
6
|
+
# and timing information. Turns are mutable during their lifecycle and
|
|
7
|
+
# become effectively immutable once finished.
|
|
8
|
+
class Turn
|
|
9
|
+
attr_reader :input, :output, :tool_calls, :started_at, :finished_at,
|
|
10
|
+
:number, :metadata
|
|
11
|
+
|
|
12
|
+
def initialize(input:, number:)
|
|
13
|
+
@input = input
|
|
14
|
+
@output = nil
|
|
15
|
+
@tool_calls = []
|
|
16
|
+
@number = number
|
|
17
|
+
@started_at = Time.now
|
|
18
|
+
@finished_at = nil
|
|
19
|
+
@metadata = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Rebuilds a turn from persisted attributes without running initialize.
|
|
23
|
+
def self.restore(data)
|
|
24
|
+
turn = allocate
|
|
25
|
+
turn.instance_variable_set(:@input, data[:input])
|
|
26
|
+
turn.instance_variable_set(:@output, data[:output])
|
|
27
|
+
turn.instance_variable_set(:@tool_calls, data[:tool_calls] || [])
|
|
28
|
+
turn.instance_variable_set(:@number, data[:number])
|
|
29
|
+
turn.instance_variable_set(:@started_at, data[:started_at])
|
|
30
|
+
turn.instance_variable_set(:@finished_at, data[:finished_at])
|
|
31
|
+
turn.instance_variable_set(:@metadata, data[:metadata] || {})
|
|
32
|
+
turn
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def finish!(output:)
|
|
36
|
+
@output = output
|
|
37
|
+
@finished_at = Time.now
|
|
38
|
+
@metadata[:duration_ms] = duration_ms
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def record_tool_call(
|
|
42
|
+
name:,
|
|
43
|
+
arguments:,
|
|
44
|
+
result:,
|
|
45
|
+
duration_ms:,
|
|
46
|
+
scope_id: nil,
|
|
47
|
+
idempotency_key: nil,
|
|
48
|
+
was_cached: nil,
|
|
49
|
+
cache_age_ms: nil
|
|
50
|
+
)
|
|
51
|
+
entry = {
|
|
52
|
+
name: name,
|
|
53
|
+
arguments: arguments,
|
|
54
|
+
result: result,
|
|
55
|
+
duration_ms: duration_ms,
|
|
56
|
+
timestamp: Time.now,
|
|
57
|
+
}
|
|
58
|
+
entry[:scope_id] = scope_id unless scope_id.nil?
|
|
59
|
+
entry[:idempotency_key] = idempotency_key unless idempotency_key.nil?
|
|
60
|
+
entry[:was_cached] = was_cached unless was_cached.nil?
|
|
61
|
+
entry[:cache_age_ms] = cache_age_ms unless cache_age_ms.nil?
|
|
62
|
+
@tool_calls << entry
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Duration in seconds (float).
|
|
66
|
+
def duration
|
|
67
|
+
return nil unless finished_at
|
|
68
|
+
|
|
69
|
+
finished_at - started_at
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Duration in milliseconds (integer), for audit logging.
|
|
73
|
+
def duration_ms
|
|
74
|
+
return nil unless finished_at
|
|
75
|
+
|
|
76
|
+
((finished_at - started_at) * 1000).round
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def tool_call_count
|
|
80
|
+
tool_calls.length
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def complete?
|
|
84
|
+
!finished_at.nil?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Compact summary for logging and debugging.
|
|
88
|
+
def summary
|
|
89
|
+
{
|
|
90
|
+
number: number,
|
|
91
|
+
tool_calls: tool_call_count,
|
|
92
|
+
duration_ms: duration_ms,
|
|
93
|
+
complete: complete?,
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|