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,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