Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import link.socket.ampere.agents.domain.event.MessageEvent
import link.socket.ampere.agents.domain.event.NotificationEvent
import link.socket.ampere.agents.domain.event.PlanEvent
import link.socket.ampere.agents.domain.event.PermissionDeniedEvent
import link.socket.ampere.agents.domain.event.ProbeEvent
import link.socket.ampere.agents.domain.event.ProviderCallCompletedEvent
import link.socket.ampere.agents.domain.event.ProviderCallStartedEvent
import link.socket.ampere.agents.domain.event.ProductEvent
Expand All @@ -25,6 +26,7 @@ import link.socket.ampere.agents.domain.event.SparkEvent
import link.socket.ampere.agents.domain.event.TaskEvent
import link.socket.ampere.agents.domain.event.TicketEvent
import link.socket.ampere.agents.domain.event.ToolEvent
import link.socket.ampere.probe.Verdict

/**
* Determines the significance of events for observation purposes.
Expand Down Expand Up @@ -139,6 +141,16 @@ object EventCategorizer {
// A rung floor with no satisfying model is a terminal routing failure:
// the call cannot proceed, so it warrants immediate human awareness.
is RoutingEvent.RouteFloorUnmet -> EventSignificance.CRITICAL

// A Probe verdict: a clean pass is routine, every other outcome is a
// decision worth surfacing. An Undetermined is never a quiet pass.
is ProbeEvent.VerdictReached -> when (event.verdict) {
is Verdict.Holds -> EventSignificance.ROUTINE
is Verdict.Warn,
is Verdict.Violated,
is Verdict.Undetermined,
-> EventSignificance.SIGNIFICANT
}
}.let { significance ->
if (event is ProviderCallCompletedEvent && !event.success) {
EventSignificance.SIGNIFICANT
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import link.socket.ampere.agents.domain.event.MessageEvent
import link.socket.ampere.agents.domain.event.NotificationEvent
import link.socket.ampere.agents.domain.event.PlanEvent
import link.socket.ampere.agents.domain.event.PermissionDeniedEvent
import link.socket.ampere.agents.domain.event.ProbeEvent
import link.socket.ampere.agents.domain.event.ProviderCallCompletedEvent
import link.socket.ampere.agents.domain.event.ProviderCallStartedEvent
import link.socket.ampere.agents.domain.event.ProductEvent
Expand All @@ -41,6 +42,7 @@ import link.socket.ampere.agents.domain.event.SparkRemovedEvent
import link.socket.ampere.agents.domain.event.TaskEvent
import link.socket.ampere.agents.domain.event.TicketEvent
import link.socket.ampere.agents.domain.event.ToolEvent
import link.socket.ampere.probe.Verdict

/**
* Renders events to terminal with color coding and formatting.
Expand Down Expand Up @@ -186,6 +188,14 @@ class EventRenderer(
is LinkEvent.LinkResolutionFailed -> "🔌" to red
// Asset resolution: out-of-band, mirroring Link's icon family
is AssetAccessEvent -> "🖼" to green
// Probe verdicts: the colour is the verdict. Undetermined is never
// green — it is not decided, and must not read as a pass.
is ProbeEvent.VerdictReached -> "⚖" to when (event.verdict) {
is Verdict.Holds -> green
is Verdict.Warn -> yellow
is Verdict.Violated -> red
is Verdict.Undetermined -> magenta
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import link.socket.ampere.agents.domain.event.EventRegistry.allEventTypes
* 1. Add it to the [allEventTypes] list
* 2. It will automatically be available in EventTypeParser and EnvironmentService
*
* The list is hand-maintained, so `EventRegistryCompletenessTest` walks the sealed
* [Event] hierarchy and fails when a subtype is missing from it. An event that is not
* here is invisible to `subscribeToAll`, the relay, and every recorded trace.
*
* Benefits:
* - Single place to maintain event type list
* - Prevents inconsistencies between different parts of the system
Expand Down Expand Up @@ -103,6 +107,29 @@ object EventRegistry {
RoutingEvent.RouteSelected.EVENT_TYPE,
RoutingEvent.RouteFallback.EVENT_TYPE,
RoutingEvent.RouteResolved.EVENT_TYPE,
RoutingEvent.RouteFloorUnmet.EVENT_TYPE,

// PlanEvent types
PlanEvent.PlanStepStarted.EVENT_TYPE,
PlanEvent.PlanStepCompleted.EVENT_TYPE,
PlanEvent.TaskAssigned.EVENT_TYPE,
PlanEvent.MonitoringStarted.EVENT_TYPE,

// GitEvent types
GitEvent.BranchCreated.EVENT_TYPE,
GitEvent.Committed.EVENT_TYPE,
GitEvent.Pushed.EVENT_TYPE,
GitEvent.PullRequestCreated.EVENT_TYPE,
GitEvent.FilesStaged.EVENT_TYPE,
GitEvent.OperationFailed.EVENT_TYPE,

// BenchEvent types
BenchEvent.BenchRunStarted.EVENT_TYPE,
BenchEvent.ProbeGraded.EVENT_TYPE,
BenchEvent.BenchRunCompleted.EVENT_TYPE,

// ProbeEvent types
ProbeEvent.VerdictReached.EVENT_TYPE,

// TelemetryEvent types
ProviderCallStartedEvent.EVENT_TYPE,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package link.socket.ampere.agents.domain.event

import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import link.socket.ampere.agents.domain.Urgency
import link.socket.ampere.probe.Probe
import link.socket.ampere.probe.ProbeId
import link.socket.ampere.probe.ProbeSuite
import link.socket.ampere.probe.Verdict

/**
* Verdicts reached by a [Probe], carried on the `EventSerialBus` so a decision
* about a plan, a manifest, or a recalled fact is visible in the trace rather
* than only returned to whoever asked (AMPR-321).
*
* Lives alongside [BenchEvent] for the same reason it does: `Event` is a sealed
* interface, and Kotlin requires sealed subtypes to share both module and
* package with the base type. That constraint is also why the payload is
* primitives plus [Verdict] — a consumer's Probe may judge a subject type
* Ampere cannot name, so the subject itself never crosses this boundary.
*/
@Serializable
sealed interface ProbeEvent : Event {

/** The Probe that reached the verdict. */
val probeId: ProbeId

/**
* One Probe reached a verdict on one identified subject.
*
* [subjectId] is caller-supplied (see [ProbeSuite]) because a Probe's
* subject type is unconstrained and the SPI cannot ask a subject for its
* own identity. [detail] is free-form key/value for the Oscilloscope; keep
* it small — it is stored in every trace that captures this event.
*/
@Serializable
data class VerdictReached(
override val eventId: EventId,
override val eventSource: EventSource,
override val timestamp: Instant,
override val probeId: ProbeId,
val subjectId: String,
val verdict: Verdict,
val detail: Map<String, String> = emptyMap(),
override val urgency: Urgency = Urgency.LOW,
) : ProbeEvent {

override val eventType: EventType = EVENT_TYPE

override fun getSummary(
formatUrgency: (Urgency) -> String,
formatSource: (EventSource) -> String,
): String = buildString {
append("Probe ${probeId.value} on $subjectId: ${verdict.label()}")
verdict.reason?.let { append(" — $it") }
append(" ${formatUrgency(urgency)}")
}

companion object {
const val EVENT_TYPE: EventType = "VerdictReached"
}
}
}

