A small helpdesk system: an office Pixel running a patched GrapheneOS Dialer answers technician calls, records both call legs as separate channels, and a Ruby backend transcribes them through Whisper and files an AI summary against the caller. Squashed to a single commit for sharing. No credentials are included; secrets live outside the repo in /etc/helpdesk/env on the server or a gitignored .claude/env.local locally. See .claude/env.local.example for the shape. Start at README.md, then docs/architecture.md.
100 lines
5.2 KiB
Ruby
100 lines
5.2 KiB
Ruby
# frozen_string_literal: true
|
|
# Parity harness: run an IDENTICAL scripted sequence of Service operations against the JSON Store and
|
|
# PgStore, then diff the observable results. Any divergence is a bug. This is what proves the two
|
|
# backends behave the same, so the store choice (HELPDESK_DATABASE_URL) stays a pure config switch.
|
|
# Uses a fixed clock for determinism and a fresh (truncated) helpdesk_test DB for the PG side.
|
|
# HELPDESK_TEST_DATABASE_URL=postgres://... ruby db/parity_check.rb
|
|
require_relative "../lib/helpdesk/domain"
|
|
require_relative "../lib/helpdesk/pg_store"
|
|
|
|
TEST_URL = ENV["HELPDESK_TEST_DATABASE_URL"] or abort "set HELPDESK_TEST_DATABASE_URL"
|
|
CLOCK = -> { Time.utc(2026, 7, 23, 12, 0, 0) } # fixed clock
|
|
|
|
def fresh_pg
|
|
db = Sequel.connect(TEST_URL)
|
|
db.run "TRUNCATE orgs, campaigns, dids, people, phone_numbers, events, quarantine;
|
|
ALTER SEQUENCE org_id_seq RESTART; ALTER SEQUENCE campaign_id_seq RESTART;
|
|
ALTER SEQUENCE person_id_seq RESTART; ALTER SEQUENCE event_id_seq RESTART;
|
|
ALTER SEQUENCE ledger_seq RESTART;"
|
|
db.disconnect
|
|
Helpdesk::Service.new(Helpdesk::PgStore.new(TEST_URL), clock: CLOCK)
|
|
end
|
|
|
|
def fresh_json = Helpdesk::Service.new(Helpdesk::Store.new, clock: CLOCK)
|
|
|
|
# The identical script. Returns an ordered list of [label, observable-result] to diff. Runs the
|
|
# formerly-in-place-mutating paths: webhooks, notes/resolution, people CRUD, AI-context CAS + pen-edit,
|
|
# ledger apply, reconcile, command apply, quarantine reattach race.
|
|
def run_script(svc)
|
|
out = []
|
|
ts = "2026-07-23T11:00:00Z"
|
|
# a full inbound call lifecycle
|
|
svc.incoming(call_id: "c1", ts: ts, number: "+420602345678", presentation: "allowed", device_id: "dev1")
|
|
svc.answered(call_id: "c1", ts: "2026-07-23T11:00:05Z")
|
|
svc.ended(call_id: "c1", ts: "2026-07-23T11:01:00Z", duration_s: 55, disconnect_cause: "local")
|
|
svc.recording_uploaded(recording_uuid: "u1", call_id: "c1", url: "/rec/u1", audio_path: "/tmp/u1.audio")
|
|
svc.mark_transcribed(call_id: "c1", transcript: "ahoj svete")
|
|
svc.mark_summarised(call_id: "c1", summary: "test call", action_items: [{ "text" => "do x", "kind" => "doc" }],
|
|
caller_facts: ["fact a"], context_digest: "digest a")
|
|
svc.set_notes(call_id: "c1", notes: "operator note", resolution: "solved")
|
|
out << ["c1", svc.event_by_call_id("c1")]
|
|
# quarantine-before-incoming race
|
|
svc.recording_uploaded(recording_uuid: "u2", call_id: "c2", url: "/rec/u2", audio_path: "/tmp/u2.audio")
|
|
svc.incoming(call_id: "c2", ts: "2026-07-23T11:05:00Z", number: "+420602345678", presentation: "allowed")
|
|
out << ["c2_reattached_rec", svc.event_by_call_id("c2")[:recording_url]]
|
|
# outgoing
|
|
svc.outgoing(call_id: "c3", ts: "2026-07-23T11:10:00Z", number: "+420777111222", device_id: "dev1")
|
|
out << ["c3_dir", svc.event_by_call_id("c3")[:direction]]
|
|
# people CRUD
|
|
pd = svc.create_person(name: "Alice", operator: "tmobile", numbers: ["+420602345678"], email: "a@x.cz")
|
|
pid = pd[:id]
|
|
svc.update_person(person_id: pid, position: "technik", context: "vip")
|
|
svc.set_person_context(person_id: pid, context: "vip caller")
|
|
out << ["person", svc.person_detail(pid)&.slice(:name, :position, :context, :operator, :notes_rev)]
|
|
# AI-context CAS + pen-edit + release
|
|
svc.set_person_ai_context(person_id: pid, ai_context: { "summary" => "S1", "other_context" => ["o1"] }, expected_rev: 0,
|
|
built_from: { "model" => "gemini", "at" => "2026-07-23T11:11:00Z" })
|
|
stale = svc.set_person_ai_context(person_id: pid, ai_context: { "summary" => "STALE" }, expected_rev: 0) # wrong rev
|
|
svc.edit_person_ai_context(person_id: pid, section: "summary", value: "PINNED")
|
|
svc.release_ai_context_section(person_id: pid, section: "summary")
|
|
p = svc.person_detail(pid)
|
|
out << ["ai", { ctx: p[:ai_context], rev: p[:ai_context_rev], stale: stale[:stale] }]
|
|
# reconcile (nothing should trip with the fixed near-time clock, but exercise it)
|
|
svc.reconcile!
|
|
out << ["people_count", svc.store.people.size]
|
|
out << ["event_count", svc.store.events.size]
|
|
# command apply (mute/hold live attrs + dtmf) on the active-ish call
|
|
svc.incoming(call_id: "c4", ts: "2026-07-23T11:20:00Z", number: "+420777111222", presentation: "allowed")
|
|
svc.answered(call_id: "c4", ts: "2026-07-23T11:20:03Z")
|
|
svc.apply_command(id: "k1", call_id: "c4", verb: "mute")
|
|
svc.apply_command(id: "k2", call_id: "c4", verb: "hold")
|
|
svc.apply_command(id: "k3", call_id: "c4", verb: "dtmf", arg: "5")
|
|
e4 = svc.event_by_call_id("c4")
|
|
out << ["c4_attrs", { muted: e4[:muted], on_hold: e4[:on_hold], dtmf: e4[:dtmf_log] }]
|
|
out
|
|
end
|
|
|
|
def normalize(o)
|
|
# stable, backend-agnostic comparison: strip clock-derived nondeterminism is unnecessary (fixed clock),
|
|
# but drop nils vs missing keys mismatch by deep JSON round-trip (both -> plain JSON structures).
|
|
JSON.parse(JSON.generate(o))
|
|
end
|
|
|
|
json = run_script(fresh_json)
|
|
pg = run_script(fresh_pg)
|
|
|
|
fails = 0
|
|
json.each_with_index do |(label, jv), i|
|
|
plabel, pv = pg[i]
|
|
jn = normalize(jv); pn = normalize(pv)
|
|
if jn == pn
|
|
puts " ok #{label}"
|
|
else
|
|
fails += 1
|
|
puts " DIVERGE #{label}"
|
|
puts " json: #{jn.inspect[0, 300]}"
|
|
puts " pg: #{pn.inspect[0, 300]}"
|
|
end
|
|
end
|
|
puts "\n#{json.size - fails}/#{json.size} parity checks passed"
|
|
exit(fails.zero? ? 0 : 1)
|