Echo: add Session and Message persistence - #1649
Conversation
QA Evidence Report — send-backBranch: Slice cap: 11 files / 404 LOC — within ≤15 files / ≤600 LOC. ✅ CI jobs reproduced (from
|
| Job | Command | Exit | Result |
|---|---|---|---|
| bundler-audit | bundle exec bundler-audit --update --ignore … |
0 | No vulnerabilities found (1200 advisories) |
| ruby-audit | bundle exec ruby-audit update && … check --ignore … |
0 | No vulnerabilities found |
| brakeman | bundle exec brakeman -q -w2 |
0 | No warnings found |
| rubocop | bin/rubocop-ci echo/add-sessions false |
1 | 5 offenses (Layout/HashAlignment) ❌ |
| rspec (engine) | bundle exec rspec engines/dradis-echo/spec |
0 | 139 examples, 0 failures |
| rspec (new models) | rspec …/session_spec.rb …/message_spec.rb |
0 | 28 examples, 0 failures |
| rspec (core flow) | rspec spec/models/{user,note,issue}_spec.rb |
0 | 33 examples, 0 failures |
Migrations
db:migrateup →db:rollback STEP=2→ re-migrate: all clean.schema.rbdiff limited to the two tables + version bump, no drift after up/down/up.
Affected-flow checks
- User delete → sessions/messages
user_idnullify — spec'd & green. - Issue/Note delete → sessions destroyed — spec'd & green.
record=forcesrecord_type: 'Issue';Session#project→record.project— spec'd & green.to_provider_messagesordered[{role:, content:}];_messagepartial is strict-locals (message:only) — verified.
Findings (blocker)
F1 — rubocop Lint job fails (CI-breaking). 5 Layout/HashAlignment offenses, all autocorrectable, in the two new migrations. Reproduced against both echo/add-sessions and develop, so this is not a base artifact — the PR's Lint job would fail as-is.
engines/dradis-echo/db/migrate/20260714000001_create_dradis_plugins_echo_sessions.rb:5:9
engines/dradis-echo/db/migrate/20260714000001_create_dradis_plugins_echo_sessions.rb:7:9
engines/dradis-echo/db/migrate/20260714000002_create_dradis_plugins_echo_messages.rb:5:9
engines/dradis-echo/db/migrate/20260714000002_create_dradis_plugins_echo_messages.rb:8:9
engines/dradis-echo/db/migrate/20260714000002_create_dradis_plugins_echo_messages.rb:10:9
Layout/HashAlignment: Align the keys of a hash literal if they span more than one line.
The foreign_key: continuation line must align with the first hash key on the previous line, e.g.:
t.references :agent, null: false,
foreign_key: { to_table: :dradis_plugins_echo_agents }Fix: bin/rubocop-ci develop false -a (autocorrect) or hand-align, then re-push. No behavioral change.
Verdict
send-back-with-findings — everything else (security audits, brakeman, full rspec, migrations, affected flows, slice cap) is green; only the rubocop Lint job blocks. Fix F1 and re-push; re-verification will be fast.
QA re-verification — PASS ✅Re-verified at HEAD
F1 resolved. All acceptance criteria met. Approving. |
fa8d0ed to
b45cf79
Compare
QA Evidence Report — ready-for-human-mergeBranch: CI jobs reproduced (at retire tip, exact
|
| Job | Command | Exit | Duration | Output snippet |
|---|---|---|---|---|
| bundler-audit | bundle exec bundler-audit --update --ignore CVE-2024-21510 CVE-2025-61921 CVE-2026-38969 |
0 | ~5s | No vulnerabilities found |
| ruby-audit | bundle exec ruby-audit update && ruby-audit check --ignore CVE-2025-61594 CVE-2025-58767 CVE-2026-41316 |
0 | ~3s | No vulnerabilities found |
| brakeman | bundle exec brakeman -q -w2 |
0 | ~4s | Security Warnings: 0, Errors: 0, No warnings found |
| rubocop-ci | bin/rubocop-ci develop false |
0 | ~15s | Clean code! No rubocop offenses … 7 changed file(s). Compared to origin/develop |
| rspec (engine) | bundle exec rspec engines/dradis-echo/spec |
0 | 18.2s | 183 examples, 0 failures |
Notes:
- CE
ci.ymlrspec job isbundle exec rspec spec engines -p(whole app). This change is engine-only (verified: no CE coreapp//config/source edits), soengines/dradis-echo/specis the affected surface. 183/0 matches Coder's run. - The two audits flagged CVEs (Sinatra
CVE-2025-61921, ERBCVE-2026-41316) only when run without the--ignoreflags — both are on the CE CI ignore list, and the Gemfile/Gemfile.lock is unchanged vs develop (0 delta), so they are pre-existing/environmental, not introduced here. With the exact CI flags: clean, exit 0. - Also ran
rubocopdirectly on the 3 changed files (sessionable.rb,engine.rb,session_spec.rb):3 files inspected, no offenses detected.
Affected-flow checks — cascade (all 3 approved destroy paths)
rspec … session_spec.rb -e "destroying the record" → 3 examples, 0 failures
- Note destroyed → its sessions destroyed — PASS
- Issue destroyed → its sessions destroyed — PASS
- Issue row destroyed loaded as
Note(Note.find(issue.id)) → itsrecord_type: 'Issue'sessions destroyed via the sweep — PASS
Mutation test (proves the specs have teeth): disabled the after_destroy sweep body in my isolated worktree and re-ran → path 3 and path 2 both fail (2 failures), then restored. This confirms the sweep is load-bearing and the loaded-as-Note gap is genuinely covered.
Design / train-consistency verification
sessionable.rbandengine.rbare byte-identical (same git blob hash) across all 6 PR heads (Echo: add Session and Message persistence #1649–Echo Sessions: full feature #1655) — the fix is carried consistently through the whole train.- No
before_destroy/for_record-as-cascade / hardcoded-Notecascade remains inengine.rbon any head. Session.for_recordretained only as a query scope (used by the sessions + interactions controllers), no longer the cascade mechanism.- Concern implements the approved design exactly:
has_many :echo_sessions, as: :record, dependent: :destroy+ unconditionalafter_destroysweep, nois_a?(Issue)guard. No CE core source edits.
Findings
- (non-blocking, maintainability) The concern's inline comment states "A genuine Issue is already covered by
dependent: :destroy." My mutation test contradicts this: with the sweep disabled, the genuine-Issue path (session_spec.rb:171) also fails. In practice theafter_destroysweep (record_type: 'Issue') is what cascades all Issue sessions;dependent: :destroyonly coversrecord_type: 'Note'(real Notes). Behavior is correct because both mechanisms are present — but the comment could mislead a future maintainer into deleting the "redundant" sweep and silently breaking genuine-Issue cascade. Recommend correcting the comment (fast-follow; not a merge blocker). - (informational) Pro-schema run not performed: Pro's
dradis-echopredates the sessions feature (nosession.rb— verified in/mnt/repos/dradis-pro), so there is no Pro checkout to run these specs against yet. MySQL is up locally but CE's bundle doesn't vendormysql2and the repo disk is at 97%. The cascade uses only ORM-level, adapter-agnostic mechanisms (dependent: :destroy+where(...).destroy_all— no raw SQL / FKON DELETE), identical tonote.rb's already-Pro-proven Comment/InlineThread/Subscription sweep, so MySQL↔SQLite cannot change behavior. Path 3 (the exact Proproject.notesgap) passes on CE. Coder's reasoning accepted; actual Pro integration gets its own QA when Pro pulls the feature. - (informational, out of scope for SEC-507) Slice cap: SEC-507's own delta is 3 files. Base PR #1649 = 12 files / +442, within cap. Downstream #1651 (16 files) and the retire #1655 (58 files / +2272) exceed the per-PR cap, but these are pre-existing, separately-scoped slice/retire PRs — not SEC-507's delta. Flagging for CTO awareness only.
Verdict
ready-for-human-merge — all CI jobs reproduced green at the retire tip, all 3 cascade paths pass (and are proven non-vacuous by mutation), fix byte-identical across the whole train, monkeypatch fully removed. One non-blocking comment-accuracy finding recommended as a fast-follow.
Move the record_type 'Issue' after_destroy sweep out of the shared Sessionable concern and into the Note-only on_load(:note_model) wiring in engine.rb, right after ::Note.include Sessionable. Hardcoding 'Issue' in the shared concern would let a future host (Evidence/ContentBlock, independent id sequences) delete an unrelated Issue #N's sessions on destroy. - Message: before_validation :complete_user_messages, if: :user? (drop the internal user? guard). - Session#project: delegate :project, to: :record. - Session.for_record: comment why where(record: record) misses forced 'Issue' rows. - Specs: keep the loaded-as-Note sweep coverage (now against the relocated callback) and add a guard proving a non-Note Sessionable host destroy leaves unrelated Issue sessions intact.
5296b48 to
722d1d4
Compare
Move the record_type 'Issue' after_destroy sweep out of the shared Sessionable concern and into the Note-only on_load(:note_model) wiring in engine.rb, right after ::Note.include Sessionable. Hardcoding 'Issue' in the shared concern would let a future host (Evidence/ContentBlock, independent id sequences) delete an unrelated Issue #N's sessions on destroy. - Message: before_validation :complete_user_messages, if: :user? (drop the internal user? guard). - Session#project: delegate :project, to: :record. - Session.for_record: comment why where(record: record) misses forced 'Issue' rows. - Specs: keep the loaded-as-Note sweep coverage (now against the relocated callback) and add a guard proving a non-Note Sessionable host destroy leaves unrelated Issue sessions intact.
c9ae1ac to
8c3d045
Compare
Move the record_type 'Issue' after_destroy sweep out of the shared Sessionable concern and into the Note-only on_load(:note_model) wiring in engine.rb, right after ::Note.include Sessionable. Hardcoding 'Issue' in the shared concern would let a future host (Evidence/ContentBlock, independent id sequences) delete an unrelated Issue #N's sessions on destroy. - Message: before_validation :complete_user_messages, if: :user? (drop the internal user? guard). - Session#project: delegate :project, to: :record. - Session.for_record: comment why where(record: record) misses forced 'Issue' rows. - Specs: keep the loaded-as-Note sweep coverage (now against the relocated callback) and add a guard proving a non-Note Sessionable host destroy leaves unrelated Issue sessions intact.
|
@MattBudz — pinging on this PR. It's been 5 days since last activity. Any blockers or ETA for merge? |
Persist Echo agent conversations. Sessions hang off a polymorphic record (Note/Issue) and an agent; messages record the multi-turn exchange. Deleting a user preserves history by nullifying authorship; deleting a record cleans up its sessions.
Layout/HashAlignment: the foreign_key: continuation must align under the first hash key (null:), or the Lint CI job fails.
Move the record_type 'Issue' after_destroy sweep out of the shared Sessionable concern and into the Note-only on_load(:note_model) wiring in engine.rb, right after ::Note.include Sessionable. Hardcoding 'Issue' in the shared concern would let a future host (Evidence/ContentBlock, independent id sequences) delete an unrelated Issue #N's sessions on destroy. - Message: before_validation :complete_user_messages, if: :user? (drop the internal user? guard). - Session#project: delegate :project, to: :record. - Session.for_record: comment why where(record: record) misses forced 'Issue' rows. - Specs: keep the loaded-as-Note sweep coverage (now against the relocated callback) and add a guard proving a non-Note Sessionable host destroy leaves unrelated Issue sessions intact.
- Message: require user_id on user messages (symmetric to the assistant absence rule) so a nil user_id means a genuine delete; dependent: :nullify bypasses it via update_all. - Session#record=: reuse record_type_for as the single source of truth, guarded on new_record (record_type_for(nil) raises). - Reorder the record= FIXME above its explanation; trim the _message locals note; migration parent note -> dormant. - Rename migrations to CreateSessions / CreateMessages (files and classes; timestamps unchanged) and drop the redundant null: true on the user references. - Add the user association to the base echo_message factory.
722d1d4 to
2c59ba7
Compare
8c3d045 to
03498bf
Compare
Move the record_type 'Issue' after_destroy sweep out of the shared Sessionable concern and into the Note-only on_load(:note_model) wiring in engine.rb, right after ::Note.include Sessionable. Hardcoding 'Issue' in the shared concern would let a future host (Evidence/ContentBlock, independent id sequences) delete an unrelated Issue #N's sessions on destroy. - Message: before_validation :complete_user_messages, if: :user? (drop the internal user? guard). - Session#project: delegate :project, to: :record. - Session.for_record: comment why where(record: record) misses forced 'Issue' rows. - Specs: keep the loaded-as-Note sweep coverage (now against the relocated callback) and add a guard proving a non-Note Sessionable host destroy leaves unrelated Issue sessions intact.
Summary
Slice 2 of 5 of the Echo Sessions feature (see SEC-469 / SEC-473). Branches off Slice 1's provider-layer work and merges back into
echo/add-sessions.Adds the persistence layer for Echo agent conversations:
dradis_plugins_echo_sessions(polymorphicrecord, agent FK, nullable user FK-nullify,statusenum idle/generating,title) anddradis_plugins_echo_messages(session FK, dormant self-referentialparent, nullable user,roleenum user/assistant,statusenum complete/streaming/failed,content, JSONmetadata).Sessionmodel —belongs_to :agent/:record (polymorphic)/:user,has_many :messages, dependent: :destroy,#projectdelegates torecord.project,#to_provider_messagesreturns ordered[{ role:, content: }], and arecord=override forcingrecord_type: 'Issue'for Issues (mirrorsComment#commentable=), plus afor_recordscope that resolves the same type.Messagemodel — enums, JSONmetadatastore, validations (content presence when complete; assistant messages carry no user; user messages forced complete), and anafter_create_commitTurbo broadcast to[session, :messages]._messagepartial — author line keyed off role with a'Deleted user'fallback and spinner/error states (Slice 5 restyles).User has_many :echo_sessions/:echo_messages, dependent: :nullify(history survives user deletion) and aNote(inherited byIssue)before_destroyhook cleaning up polymorphic sessions.request_reply!locking is deferred to Slice 3.Testing steps
bundle exec rspec engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb engines/dradis-echo/spec/models/dradis/plugins/echo/message_spec.rb— 28 examples, 0 failures.bin/rails db:migrateup /db:rollback STEP=2/ re-migrate are clean;db/schema.rbdiff is limited to the two new tables and the version bump.engines/dradis-echo/spec/{models,jobs,services,requests}(133 examples) and corespec/models/{user,note,issue}_spec.rbgreen — no regression from the User/Note extensions.Other Information
Migrations are additive and reference only tables shared by CE and Pro (
users,dradis_plugins_echo_agents, and the two new echo tables), so they apply cleanly against a Pro-schema checkout.