/**
* Short, stable name for a [Verdict] in a summary line. `Undetermined` reads as
* itself and never as a soft pass — the whole point of the fourth value.
*/
private fun Verdict.label(): String = when (this) {
is Verdict.Holds -> "holds"
is Verdict.Warn -> "warn"
is Verdict.Violated -> "violated"
is Verdict.Undetermined -> "undetermined(${cause.name})"
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import link.socket.ampere.agents.domain.event.MessageEvent
import link.socket.ampere.agents.domain.event.NotificationEvent
import link.socket.ampere.agents.domain.event.PermissionDeniedEvent
import link.socket.ampere.agents.domain.event.PlanEvent
import link.socket.ampere.agents.domain.event.ProbeEvent
import link.socket.ampere.agents.domain.event.ProductEvent
import link.socket.ampere.agents.domain.event.ProviderCallCompletedEvent
import link.socket.ampere.agents.domain.event.ProviderCallStartedEvent
Expand All @@ -30,6 +31,7 @@ import link.socket.ampere.agents.domain.event.TaskEvent
import link.socket.ampere.agents.domain.event.TicketEvent
import link.socket.ampere.agents.domain.event.ToolEvent
import link.socket.ampere.agents.events.subscription.Subscription
import link.socket.ampere.probe.Verdict

/**
* Filters events based on significance and displays rich event details.
Expand Down Expand Up @@ -187,6 +189,16 @@ class SignificanceAwareEventLogger(
is BenchEvent.ProbeGraded -> EventSignificance.ROUTINE
is BenchEvent.BenchRunCompleted -> EventSignificance.SIGNIFICANT

// Probe verdicts - a clean pass is routine; anything else is a decision
// a human may need to see. An Undetermined is never a quiet pass.
is ProbeEvent.VerdictReached -> when (event.verdict) {
is Verdict.Holds -> EventSignificance.ROUTINE
is Verdict.Warn,
is Verdict.Violated,
is Verdict.Undetermined,
-> EventSignificance.SIGNIFICANT
}

// Link lifecycle - resolution is routine, but anything that changes or
// denies a Plug's access to a wire is a consent-visible fact.
is LinkEvent.LinkResolved -> EventSignificance.ROUTINE
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,66 @@
package link.socket.ampere.probe

import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import link.socket.ampere.agents.domain.event.EventSource
import link.socket.ampere.agents.domain.event.ProbeEvent
import link.socket.ampere.agents.events.bus.EventSerialBus
import link.socket.ampere.agents.events.utils.generateUUID

/**
* Runs an ordered list of Probes over one subject.
*
* The caller supplies [evaluate]'s `subjectId` because [S] is unconstrained
* and the SPI cannot ask the subject for its own identity.
*
* Pass an [eventBus] to make the verdicts visible in the trace: [evaluate] then
* publishes one [ProbeEvent.VerdictReached] per report, in probe order, after
* every Probe has run. Left null — the default for Bench fixtures and unit
* tests — evaluation is pure and nothing is published.
*
* Wiring is manual, as everywhere else in Ampere: [eventSource], [now], and
* [idGenerator] are constructor parameters so a test can pin what a published
* event carries.
*/
class ProbeSuite<in S>(
private val probes: List<Probe<S>>,
private val eventBus: EventSerialBus? = null,
private val eventSource: EventSource = EventSource.Agent(DEFAULT_SOURCE_ID),
private val now: () -> Instant = { Clock.System.now() },
private val idGenerator: () -> String = { generateUUID() },
) {

suspend fun evaluate(subjectId: String, subject: S): List<ProbeReport> =
probes.map { probe ->
suspend fun evaluate(subjectId: String, subject: S): List<ProbeReport> {
val reports = probes.map { probe ->
ProbeReport(
probeId = probe.id,
subjectId = subjectId,
verdict = probe.evaluate(subject),
)
}

// Published after the whole suite runs, so a subscriber never sees a
// partial verdict set from a suite that threw halfway through.
eventBus?.let { bus ->
reports.forEach { report ->
bus.publish(
ProbeEvent.VerdictReached(
eventId = idGenerator(),
eventSource = eventSource,
timestamp = now(),
probeId = report.probeId,
subjectId = report.subjectId,
verdict = report.verdict,
),
)
}
}

return reports
}

companion object {
/** Attribution for verdicts published by a suite the caller did not name. */
const val DEFAULT_SOURCE_ID: String = "ampere.probe-suite"
}
}
Loading
Loading