spurline-deploy 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 (109) hide show
  1. checksums.yaml +7 -0
  2. data/lib/spurline/adapters/base.rb +17 -0
  3. data/lib/spurline/adapters/claude.rb +208 -0
  4. data/lib/spurline/adapters/open_ai.rb +213 -0
  5. data/lib/spurline/adapters/registry.rb +33 -0
  6. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  7. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  8. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  9. data/lib/spurline/agent.rb +433 -0
  10. data/lib/spurline/audit/log.rb +156 -0
  11. data/lib/spurline/audit/secret_filter.rb +121 -0
  12. data/lib/spurline/base.rb +130 -0
  13. data/lib/spurline/cartographer/analyzer.rb +71 -0
  14. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  15. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  16. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  17. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  18. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  19. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  20. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  21. data/lib/spurline/cartographer/runner.rb +88 -0
  22. data/lib/spurline/cartographer.rb +6 -0
  23. data/lib/spurline/channels/base.rb +41 -0
  24. data/lib/spurline/channels/event.rb +136 -0
  25. data/lib/spurline/channels/github.rb +205 -0
  26. data/lib/spurline/channels/router.rb +103 -0
  27. data/lib/spurline/cli/check.rb +88 -0
  28. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  29. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  30. data/lib/spurline/cli/checks/base.rb +35 -0
  31. data/lib/spurline/cli/checks/credentials.rb +43 -0
  32. data/lib/spurline/cli/checks/permissions.rb +22 -0
  33. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  34. data/lib/spurline/cli/checks/session_store.rb +97 -0
  35. data/lib/spurline/cli/console.rb +73 -0
  36. data/lib/spurline/cli/credentials.rb +181 -0
  37. data/lib/spurline/cli/generators/agent.rb +123 -0
  38. data/lib/spurline/cli/generators/migration.rb +62 -0
  39. data/lib/spurline/cli/generators/project.rb +331 -0
  40. data/lib/spurline/cli/generators/tool.rb +98 -0
  41. data/lib/spurline/cli/router.rb +121 -0
  42. data/lib/spurline/configuration.rb +23 -0
  43. data/lib/spurline/dsl/guardrails.rb +108 -0
  44. data/lib/spurline/dsl/hooks.rb +51 -0
  45. data/lib/spurline/dsl/memory.rb +39 -0
  46. data/lib/spurline/dsl/model.rb +23 -0
  47. data/lib/spurline/dsl/persona.rb +74 -0
  48. data/lib/spurline/dsl/suspend_until.rb +53 -0
  49. data/lib/spurline/dsl/tools.rb +176 -0
  50. data/lib/spurline/errors.rb +109 -0
  51. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  52. data/lib/spurline/lifecycle/runner.rb +456 -0
  53. data/lib/spurline/lifecycle/states.rb +47 -0
  54. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  55. data/lib/spurline/memory/context_assembler.rb +100 -0
  56. data/lib/spurline/memory/embedder/base.rb +17 -0
  57. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  58. data/lib/spurline/memory/episode.rb +56 -0
  59. data/lib/spurline/memory/episodic_store.rb +147 -0
  60. data/lib/spurline/memory/long_term/base.rb +22 -0
  61. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  62. data/lib/spurline/memory/manager.rb +147 -0
  63. data/lib/spurline/memory/short_term.rb +57 -0
  64. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  65. data/lib/spurline/orchestration/judge.rb +109 -0
  66. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  67. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  68. data/lib/spurline/orchestration/ledger.rb +339 -0
  69. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  70. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  71. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  72. data/lib/spurline/persona/base.rb +42 -0
  73. data/lib/spurline/persona/registry.rb +42 -0
  74. data/lib/spurline/secrets/resolver.rb +65 -0
  75. data/lib/spurline/secrets/vault.rb +42 -0
  76. data/lib/spurline/security/content.rb +76 -0
  77. data/lib/spurline/security/context_pipeline.rb +58 -0
  78. data/lib/spurline/security/gates/base.rb +36 -0
  79. data/lib/spurline/security/gates/operator_config.rb +22 -0
  80. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  81. data/lib/spurline/security/gates/tool_result.rb +23 -0
  82. data/lib/spurline/security/gates/user_input.rb +22 -0
  83. data/lib/spurline/security/injection_scanner.rb +109 -0
  84. data/lib/spurline/security/pii_filter.rb +104 -0
  85. data/lib/spurline/session/resumption.rb +36 -0
  86. data/lib/spurline/session/serializer.rb +169 -0
  87. data/lib/spurline/session/session.rb +154 -0
  88. data/lib/spurline/session/store/base.rb +27 -0
  89. data/lib/spurline/session/store/memory.rb +45 -0
  90. data/lib/spurline/session/store/postgres.rb +123 -0
  91. data/lib/spurline/session/store/sqlite.rb +139 -0
  92. data/lib/spurline/session/suspension.rb +93 -0
  93. data/lib/spurline/session/turn.rb +98 -0
  94. data/lib/spurline/spur.rb +213 -0
  95. data/lib/spurline/streaming/buffer.rb +77 -0
  96. data/lib/spurline/streaming/chunk.rb +62 -0
  97. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  98. data/lib/spurline/testing.rb +245 -0
  99. data/lib/spurline/toolkit.rb +110 -0
  100. data/lib/spurline/tools/base.rb +209 -0
  101. data/lib/spurline/tools/idempotency.rb +220 -0
  102. data/lib/spurline/tools/permissions.rb +44 -0
  103. data/lib/spurline/tools/registry.rb +43 -0
  104. data/lib/spurline/tools/runner.rb +255 -0
  105. data/lib/spurline/tools/scope.rb +309 -0
  106. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  107. data/lib/spurline/version.rb +5 -0
  108. data/lib/spurline.rb +56 -0
  109. metadata +161 -0
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ # Scans Content objects for prompt injection patterns.
6
+ # Configurable strictness: :strict, :moderate, :permissive.
7
+ #
8
+ # Only scans content at trust levels that could be injected (:user, :external, :untrusted).
9
+ # System and operator content is trusted by definition and bypasses scanning.
10
+ #
11
+ # Pattern tiers are additive: :strict includes all :moderate patterns,
12
+ # :moderate includes all :permissive (BASE) patterns.
13
+ class InjectionScanner
14
+ SKIP_TRUST_LEVELS = %i[system operator].freeze
15
+
16
+ # Patterns checked at all strictness levels — the most obvious injection attempts.
17
+ BASE_PATTERNS = [
18
+ /ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions|prompts|context|rules)/i,
19
+ /you\s+are\s+now\s+(a|an|in)\s+/i,
20
+ /\bsystem\s*:\s*\n/i,
21
+ /\bforget\s+(all\s+|everything\s+)?(previous|prior|your)\s+(instructions|context|rules|training)/i,
22
+ /\bdisregard\s+(all\s+)?(previous|prior|above|your)\s+(instructions|prompts|rules)/i,
23
+ /\bnew\s+instructions\s*:/i,
24
+ /\bpretend\s+(you\s+are|to\s+be|that\s+you)/i,
25
+ ].freeze
26
+
27
+ # Additional patterns for :moderate and :strict — social engineering and role manipulation.
28
+ MODERATE_PATTERNS = [
29
+ /\bdo\s+not\s+follow\b/i,
30
+ /\boverride\s+(your|the)\s+(instructions|rules|guidelines|programming)\b/i,
31
+ /\bact\s+as\s+(if\s+you\s+are|though\s+you|a\b)/i,
32
+ /\bbehave\s+as\s+(if|though|a\b)/i,
33
+ /\bfrom\s+now\s+on\s*,?\s*(you|your|act|behave|respond|ignore)/i,
34
+ /\bjailbreak/i,
35
+ /\bdeveloper\s+mode\b/i,
36
+ /\bDAN\s+(mode|prompt)\b/i,
37
+ /\bdo\s+anything\s+now\b/i,
38
+ /\bunfiltered\s+(mode|response|output)\b/i,
39
+ /\bno\s+(restrictions|rules|limitations|filters|censorship)\b/i,
40
+ /\bbypass\s+(your|the|any|all)\s+(restrictions|rules|filters|safety|guidelines)/i,
41
+ ].freeze
42
+
43
+ # Additional patterns for :strict only — structural attacks and format manipulation.
44
+ STRICT_PATTERNS = [
45
+ /\brole\s*:\s*(system|assistant)\b/i,
46
+ /<\/?system>/i,
47
+ /\[INST\]/i,
48
+ /<<\s*SYS\s*>>/i,
49
+ /<\|im_start\|>/i,
50
+ /\bIMPORTANT\s*:\s*(new|override|ignore|forget|disregard|update)/i,
51
+ /\bATTENTION\s*:\s*(new|override|ignore|forget|disregard|update)/i,
52
+ /\b(BEGIN|END)\s+(SYSTEM|INSTRUCTION|PROMPT)\b/i,
53
+ /---+\s*\n\s*(system|instruction|new prompt|override)/i,
54
+ /\bbase64\s*[\s:]+[A-Za-z0-9+\/=]{20,}/i,
55
+ /\btranslate\s+(the\s+)?(following|this)\s+(from|to)\s+.*\s+(and|then)\s+(ignore|forget|override)/i,
56
+ /\brepeat\s+(the\s+)?(system\s+prompt|instructions|your\s+rules)/i,
57
+ /\b(reveal|show|display|output|print)\s+(your|the)\s+(system\s+prompt|instructions|rules)/i,
58
+ ].freeze
59
+
60
+ LEVELS = %i[strict moderate permissive].freeze
61
+
62
+ attr_reader :level
63
+
64
+ def initialize(level: :strict)
65
+ validate_level!(level)
66
+ @level = level
67
+ end
68
+
69
+ # Scans a Content object for injection patterns.
70
+ # Returns nil if clean, raises InjectionAttemptError if detected.
71
+ def scan!(content)
72
+ return if SKIP_TRUST_LEVELS.include?(content.trust)
73
+
74
+ text = content.text
75
+ patterns_for_level.each do |pattern|
76
+ next unless text.match?(pattern)
77
+
78
+ raise Spurline::InjectionAttemptError,
79
+ "Injection pattern detected in content (trust: #{content.trust}, " \
80
+ "source: #{content.source}). Pattern: #{pattern.source[0..40]}. " \
81
+ "Review the content or adjust injection_filter level."
82
+ end
83
+
84
+ nil
85
+ end
86
+
87
+ private
88
+
89
+ def patterns_for_level
90
+ case level
91
+ when :strict
92
+ BASE_PATTERNS + MODERATE_PATTERNS + STRICT_PATTERNS
93
+ when :moderate
94
+ BASE_PATTERNS + MODERATE_PATTERNS
95
+ when :permissive
96
+ BASE_PATTERNS
97
+ end
98
+ end
99
+
100
+ def validate_level!(level)
101
+ return if LEVELS.include?(level)
102
+
103
+ raise Spurline::ConfigurationError,
104
+ "Invalid injection filter level: #{level.inspect}. " \
105
+ "Must be one of: #{LEVELS.map(&:inspect).join(", ")}."
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Security
5
+ # Filters personally identifiable information from Content objects.
6
+ # Modes:
7
+ # :redact — replaces detected PII with [REDACTED_<type>] placeholders
8
+ # :block — raises PIIDetectedError if PII is found
9
+ # :warn — returns content unchanged but records detections for audit
10
+ # :off — no scanning, returns content unchanged
11
+ #
12
+ # Only scans content at trust levels where PII matters (:user, :external, :untrusted).
13
+ # System and operator content is trusted by definition and bypasses PII filtering.
14
+ class PIIFilter
15
+ MODES = %i[redact block warn off].freeze
16
+
17
+ SKIP_TRUST_LEVELS = %i[system operator].freeze
18
+
19
+ # Pattern definitions: [label, regex, replacement_tag]
20
+ # Ordered from most specific to least specific to prevent partial matches.
21
+ PII_PATTERNS = [
22
+ [:ssn, /\b\d{3}-\d{2}-\d{4}\b/, "[REDACTED_SSN]"],
23
+ [:credit_card, /\b(?:\d{4}[\s-]?){3}\d{4}\b/, "[REDACTED_CREDIT_CARD]"],
24
+ [:phone, /\b(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b/, "[REDACTED_PHONE]"],
25
+ [:email, /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/, "[REDACTED_EMAIL]"],
26
+ [:ip_address, /\b(?:\d{1,3}\.){3}\d{1,3}\b/, "[REDACTED_IP]"],
27
+ ].freeze
28
+
29
+ attr_reader :mode
30
+
31
+ def initialize(mode: :off)
32
+ validate_mode!(mode)
33
+ @mode = mode
34
+ end
35
+
36
+ # Filters a Content object for PII.
37
+ # Returns the original content if no PII is detected or mode is :off.
38
+ # Returns a new Content object with redacted text in :redact mode.
39
+ # Raises PIIDetectedError in :block mode.
40
+ # Returns the original content with detections array in :warn mode.
41
+ def filter(content)
42
+ return content if mode == :off
43
+ return content if SKIP_TRUST_LEVELS.include?(content.trust)
44
+
45
+ detections = detect(content.text)
46
+ return content if detections.empty?
47
+
48
+ case mode
49
+ when :redact
50
+ redact(content, detections)
51
+ when :block
52
+ block!(content, detections)
53
+ when :warn
54
+ # In :warn mode, content passes through unchanged.
55
+ # The caller (ContextPipeline) can check detections via the return.
56
+ # For now, we simply return the content — audit logging is deferred.
57
+ content
58
+ end
59
+ end
60
+
61
+ # Scans text for PII patterns. Returns array of {type:, match:, pattern:} hashes.
62
+ def detect(text)
63
+ detections = []
64
+ PII_PATTERNS.each do |label, pattern, _|
65
+ text.scan(pattern) do |match|
66
+ detected = match.is_a?(Array) ? match.first : $~.to_s
67
+ detections << { type: label, match: detected, pattern: pattern }
68
+ end
69
+ end
70
+ detections
71
+ end
72
+
73
+ private
74
+
75
+ def redact(content, detections)
76
+ redacted_text = content.text.dup
77
+ PII_PATTERNS.each do |_, pattern, replacement|
78
+ redacted_text.gsub!(pattern, replacement)
79
+ end
80
+
81
+ Content.new(
82
+ text: redacted_text,
83
+ trust: content.trust,
84
+ source: content.source
85
+ )
86
+ end
87
+
88
+ def block!(content, detections)
89
+ types = detections.map { |d| d[:type] }.uniq.join(", ")
90
+ raise Spurline::PIIDetectedError,
91
+ "PII detected in content (trust: #{content.trust}, source: #{content.source}). " \
92
+ "Types found: #{types}. Set pii_filter to :redact or :off to allow this content."
93
+ end
94
+
95
+ def validate_mode!(mode)
96
+ return if MODES.include?(mode)
97
+
98
+ raise Spurline::ConfigurationError,
99
+ "Invalid PII filter mode: #{mode.inspect}. " \
100
+ "Must be one of: #{MODES.map(&:inspect).join(", ")}."
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Session
5
+ # Handles restoring a resumed session's turn history into short-term memory.
6
+ # When a session is resumed by ID, completed turns are replayed into the
7
+ # memory manager so the agent has context from the previous conversation.
8
+ class Resumption
9
+ attr_reader :restored_count
10
+
11
+ def initialize(session:, memory:)
12
+ @session = session
13
+ @memory = memory
14
+ @restored_count = 0
15
+ end
16
+
17
+ # Restores the session's completed turn history into the memory manager.
18
+ # Incomplete turns are skipped — they represent interrupted work.
19
+ def restore!
20
+ @session.turns.each do |turn|
21
+ next unless turn.complete?
22
+
23
+ @memory.add_turn(turn)
24
+ @restored_count += 1
25
+ end
26
+
27
+ @restored_count
28
+ end
29
+
30
+ # Whether this session has prior turns to restore.
31
+ def resumable?
32
+ @session.turns.any?(&:complete?)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Spurline
7
+ module Session
8
+ # JSON serializer/deserializer for persisted sessions.
9
+ # Includes a format_version field for forward-compatible migrations.
10
+ class Serializer
11
+ FORMAT_VERSION = 1
12
+ CONTENT_TYPE = "Spurline::Security::Content"
13
+ TIME_TYPE = "Time"
14
+ SYMBOL_TYPE = "Symbol"
15
+
16
+ def to_json(session)
17
+ JSON.generate(
18
+ format_version: FORMAT_VERSION,
19
+ session: serialize_session(session)
20
+ )
21
+ end
22
+
23
+ def from_json(json, store:)
24
+ payload = JSON.parse(json)
25
+ unless payload.is_a?(Hash)
26
+ raise Spurline::SessionDeserializationError, "Session payload must be a JSON object."
27
+ end
28
+
29
+ version = payload.fetch("format_version")
30
+ unless version == FORMAT_VERSION
31
+ raise Spurline::SessionDeserializationError,
32
+ "Unsupported session format version: #{version.inspect}."
33
+ end
34
+
35
+ session_data = deserialize_session(payload.fetch("session"))
36
+ Session.restore(session_data, store: store)
37
+ rescue Spurline::SessionDeserializationError
38
+ raise
39
+ rescue JSON::ParserError, KeyError, TypeError, NoMethodError, ArgumentError, Spurline::ConfigurationError => e
40
+ raise Spurline::SessionDeserializationError, "Failed to deserialize session payload: #{e.message}"
41
+ end
42
+
43
+ private
44
+
45
+ def serialize_session(session)
46
+ {
47
+ id: session.id,
48
+ agent_class: session.agent_class,
49
+ user: session.user,
50
+ state: session.state.to_s,
51
+ started_at: serialize_value(session.started_at),
52
+ finished_at: serialize_value(session.finished_at),
53
+ metadata: serialize_value(session.metadata),
54
+ turns: session.turns.map { |turn| serialize_turn(turn) },
55
+ }
56
+ end
57
+
58
+ def serialize_turn(turn)
59
+ {
60
+ input: serialize_value(turn.input),
61
+ output: serialize_value(turn.output),
62
+ tool_calls: serialize_value(turn.tool_calls),
63
+ number: turn.number,
64
+ started_at: serialize_value(turn.started_at),
65
+ finished_at: serialize_value(turn.finished_at),
66
+ metadata: serialize_value(turn.metadata),
67
+ }
68
+ end
69
+
70
+ def serialize_value(value)
71
+ case value
72
+ when Spurline::Security::Content
73
+ {
74
+ __type: CONTENT_TYPE,
75
+ text: value.text,
76
+ trust: value.trust.to_s,
77
+ source: value.source,
78
+ }
79
+ when Time
80
+ {
81
+ __type: TIME_TYPE,
82
+ iso8601: value.utc.iso8601(6),
83
+ }
84
+ when Symbol
85
+ {
86
+ __type: SYMBOL_TYPE,
87
+ value: value.to_s,
88
+ }
89
+ when Array
90
+ value.map { |item| serialize_value(item) }
91
+ when Hash
92
+ value.each_with_object({}) do |(key, item), hash|
93
+ hash[key.to_s] = serialize_value(item)
94
+ end
95
+ else
96
+ value
97
+ end
98
+ end
99
+
100
+ def deserialize_session(data)
101
+ hash = deserialize_hash(data)
102
+ {
103
+ id: hash.fetch(:id),
104
+ agent_class: hash[:agent_class],
105
+ user: hash[:user],
106
+ turns: Array(hash[:turns]).map { |turn_data| Turn.restore(deserialize_turn(turn_data)) },
107
+ state: hash.fetch(:state).to_sym,
108
+ started_at: hash.fetch(:started_at),
109
+ finished_at: hash[:finished_at],
110
+ metadata: hash[:metadata] || {},
111
+ }
112
+ end
113
+
114
+ def deserialize_turn(data)
115
+ hash = deserialize_hash(data)
116
+ {
117
+ input: hash[:input],
118
+ output: hash[:output],
119
+ tool_calls: hash[:tool_calls] || [],
120
+ number: hash.fetch(:number),
121
+ started_at: hash.fetch(:started_at),
122
+ finished_at: hash[:finished_at],
123
+ metadata: hash[:metadata] || {},
124
+ }
125
+ end
126
+
127
+ def deserialize_hash(value)
128
+ hash = deserialize_value(value)
129
+ unless hash.is_a?(Hash)
130
+ raise TypeError, "Expected Hash value, got #{hash.class}."
131
+ end
132
+
133
+ hash
134
+ end
135
+
136
+ def deserialize_value(value)
137
+ case value
138
+ when Array
139
+ value.map { |item| deserialize_value(item) }
140
+ when Hash
141
+ deserialize_hash_value(value)
142
+ else
143
+ value
144
+ end
145
+ end
146
+
147
+ def deserialize_hash_value(value)
148
+ type = value["__type"]
149
+
150
+ case type
151
+ when CONTENT_TYPE
152
+ Spurline::Security::Content.new(
153
+ text: value.fetch("text"),
154
+ trust: value.fetch("trust").to_sym,
155
+ source: value.fetch("source")
156
+ )
157
+ when TIME_TYPE
158
+ Time.iso8601(value.fetch("iso8601"))
159
+ when SYMBOL_TYPE
160
+ value.fetch("value").to_sym
161
+ else
162
+ value.each_with_object({}) do |(key, item), hash|
163
+ hash[key.to_sym] = deserialize_value(item)
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -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,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