merge: integrate feat/backlog-burndown into master
master had advanced 16 commits past feat/backlog-burndown's base, and the two branches independently built four of the same features. Resolved 26 conflicts. Overlap features — kept master's implementation (more complete / production-wired / more robust), dropped the feature branch's parallel constellation: - llama-server health probe: kept master's event-store-backed tps probe; dropped the branch's LlamaLivenessClient (liveness-only, throughput unwired). - event-store probe: kept master's EventStoreHealthProbe; dropped EventStoreLatencyProbe. - brief echo-back gate: kept master's BriefEchoDiff (Jaccard, tolerates rewording); dropped the branch's exact-set-diff BriefEchoComparator/Extractor. - static-first reviewer: kept master's command/exit-code gate (ProcessStaticAnalysisRunner, wired); dropped the branch's structured-finding static_check stage (no-op seam). Its structured-findings model is filed as a follow-up in BACKLOG. Feature-branch net-new work brought in and kept (master had none): - native task tracking (aggregate, agent tools wired into analyst/implementer/reviewer, dependency graph + gates, decompose, REST/CLI, TUI task board) - critique-outcome producer (role-rel §6 — master had deferred it) - stage-level plan checkpointing (C-A2, folded into runPostStageGates) - CLAUDE.md/AGENTS.md L0 standing context - cross-session grants + TUI (grant scopes/revoke, @ picker, session resume browser) Verified: full Gradle compile (all modules + tests) green; tests pass for core:events, core:kernel, infrastructure:workflow, apps:server, apps:cli, testing:integration; tui-go go build + go test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
package com.correx.core.approvals
|
||||
|
||||
import com.correx.core.events.types.ProjectId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import java.nio.file.Paths
|
||||
|
||||
/**
|
||||
* Reserved event-store stream that holds cross-session approval grants (PROJECT and GLOBAL scopes).
|
||||
*
|
||||
* Session-scoped grants live in their own session's stream and die with it; project/global grants
|
||||
* must outlive any single session and be visible to every other one, so they are appended here
|
||||
* instead. The approval gate folds this ledger ([com.correx.core.approvals.DefaultApprovalReducer]
|
||||
* over [DefaultApprovalRepository.getApprovalState]) and unions its grants with the running
|
||||
* session's own before evaluating — see `SessionOrchestrator`. Revocation appends an
|
||||
* [com.correx.core.events.events.ApprovalGrantExpiredEvent] to the same stream, which the reducer
|
||||
* drops, so the audit trail stays intact (invariant #9: record facts, replay reads them).
|
||||
*
|
||||
* The id is a sentinel, not a real session; it never carries a workflow run.
|
||||
*/
|
||||
val GRANT_LEDGER_SESSION_ID = SessionId("__grant_ledger__")
|
||||
|
||||
/**
|
||||
* Derives a stable [ProjectId] from a workspace root path so PROJECT-scoped grants created in one
|
||||
* session match later sessions opened on the same repository. Both the grant-creation site (server)
|
||||
* and the approval gate (orchestrator) run in the same process, so canonicalising to a normalised
|
||||
* absolute path yields the same id on both sides regardless of how the root was spelled.
|
||||
*/
|
||||
object ProjectIdentity {
|
||||
fun of(workspaceRoot: String): ProjectId =
|
||||
ProjectId(Paths.get(workspaceRoot).toAbsolutePath().normalize().toString())
|
||||
}
|
||||
@@ -8,8 +8,15 @@ import kotlinx.serialization.Serializable
|
||||
sealed interface GrantScope {
|
||||
// toolName binds this grant to a specific operation kind (e.g. "shell", "write_file").
|
||||
// A null toolName is rejected at grant-creation time; it is kept nullable here only
|
||||
// for backward-compatible deserialization of legacy events.
|
||||
// for backward-compatible deserialization of legacy events. Every operator-creatable
|
||||
// scope is tool-bound so a grant can never become a blanket "approve everything"
|
||||
// (that is YOLO mode, configured separately).
|
||||
@Serializable data class SESSION(val toolName: String? = null) : GrantScope
|
||||
@Serializable data class STAGE(val stageId: StageId) : GrantScope
|
||||
@Serializable data class PROJECT(val projectId: ProjectId) : GrantScope
|
||||
|
||||
/** Auto-approve [toolName] for any session whose workspace resolves to [projectId]. */
|
||||
@Serializable data class PROJECT(val projectId: ProjectId, val toolName: String? = null) : GrantScope
|
||||
|
||||
/** Auto-approve [toolName] for every session on this machine (the widest scope). */
|
||||
@Serializable data class GLOBAL(val toolName: String? = null) : GrantScope
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.correx.core.events.events
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One prior decision/ADR surfaced as semantically near a new architect decision.
|
||||
* [source] is the L3 entry's turnId (or a decision id) the line came from; [score] is the
|
||||
* similarity of the prior decision to the new one.
|
||||
*/
|
||||
@Serializable
|
||||
data class RelatedDecision(
|
||||
val summary: String,
|
||||
val score: Double,
|
||||
val source: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Display-only architect contradiction surfacing (BACKLOG §B-§4). Lists prior decisions
|
||||
* semantically near the new one so the operator can spot a reversal. Non-blocking: this event
|
||||
* only surfaces similarity candidates for an operator to eyeball — it never halts or fails a
|
||||
* stage, and there is no LLM judge.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("PossibleContradictionFlagged")
|
||||
data class PossibleContradictionFlaggedEvent(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId,
|
||||
val decisionSummary: String,
|
||||
val related: List<RelatedDecision>,
|
||||
) : EventPayload
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.correx.core.events.events
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** A critic's verdict for one review iteration. */
|
||||
@Serializable
|
||||
enum class CritiqueVerdict { APPROVED, CHANGES_REQUESTED }
|
||||
|
||||
/**
|
||||
* The findings a critic (plan critic or code reviewer) raised in one review iteration, plus its
|
||||
* [verdict] for that iteration. This is the **producing side** of critique calibration: the
|
||||
* reviewer-loop runtime correlates the sequence of these across iterations into
|
||||
* [CritiqueOutcomeCorrelatedEvent]s (see CritiqueOutcomeCorrelator), which the
|
||||
* `:core:critique` projection then folds into per-(model, role) precision.
|
||||
*
|
||||
* [iteration] is the refinement-loop iteration (1-based) this review belongs to, so the
|
||||
* correlator can order reviews and tell whether a finding was fixed between rounds. [modelHash]
|
||||
* identifies the model that produced the findings (carried through to the outcome event so
|
||||
* calibration is per-model).
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("CritiqueFindingsRecorded")
|
||||
data class CritiqueFindingsRecordedEvent(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId,
|
||||
val role: CritiqueRole,
|
||||
val modelHash: String,
|
||||
val iteration: Int,
|
||||
val verdict: CritiqueVerdict,
|
||||
val findings: List<CritiqueFinding>,
|
||||
) : EventPayload
|
||||
|
||||
/**
|
||||
* How a prior [CritiqueFinding] actually turned out once the surrounding loop resolved.
|
||||
* UPHELD = the finding was confirmed/actioned; DISMISSED = rejected as a false positive;
|
||||
* INCONCLUSIVE = no signal either way.
|
||||
*/
|
||||
@Serializable
|
||||
enum class CritiqueOutcome { UPHELD, DISMISSED, INCONCLUSIVE }
|
||||
|
||||
/**
|
||||
* Correlates one prior [CritiqueFinding] (by [findingId]) with how it actually turned out, so a
|
||||
* critic's precision can be tracked per model and per role over time (the calibration projection
|
||||
* in `:core:critique`). [modelHash] identifies the model that produced the finding; [severity]
|
||||
* is carried so calibration can be sliced by severity band without re-reading the finding.
|
||||
*
|
||||
* This is the recording half only — who decides UPHELD/DISMISSED lives in the reviewer-loop
|
||||
* runtime and is wired separately.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("CritiqueOutcomeCorrelated")
|
||||
data class CritiqueOutcomeCorrelatedEvent(
|
||||
val sessionId: SessionId,
|
||||
val findingId: String,
|
||||
val role: CritiqueRole,
|
||||
val modelHash: String,
|
||||
val severity: CritiqueSeverity,
|
||||
val outcome: CritiqueOutcome,
|
||||
) : EventPayload
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.correx.core.events.events
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Which critic role produced a [CritiqueFinding]. */
|
||||
@Serializable
|
||||
enum class CritiqueRole { PLAN_CRITIC, CODE_REVIEWER }
|
||||
|
||||
/** Severity of a [CritiqueFinding], highest-impact first. */
|
||||
@Serializable
|
||||
enum class CritiqueSeverity { BLOCKER, MAJOR, MINOR, NIT }
|
||||
|
||||
/**
|
||||
* One structured finding emitted by a critic (plan critic) or reviewer (code reviewer),
|
||||
* replacing the free-text verdict prose those roles produced before. Shared by both roles so
|
||||
* findings can be counted, ranked, and calibrated uniformly.
|
||||
*
|
||||
* [id] is a stable identifier for the finding, used to correlate it with its eventual outcome
|
||||
* (see [CritiqueOutcomeCorrelatedEvent]). [category] is a free-form classification
|
||||
* (e.g. "correctness", "missing_requirement", "style"). [location] optionally points at a
|
||||
* `file:line` or an artifact/stage reference the finding concerns.
|
||||
*
|
||||
* Distinct from `core.validation.ValidationIssue`, which models *structural* validation
|
||||
* (graph/schema) rather than a critic's judgement.
|
||||
*/
|
||||
@Serializable
|
||||
data class CritiqueFinding(
|
||||
val id: String,
|
||||
val role: CritiqueRole,
|
||||
val severity: CritiqueSeverity,
|
||||
val category: String,
|
||||
val message: String,
|
||||
val location: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.correx.core.events.events
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Grants additional egress [hosts] for this session (e.g. the hosts drawn from an approved
|
||||
* research source list). Additive to the static `networkAllowedHosts` allow-list: a host is
|
||||
* permitted if it matches the static list OR any host granted to the session, so egress can be
|
||||
* widened per-session without loosening the global default. Host matching honours the same
|
||||
* exact-or-suffix semantics the static allow-list uses (a granted "example.com" covers
|
||||
* "api.example.com"). [reason] is free-form operator context (e.g. which source list was approved).
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("EgressHostsGranted")
|
||||
data class EgressHostsGrantedEvent(
|
||||
val sessionId: SessionId,
|
||||
val hosts: Set<String>,
|
||||
val reason: String = "",
|
||||
) : EventPayload
|
||||
@@ -31,3 +31,19 @@ data class IdeaDiscardedEvent(
|
||||
val ideaId: String,
|
||||
val sessionId: SessionId,
|
||||
) : EventPayload
|
||||
|
||||
/**
|
||||
* The operator promoted an idea from the cross-session board into the curated per-repo profile
|
||||
* (`<workspaceRoot>/.correx/project.toml`, as a `conventions` entry). Like [IdeaDiscardedEvent] this
|
||||
* is a tombstone — the original [IdeaCapturedEvent] stays in the log (and replays), but the idea-board
|
||||
* projection filters out any promoted [ideaId]. [sessionId] is the capturing session (so all of one
|
||||
* idea's events stay co-located); [text] records the convention text written to the profile.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("IdeaPromoted")
|
||||
data class IdeaPromotedEvent(
|
||||
val ideaId: String,
|
||||
val sessionId: SessionId,
|
||||
val text: String,
|
||||
val timestampMs: Long,
|
||||
) : EventPayload
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.correx.core.events.events
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A research T2 fetch completed and produced content at [contentSha256] (research-workflow-spec §3/§5).
|
||||
*
|
||||
* Promotes the fetch-quality + content-hash that previously lived only inside
|
||||
* [ToolExecutionCompletedEvent] metadata into a first-class event, so "what sources were fetched"
|
||||
* is queryable and renderable directly from the journal. Additive: the metadata write is retained
|
||||
* for existing consumers. [quality] mirrors the extractor's quality marker as recorded in metadata.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("SourceFetched")
|
||||
data class SourceFetchedEvent(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId,
|
||||
val url: String,
|
||||
val contentSha256: String,
|
||||
val quality: String,
|
||||
val byteCount: Int = 0,
|
||||
) : EventPayload
|
||||
|
||||
/**
|
||||
* An extraction was retained but flagged low-quality — below the quality bar (research-workflow-spec §5)
|
||||
* — for operator visibility. Emitted alongside [SourceFetchedEvent] when the fetch cleared rejection
|
||||
* but fell under the minimum-content threshold (JS-rendered SPA, paywall); the content is still kept,
|
||||
* the operator is simply warned that synthesis may want to route around it.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("LowQualityExtraction")
|
||||
data class LowQualityExtractionEvent(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId,
|
||||
val url: String,
|
||||
val contentSha256: String,
|
||||
val reason: String,
|
||||
) : EventPayload
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.correx.core.events.events
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.TaskId
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -10,6 +11,20 @@ data class ChatSessionStartedEvent(
|
||||
val sessionId: SessionId,
|
||||
) : EventPayload
|
||||
|
||||
/**
|
||||
* Records, in the session's own stream, that this run claimed [taskId] and is working it with the
|
||||
* declared [affectedPaths] write scope — a session-local fact so the gates know the active task
|
||||
* without scanning task streams. Re-emitted when the claimant edits affected_paths, so the folded
|
||||
* scope stays current; SessionContextProjection takes the most recent.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("SessionWorkingTask")
|
||||
data class SessionWorkingTaskEvent(
|
||||
val sessionId: SessionId,
|
||||
val taskId: TaskId,
|
||||
val affectedPaths: List<String> = emptyList(),
|
||||
) : EventPayload
|
||||
|
||||
@Serializable
|
||||
@SerialName("SessionWorkspaceBound")
|
||||
data class SessionWorkspaceBoundEvent(
|
||||
@@ -37,3 +52,12 @@ data class ProjectProfileBoundEvent(
|
||||
val conventions: List<String>,
|
||||
val commands: Map<String, String>,
|
||||
) : EventPayload
|
||||
|
||||
@Serializable
|
||||
@SerialName("AgentInstructionsBound")
|
||||
data class AgentInstructionsBoundEvent(
|
||||
val sessionId: SessionId,
|
||||
val workspaceRoot: String,
|
||||
val sources: List<String>, // file names found, e.g. ["CLAUDE.md","AGENTS.md"]
|
||||
val content: String, // concatenated, header-labeled instructions
|
||||
) : EventPayload
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.correx.core.events.events
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Per-stage reconciliation of a stage's produced artifacts against the locked plan's produces-slots,
|
||||
* recorded so plan adherence is observable and replayable. Emitted only on plan-driven (phase-2)
|
||||
* runs — a session whose log contains an [ExecutionPlanLockedEvent]; non-plan workflows emit nothing.
|
||||
*
|
||||
* The *passed* variant means every expected slot was produced: [producedArtifacts] is a superset of
|
||||
* [expectedArtifacts] (extra artifacts beyond the expected set do not fail the checkpoint).
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("StageCheckpointPassed")
|
||||
data class StageCheckpointPassedEvent(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId,
|
||||
val expectedArtifacts: Set<String>,
|
||||
val producedArtifacts: Set<String>,
|
||||
) : EventPayload
|
||||
|
||||
/**
|
||||
* Per-stage reconciliation of a stage's produced artifacts against the locked plan's produces-slots,
|
||||
* recorded so plan adherence is observable and replayable. Emitted only on plan-driven (phase-2)
|
||||
* runs — a session whose log contains an [ExecutionPlanLockedEvent]; non-plan workflows emit nothing.
|
||||
*
|
||||
* The *failed* variant accompanies the stage halt owned by `verifyProduces` and surfaces *why* the
|
||||
* plan diverged: [missingArtifacts] are the expected slots ([expectedArtifacts] minus
|
||||
* [producedArtifacts]) the stage never produced.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("StageCheckpointFailed")
|
||||
data class StageCheckpointFailedEvent(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId,
|
||||
val expectedArtifacts: Set<String>,
|
||||
val producedArtifacts: Set<String>,
|
||||
val missingArtifacts: Set<String>,
|
||||
) : EventPayload
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.correx.core.events.events
|
||||
|
||||
import com.correx.core.events.types.ProjectId
|
||||
import com.correx.core.events.types.TaskId
|
||||
import com.correx.core.events.types.TaskLinkType
|
||||
import com.correx.core.events.types.TaskNoteAuthor
|
||||
import com.correx.core.events.types.TaskTargetKind
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Marker over every task-lifecycle event. Deliberately *not* part of the serialized
|
||||
* [EventPayload] hierarchy — concrete events implement both interfaces — so projections
|
||||
* can recover the owning [taskId] from any payload via `payload as? TaskEvent` without a
|
||||
* giant `when`. Status is never carried on the wire: it is derived by the task reducer
|
||||
* from these facts (mirroring how session status is derived from stage events).
|
||||
*/
|
||||
sealed interface TaskEvent {
|
||||
val taskId: TaskId
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskCreated")
|
||||
data class TaskCreatedEvent(
|
||||
override val taskId: TaskId,
|
||||
val projectId: ProjectId,
|
||||
val key: String,
|
||||
val title: String,
|
||||
val goal: String,
|
||||
val acceptanceCriteria: List<String> = emptyList(),
|
||||
val affectedPaths: List<String> = emptyList(),
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskClaimed")
|
||||
data class TaskClaimedEvent(
|
||||
override val taskId: TaskId,
|
||||
val claimant: String,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskReleased")
|
||||
data class TaskReleasedEvent(
|
||||
override val taskId: TaskId,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskBlocked")
|
||||
data class TaskBlockedEvent(
|
||||
override val taskId: TaskId,
|
||||
val reason: String,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskUnblocked")
|
||||
data class TaskUnblockedEvent(
|
||||
override val taskId: TaskId,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskSubmittedForReview")
|
||||
data class TaskSubmittedForReviewEvent(
|
||||
override val taskId: TaskId,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskCompleted")
|
||||
data class TaskCompletedEvent(
|
||||
override val taskId: TaskId,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskReopened")
|
||||
data class TaskReopenedEvent(
|
||||
override val taskId: TaskId,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskCancelled")
|
||||
data class TaskCancelledEvent(
|
||||
override val taskId: TaskId,
|
||||
val reason: String? = null,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskEdited")
|
||||
data class TaskEditedEvent(
|
||||
override val taskId: TaskId,
|
||||
val title: String? = null,
|
||||
val goal: String? = null,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskAcceptanceCriteriaSet")
|
||||
data class TaskAcceptanceCriteriaSetEvent(
|
||||
override val taskId: TaskId,
|
||||
val criteria: List<String>,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskAffectedPathsSet")
|
||||
data class TaskAffectedPathsSetEvent(
|
||||
override val taskId: TaskId,
|
||||
val paths: List<String>,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskLinked")
|
||||
data class TaskLinkedEvent(
|
||||
override val taskId: TaskId,
|
||||
val targetId: String,
|
||||
// `linkType`, not `type`: the JSON polymorphic class discriminator is "type".
|
||||
@SerialName("linkType") val type: TaskLinkType,
|
||||
val targetKind: TaskTargetKind,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskUnlinked")
|
||||
data class TaskUnlinkedEvent(
|
||||
override val taskId: TaskId,
|
||||
val targetId: String,
|
||||
@SerialName("linkType") val type: TaskLinkType,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskNoteAdded")
|
||||
data class TaskNoteAddedEvent(
|
||||
override val taskId: TaskId,
|
||||
val author: TaskNoteAuthor,
|
||||
val body: String,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
/**
|
||||
* Soft-delete tombstone. The event stays in the log (history is never rewritten); the board
|
||||
* projection drops the task from active views. Distinct from cancellation, which is a
|
||||
* lifecycle outcome that stays visible as CANCELLED.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("TaskDeleted")
|
||||
data class TaskDeletedEvent(
|
||||
override val taskId: TaskId,
|
||||
) : EventPayload, TaskEvent
|
||||
@@ -4,6 +4,7 @@ import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -52,6 +53,10 @@ data class ToolInvocationRequestedEvent(
|
||||
val toolName: String,
|
||||
val tier: Tier,
|
||||
val request: ToolRequest,
|
||||
// The tool's declared capabilities, recorded at emission so replay classifies the call by what
|
||||
// it could actually do (e.g. FILE_READ/FILE_WRITE) without re-deriving from the live registry.
|
||||
// Defaulted for backward compatibility: events written before this field deserialize as empty.
|
||||
val capabilities: Set<ToolCapability> = emptySet(),
|
||||
) : EventPayload
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.AgentInstructionsBoundEvent
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalGrantCreatedEvent
|
||||
import com.correx.core.events.events.ApprovalGrantExpiredEvent
|
||||
@@ -13,14 +14,19 @@ import com.correx.core.events.events.BriefGroundingCheckedEvent
|
||||
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
||||
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||
import com.correx.core.events.events.ChatSessionStartedEvent
|
||||
import com.correx.core.events.events.SessionWorkingTaskEvent
|
||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||
import com.correx.core.events.events.ClarificationRequestedEvent
|
||||
import com.correx.core.events.events.ChatTurnEvent
|
||||
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
|
||||
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
|
||||
import com.correx.core.events.events.RouterNarrationEvent
|
||||
import com.correx.core.events.events.OperatorProfileBoundEvent
|
||||
import com.correx.core.events.events.ProjectProfileBoundEvent
|
||||
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
||||
import com.correx.core.events.events.ContextTruncatedEvent
|
||||
import com.correx.core.events.events.PossibleContradictionFlaggedEvent
|
||||
import com.correx.core.events.events.EgressHostsGrantedEvent
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
||||
@@ -28,9 +34,11 @@ import com.correx.core.events.events.HealthDegradedEvent
|
||||
import com.correx.core.events.events.HealthRestoredEvent
|
||||
import com.correx.core.events.events.IdeaCapturedEvent
|
||||
import com.correx.core.events.events.IdeaDiscardedEvent
|
||||
import com.correx.core.events.events.IdeaPromotedEvent
|
||||
import com.correx.core.events.events.JournalCompactedEvent
|
||||
import com.correx.core.events.events.FileWrittenEvent
|
||||
import com.correx.core.events.events.L3MemoryRetrievedEvent
|
||||
import com.correx.core.events.events.LowQualityExtractionEvent
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.InitialIntentEvent
|
||||
import com.correx.core.events.events.InferenceFailedEvent
|
||||
@@ -46,6 +54,9 @@ import com.correx.core.events.events.RepoMapComputedEvent
|
||||
import com.correx.core.events.events.RetryAttemptedEvent
|
||||
import com.correx.core.events.events.WorkspaceStateObservedEvent
|
||||
import com.correx.core.events.events.RiskAssessedEvent
|
||||
import com.correx.core.events.events.SourceFetchedEvent
|
||||
import com.correx.core.events.events.StageCheckpointFailedEvent
|
||||
import com.correx.core.events.events.StageCheckpointPassedEvent
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.PreemptRedirectBlockedEvent
|
||||
import com.correx.core.events.events.PreemptRedirectEvent
|
||||
@@ -63,6 +74,22 @@ import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowProposedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.events.TaskCreatedEvent
|
||||
import com.correx.core.events.events.TaskClaimedEvent
|
||||
import com.correx.core.events.events.TaskReleasedEvent
|
||||
import com.correx.core.events.events.TaskBlockedEvent
|
||||
import com.correx.core.events.events.TaskUnblockedEvent
|
||||
import com.correx.core.events.events.TaskSubmittedForReviewEvent
|
||||
import com.correx.core.events.events.TaskCompletedEvent
|
||||
import com.correx.core.events.events.TaskReopenedEvent
|
||||
import com.correx.core.events.events.TaskCancelledEvent
|
||||
import com.correx.core.events.events.TaskEditedEvent
|
||||
import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent
|
||||
import com.correx.core.events.events.TaskAffectedPathsSetEvent
|
||||
import com.correx.core.events.events.TaskLinkedEvent
|
||||
import com.correx.core.events.events.TaskUnlinkedEvent
|
||||
import com.correx.core.events.events.TaskNoteAddedEvent
|
||||
import com.correx.core.events.events.TaskDeletedEvent
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.polymorphic
|
||||
@@ -116,11 +143,13 @@ val eventModule = SerializersModule {
|
||||
subclass(PlanLintCompletedEvent::class)
|
||||
subclass(RiskAssessedEvent::class)
|
||||
subclass(ChatSessionStartedEvent::class)
|
||||
subclass(SessionWorkingTaskEvent::class)
|
||||
subclass(ChatTurnEvent::class)
|
||||
subclass(RouterNarrationEvent::class)
|
||||
subclass(SessionWorkspaceBoundEvent::class)
|
||||
subclass(OperatorProfileBoundEvent::class)
|
||||
subclass(ProjectProfileBoundEvent::class)
|
||||
subclass(AgentInstructionsBoundEvent::class)
|
||||
subclass(L3MemoryRetrievedEvent::class)
|
||||
subclass(ContextTruncatedEvent::class)
|
||||
subclass(ExecutionPlanLockedEvent::class)
|
||||
@@ -133,6 +162,31 @@ val eventModule = SerializersModule {
|
||||
subclass(WorkflowProposedEvent::class)
|
||||
subclass(IdeaCapturedEvent::class)
|
||||
subclass(IdeaDiscardedEvent::class)
|
||||
subclass(IdeaPromotedEvent::class)
|
||||
subclass(CritiqueOutcomeCorrelatedEvent::class)
|
||||
subclass(CritiqueFindingsRecordedEvent::class)
|
||||
subclass(StageCheckpointPassedEvent::class)
|
||||
subclass(StageCheckpointFailedEvent::class)
|
||||
subclass(PossibleContradictionFlaggedEvent::class)
|
||||
subclass(SourceFetchedEvent::class)
|
||||
subclass(LowQualityExtractionEvent::class)
|
||||
subclass(EgressHostsGrantedEvent::class)
|
||||
subclass(TaskCreatedEvent::class)
|
||||
subclass(TaskClaimedEvent::class)
|
||||
subclass(TaskReleasedEvent::class)
|
||||
subclass(TaskBlockedEvent::class)
|
||||
subclass(TaskUnblockedEvent::class)
|
||||
subclass(TaskSubmittedForReviewEvent::class)
|
||||
subclass(TaskCompletedEvent::class)
|
||||
subclass(TaskReopenedEvent::class)
|
||||
subclass(TaskCancelledEvent::class)
|
||||
subclass(TaskEditedEvent::class)
|
||||
subclass(TaskAcceptanceCriteriaSetEvent::class)
|
||||
subclass(TaskAffectedPathsSetEvent::class)
|
||||
subclass(TaskLinkedEvent::class)
|
||||
subclass(TaskUnlinkedEvent::class)
|
||||
subclass(TaskNoteAddedEvent::class)
|
||||
subclass(TaskDeletedEvent::class)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ typealias TransitionId = TypeId
|
||||
typealias ArtifactId = TypeId
|
||||
|
||||
|
||||
// Tasks types
|
||||
typealias TaskId = TypeId
|
||||
|
||||
|
||||
// Context types
|
||||
typealias ContextPackId = TypeId
|
||||
typealias ContextEntryId = TypeId
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.correx.core.events.types
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Edge types for the work graph. A task links to other tasks, artifacts (by [ArtifactId]),
|
||||
* or sessions (by [SessionId]) — the target is stored as a plain id string plus a type.
|
||||
*/
|
||||
@Serializable
|
||||
enum class TaskLinkType {
|
||||
@SerialName("DEPENDS_ON") DEPENDS_ON,
|
||||
@SerialName("BLOCKS") BLOCKS,
|
||||
@SerialName("IMPLEMENTS") IMPLEMENTS,
|
||||
@SerialName("RELATES_TO") RELATES_TO,
|
||||
@SerialName("PRODUCED") PRODUCED,
|
||||
@SerialName("CONTEXT") CONTEXT,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.correx.core.events.types
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Who authored a task note — used to keep the agent and human lanes separable. */
|
||||
@Serializable
|
||||
enum class TaskNoteAuthor {
|
||||
@SerialName("AGENT") AGENT,
|
||||
@SerialName("HUMAN") HUMAN,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.correx.core.events.types
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* What a task link points at — recorded on the link so the context bundle dispatches resolution
|
||||
* deterministically (task lookup vs doc resolution vs left-raw) instead of inferring from the id.
|
||||
*/
|
||||
@Serializable
|
||||
enum class TaskTargetKind {
|
||||
@SerialName("TASK") TASK,
|
||||
@SerialName("DOC") DOC,
|
||||
@SerialName("ARTIFACT") ARTIFACT,
|
||||
@SerialName("SESSION") SESSION,
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.correx.core.sessions.projections
|
||||
|
||||
import com.correx.core.events.events.EgressHostsGrantedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
|
||||
/**
|
||||
* Folds the additional egress hosts granted to one session. State is the running union of every
|
||||
* [EgressHostsGrantedEvent.hosts] emitted for [sessionId]; grants are additive (set union) and
|
||||
* unrelated events — including grants for other sessions — are ignored. The resulting
|
||||
* `Set<String>` is the per-session input to
|
||||
* [com.correx.core.toolintent.rules.EgressAllowlist.isAllowed], unioned with the static
|
||||
* `networkAllowedHosts` at the network gate.
|
||||
*/
|
||||
class EgressAllowlistProjection(
|
||||
private val sessionId: SessionId,
|
||||
) : Projection<Set<String>> {
|
||||
|
||||
override fun initial(): Set<String> = emptySet()
|
||||
|
||||
override fun apply(state: Set<String>, event: StoredEvent): Set<String> {
|
||||
val payload = event.payload
|
||||
return if (payload is EgressHostsGrantedEvent && payload.sessionId == sessionId) {
|
||||
state + payload.hosts
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.correx.core.tools.contract
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* What a tool is allowed to do, independent of its name. Plane-2 (`core:toolintent`) rules dispatch
|
||||
* on these — never on tool names — and they are recorded on
|
||||
* [com.correx.core.events.events.ToolInvocationRequestedEvent] at emission, so replay classifies a
|
||||
* call by the capability it actually had rather than re-deriving it from the live registry.
|
||||
*
|
||||
* Lives in `core:events` (the lowest module) so events can carry it; `core:tools` and everything
|
||||
* above import it from here — the package name is unchanged, so the move is import-transparent.
|
||||
*/
|
||||
@Serializable
|
||||
enum class ToolCapability {
|
||||
FILE_READ,
|
||||
FILE_WRITE,
|
||||
NETWORK_ACCESS,
|
||||
SHELL_EXEC,
|
||||
PROCESS_SPAWN,
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.IdeaPromotedEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.RiskAssessedEvent
|
||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||
@@ -81,6 +82,12 @@ class EventSerializationHardeningTest {
|
||||
terminalStageId = stageId,
|
||||
totalStages = 1,
|
||||
),
|
||||
"IdeaPromoted" to IdeaPromotedEvent(
|
||||
ideaId = "idea-1",
|
||||
sessionId = sessionId,
|
||||
text = "cache the repo map across sessions",
|
||||
timestampMs = 1_700_000_000_000,
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.PossibleContradictionFlaggedEvent
|
||||
import com.correx.core.events.events.RelatedDecision
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ContradictionEventSerializationTest {
|
||||
|
||||
@Test
|
||||
fun `PossibleContradictionFlaggedEvent round-trips as polymorphic EventPayload`() {
|
||||
val sample: EventPayload = PossibleContradictionFlaggedEvent(
|
||||
sessionId = SessionId("s"),
|
||||
stageId = StageId("architect"),
|
||||
decisionSummary = "Use Postgres for the event store.",
|
||||
related = listOf(
|
||||
RelatedDecision(
|
||||
summary = "Decided to use SQLite for the event store.",
|
||||
score = 0.91,
|
||||
source = "project:/repo",
|
||||
),
|
||||
),
|
||||
)
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
assertTrue(
|
||||
encoded.contains("\"type\":\"PossibleContradictionFlagged\""),
|
||||
"SerialName must be present: $encoded",
|
||||
)
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.CritiqueOutcome
|
||||
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
|
||||
import com.correx.core.events.events.CritiqueRole
|
||||
import com.correx.core.events.events.CritiqueSeverity
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CritiqueCalibrationEventSerializationTest {
|
||||
|
||||
@Test
|
||||
fun `CritiqueOutcomeCorrelatedEvent round-trips as polymorphic EventPayload`() {
|
||||
val sample: EventPayload = CritiqueOutcomeCorrelatedEvent(
|
||||
sessionId = SessionId("s"),
|
||||
findingId = "f-1",
|
||||
role = CritiqueRole.CODE_REVIEWER,
|
||||
modelHash = "sha256:abc",
|
||||
severity = CritiqueSeverity.MAJOR,
|
||||
outcome = CritiqueOutcome.UPHELD,
|
||||
)
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
assertTrue(
|
||||
encoded.contains("\"type\":\"CritiqueOutcomeCorrelated\""),
|
||||
"SerialName must be present: $encoded",
|
||||
)
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.EgressHostsGrantedEvent
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class EgressEventSerializationTest {
|
||||
|
||||
@Test
|
||||
fun `EgressHostsGrantedEvent round-trips as polymorphic EventPayload`() {
|
||||
val sample: EventPayload = EgressHostsGrantedEvent(
|
||||
sessionId = SessionId("s"),
|
||||
hosts = setOf("example.com", "docs.rust-lang.org"),
|
||||
reason = "approved research source list",
|
||||
)
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
assertTrue(encoded.contains("\"type\":\"EgressHostsGranted\""), "SerialName must be present: $encoded")
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
}
|
||||
+15
@@ -1,5 +1,6 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.AgentInstructionsBoundEvent
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.ProjectProfileBoundEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
@@ -24,6 +25,20 @@ class ProjectProfileBoundEventSerializationTest {
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AgentInstructionsBoundEvent round-trips as polymorphic EventPayload`() {
|
||||
val sample: EventPayload = AgentInstructionsBoundEvent(
|
||||
sessionId = SessionId("s1"),
|
||||
workspaceRoot = "/repo",
|
||||
sources = listOf("CLAUDE.md", "AGENTS.md"),
|
||||
content = "# CLAUDE.md\n\nbe careful\n\n# AGENTS.md\n\nuse tools",
|
||||
)
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
assertTrue(encoded.contains("\"type\":\"AgentInstructionsBound\""), "SerialName must be present: $encoded")
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `round-trips with empty fields`() {
|
||||
val sample: EventPayload = ProjectProfileBoundEvent(
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.LowQualityExtractionEvent
|
||||
import com.correx.core.events.events.SourceFetchedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ResearchSourceEventSerializationTest {
|
||||
|
||||
private val sessionId = SessionId("s")
|
||||
private val stageId = StageId("st")
|
||||
|
||||
@Test
|
||||
fun `SourceFetchedEvent round-trips as polymorphic EventPayload`() {
|
||||
val sample: EventPayload = SourceFetchedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
url = "https://example.com/a",
|
||||
contentSha256 = "abc123",
|
||||
quality = "OK",
|
||||
byteCount = 4096,
|
||||
)
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
assertTrue(encoded.contains("\"type\":\"SourceFetched\""), "SerialName must be present: $encoded")
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LowQualityExtractionEvent round-trips as polymorphic EventPayload`() {
|
||||
val sample: EventPayload = LowQualityExtractionEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
url = "https://example.com/spa",
|
||||
contentSha256 = "def456",
|
||||
reason = "extracted 12 chars, below minimum 200",
|
||||
)
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
assertTrue(encoded.contains("\"type\":\"LowQualityExtraction\""), "SerialName must be present: $encoded")
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.StageCheckpointFailedEvent
|
||||
import com.correx.core.events.events.StageCheckpointPassedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class StageCheckpointEventSerializationTest {
|
||||
|
||||
@Test
|
||||
fun `StageCheckpointPassedEvent round-trips as polymorphic EventPayload`() {
|
||||
val sample: EventPayload = StageCheckpointPassedEvent(
|
||||
sessionId = SessionId("s"),
|
||||
stageId = StageId("plan"),
|
||||
expectedArtifacts = setOf("plan.json"),
|
||||
producedArtifacts = setOf("plan.json", "notes.md"),
|
||||
)
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
assertTrue(encoded.contains("\"type\":\"StageCheckpointPassed\""), "SerialName must be present: $encoded")
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StageCheckpointFailedEvent round-trips as polymorphic EventPayload`() {
|
||||
val sample: EventPayload = StageCheckpointFailedEvent(
|
||||
sessionId = SessionId("s"),
|
||||
stageId = StageId("plan"),
|
||||
expectedArtifacts = setOf("plan.json", "diff.patch"),
|
||||
producedArtifacts = setOf("plan.json"),
|
||||
missingArtifacts = setOf("diff.patch"),
|
||||
)
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
assertTrue(encoded.contains("\"type\":\"StageCheckpointFailed\""), "SerialName must be present: $encoded")
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.correx.core.sessions.projections
|
||||
|
||||
import com.correx.core.events.events.EgressHostsGrantedEvent
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.InitialIntentEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class EgressAllowlistProjectionTest {
|
||||
|
||||
private val sessionId = SessionId("s1")
|
||||
private val projection = EgressAllowlistProjection(sessionId)
|
||||
|
||||
private fun stored(payload: EventPayload, eventId: String = "e1", session: SessionId = sessionId) =
|
||||
StoredEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(eventId),
|
||||
sessionId = session,
|
||||
timestamp = Instant.parse("2026-01-01T00:00:00Z"),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
sequence = 1L,
|
||||
sessionSequence = 1L,
|
||||
payload = payload,
|
||||
)
|
||||
|
||||
private fun fold(vararg events: StoredEvent): Set<String> =
|
||||
events.fold(projection.initial()) { acc, e -> projection.apply(acc, e) }
|
||||
|
||||
@Test
|
||||
fun `initial state is empty`() {
|
||||
assertEquals(emptySet(), projection.initial())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grants fold into a union`() {
|
||||
val result = fold(
|
||||
stored(EgressHostsGrantedEvent(sessionId, setOf("a.com", "b.com")), "e1"),
|
||||
stored(EgressHostsGrantedEvent(sessionId, setOf("b.com", "c.com")), "e2"),
|
||||
)
|
||||
assertEquals(setOf("a.com", "b.com", "c.com"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grants for other sessions are ignored`() {
|
||||
val result = fold(
|
||||
stored(EgressHostsGrantedEvent(sessionId, setOf("mine.com")), "e1"),
|
||||
stored(EgressHostsGrantedEvent(SessionId("other"), setOf("theirs.com")), "e2"),
|
||||
)
|
||||
assertEquals(setOf("mine.com"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrelated events are ignored`() {
|
||||
val result = fold(
|
||||
stored(InitialIntentEvent(sessionId, "do a thing"), "e1"),
|
||||
stored(EgressHostsGrantedEvent(sessionId, setOf("kept.com")), "e2"),
|
||||
)
|
||||
assertEquals(setOf("kept.com"), result)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user