feat(tasks): native task tracking — aggregate, agent tools, context bundle

Adds task tracking as a first-class event-sourced aggregate (ADR-0012). The
event log stays the only source of truth; the board is a projection, and all of
a project's task events live in one stream (tasks:<projectId>).

core:
- new core:tasks module mirroring core:sessions: TaskStatus/State/Reducer/
  BoardProjector/CounterProjection/Repository + TaskService write path that
  appends events via the existing EventStore (no new storage)
- task events in core:events (sealed marker TaskEvent), registered in
  Serialization.kt; status is derived by the reducer from lifecycle facts
- work-graph edges: TaskLinkType + typed TaskTargetKind on the link, so bundle
  resolution dispatches on the recorded kind instead of guessing from the id
- delete is a soft tombstone (TaskDeletedEvent); cancel stays a visible outcome

surfaces:
- agent tools (Tier T2 mutators, T1 read): task_create / task_update /
  task_delete / task_context, registered in both the main and per-workspace
  tool registries
- REST GET /tasks/{id}/context for external agents

context bundle (TaskContextAssembler):
- task fields + acceptance criteria + relevant files + resolved dependency
  tasks (live status) + raw related links + notes, with a compact render()
- semantic enrichment via a TaskKnowledgeRetriever port bridged to the existing
  L3 retriever (late-bound holder for composition-root ordering)
- ADR/doc resolution via a TaskDocumentResolver port + filesystem adapter
  (docs/decisions/adr-*.md, padding-agnostic; *.md paths)

Unit-verified across core:tasks / infrastructure:tools / apps:server; full
gradlew check green. Not yet live-QA'd end-to-end against a running server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 19:00:58 +00:00
parent 7909357747
commit 0c6ac903b7
51 changed files with 2431 additions and 2 deletions
@@ -35,6 +35,7 @@ import com.correx.core.router.model.RouterConfig
import com.correx.core.router.model.WorkflowSummary
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.registry.ToolRegistry
import com.correx.infrastructure.artifactscas.CasArtifactStore
@@ -104,8 +105,11 @@ object InfrastructureModule {
return runBlocking { CasArtifactStore.open(CasConfig(rootDir), index) }
}
fun createToolRegistry(config: ToolConfig): ToolRegistry =
DefaultToolRegistry.build(config.buildTools())
fun createToolRegistry(
config: ToolConfig,
extraTools: List<Tool> = emptyList(),
): ToolRegistry =
DefaultToolRegistry.build(config.buildTools() + extraTools)
fun createLlamaCppProvider(
modelId: String,
+1
View File
@@ -13,6 +13,7 @@ dependencies {
implementation project(":core:events")
implementation project(":core:approvals")
implementation project(":core:sessions")
implementation project(":core:tasks")
implementation project(":infrastructure:tools:filesystem")
implementation project(":core:artifacts")
implementation project(":core:artifacts-store")
@@ -0,0 +1,56 @@
package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.TaskId
import com.correx.core.tasks.TaskContextAssembler
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
/**
* Agent-facing tool: fetch a task's token-optimized context bundle in one call. Read-only, so
* it sits at the lowest tier (like file_read) — agents call it before starting work on a task.
*/
class TaskContextTool(private val assembler: TaskContextAssembler) : Tool, ToolExecutor {
override val name: String = "task_context"
override val description: String =
"Fetch a task's context bundle (goal, acceptance criteria, resolved dependencies, " +
"related links, relevant files, notes) in one call. Use before working a task."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") }
}
put("required", buildJsonArray { add(JsonPrimitive("id")) })
}
override val tier: Tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult {
request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).")
return ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val id = request.stringParam("id")
?: return ToolResult.Failure(request.invocationId, "Missing 'id' (string).", recoverable = false)
val bundle = assembler.assemble(TaskId(id))
?: return ToolResult.Failure(request.invocationId, "No such task: $id", recoverable = false)
return ToolResult.Success(
invocationId = request.invocationId,
output = bundle.render(),
metadata = mapOf("taskId" to id, "status" to bundle.status),
)
}
}
@@ -0,0 +1,89 @@
package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ProjectId
import com.correx.core.tasks.TaskService
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
/** Agent-facing tool: create a task in a project's work graph. */
class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
override val name: String = "task_create"
override val description: String =
"Create a task in a project's work graph and return its id (e.g. 'auth-142')."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("project") {
put("type", "string")
put("description", "Project key, e.g. 'auth'. The new task id is '<project>-<n>'.")
}
putJsonObject("title") {
put("type", "string")
put("description", "Short imperative title.")
}
putJsonObject("goal") {
put("type", "string")
put("description", "What 'done' looks like, in a sentence or two.")
}
putJsonObject("acceptance_criteria") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Concrete, verifiable criteria.")
}
putJsonObject("affected_paths") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Globs/paths this task is expected to touch.")
}
}
put(
"required",
buildJsonArray {
add(JsonPrimitive("project"))
add(JsonPrimitive("title"))
add(JsonPrimitive("goal"))
},
)
}
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult {
request.stringParam("project") ?: return ValidationResult.Invalid("Missing 'project' (string).")
request.stringParam("title") ?: return ValidationResult.Invalid("Missing 'title' (string).")
request.stringParam("goal") ?: return ValidationResult.Invalid("Missing 'goal' (string).")
return ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val invalid = validateRequest(request) as? ValidationResult.Invalid
if (invalid != null) {
return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false)
}
val task = service.createTask(
projectId = ProjectId(request.stringParam("project")!!),
title = request.stringParam("title")!!,
goal = request.stringParam("goal")!!,
acceptanceCriteria = request.listParam("acceptance_criteria"),
affectedPaths = request.listParam("affected_paths"),
)
return ToolResult.Success(
invocationId = request.invocationId,
output = "Created ${task.taskId.value}: ${task.state.title}",
metadata = mapOf("taskId" to task.taskId.value, "status" to task.state.status.name),
)
}
}
@@ -0,0 +1,55 @@
package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.TaskId
import com.correx.core.tasks.TaskService
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
/**
* Agent-facing tool: soft-delete a task (tombstone). History is preserved in the event log;
* the task drops out of active views. Use 'task_update' with action=cancel to record an
* abandoned-but-visible outcome instead.
*/
class TaskDeleteTool(private val service: TaskService) : Tool, ToolExecutor {
override val name: String = "task_delete"
override val description: String =
"Soft-delete a task (removes it from active views; history is kept). " +
"For an abandoned-but-visible task, use task_update action=cancel instead."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") }
}
put("required", buildJsonArray { add(JsonPrimitive("id")) })
}
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult {
request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).")
return ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val id = request.stringParam("id")
?: return ToolResult.Failure(request.invocationId, "Missing 'id' (string).", recoverable = false)
return if (service.delete(TaskId(id))) {
ToolResult.Success(request.invocationId, output = "Deleted $id", metadata = mapOf("taskId" to id))
} else {
ToolResult.Failure(request.invocationId, "No such task: $id", recoverable = false)
}
}
}
@@ -0,0 +1,26 @@
package com.correx.infrastructure.tools.task
import com.correx.core.tasks.TaskContextAssembler
import com.correx.core.tasks.TaskDocumentResolver
import com.correx.core.tasks.TaskKnowledgeRetriever
import com.correx.core.tasks.TaskService
import com.correx.core.tools.contract.Tool
/** Builds the task tool set over a single [TaskService] for registration in the tool registry. */
object TaskTools {
/**
* [retriever] semantically enriches the `task_context` bundle; [documentResolver] inlines
* linked ADRs/docs. Both optional — null leaves the bundle deterministic.
*/
fun forService(
service: TaskService,
retriever: TaskKnowledgeRetriever? = null,
documentResolver: TaskDocumentResolver? = null,
): List<Tool> =
listOf(
TaskCreateTool(service),
TaskUpdateTool(service),
TaskDeleteTool(service),
TaskContextTool(TaskContextAssembler(service, retriever, documentResolver)),
)
}
@@ -0,0 +1,158 @@
package com.correx.infrastructure.tools.task
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
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 com.correx.core.tasks.TaskService
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
private val ADR_ID = Regex("(?i)^adr[-_ ]?\\d+$")
/**
* Agent-facing tool: update an existing task — edit fields, transition status, add a work-graph
* link, or attach a note. The task's project is derived from its id, so only the id is required.
*/
class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor {
override val name: String = "task_update"
override val description: String =
"Update a task: edit title/goal/criteria/paths, change status via 'action' " +
"(claim, release, block, unblock, submit_for_review, complete, reopen, cancel), " +
"add a link, or add a note."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") }
putJsonObject("title") { put("type", "string"); put("description", "New title.") }
putJsonObject("goal") { put("type", "string"); put("description", "New goal.") }
putJsonObject("acceptance_criteria") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Replaces the acceptance criteria.")
}
putJsonObject("affected_paths") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Replaces the affected paths.")
}
putJsonObject("action") {
put("type", "string")
put(
"description",
"Status transition: claim, release, block, unblock, submit_for_review, " +
"complete, reopen, cancel.",
)
}
putJsonObject("claimant") { put("type", "string"); put("description", "Who claims it (for action=claim).") }
putJsonObject("reason") { put("type", "string"); put("description", "Reason (for action=block/cancel).") }
putJsonObject("link_target") { put("type", "string"); put("description", "Id to link to (task/artifact/session).") }
putJsonObject("link_type") {
put("type", "string")
put("description", "DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT. Default RELATES_TO.")
}
putJsonObject("link_kind") {
put("type", "string")
put("description", "What the link points at: TASK, DOC, ARTIFACT, SESSION. Omit to infer from the target id.")
}
putJsonObject("note") { put("type", "string"); put("description", "Append an agent note.") }
}
put("required", buildJsonArray { add(JsonPrimitive("id")) })
}
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult {
request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).")
return ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val id = request.stringParam("id")
?: return fail(request, "Missing 'id' (string).")
val taskId = TaskId(id)
if (service.getTask(taskId) == null) return fail(request, "No such task: $id")
applyFieldEdits(request, taskId)
val action = request.stringParam("action")
if (action != null && !applyAction(taskId, action, request)) {
return fail(request, "Unknown action '$action'.")
}
val linkTarget = request.stringParam("link_target")
if (linkTarget != null) {
val type = parseLinkType(request.stringParam("link_type"))
?: return fail(request, "Invalid 'link_type'.")
val kind = resolveTargetKind(request.stringParam("link_kind"), linkTarget)
?: return fail(request, "Invalid 'link_kind'.")
service.link(taskId, linkTarget, type, kind)
}
request.stringParam("note")?.let { service.addNote(taskId, TaskNoteAuthor.AGENT, it) }
val status = service.getTask(taskId)?.state?.status?.name ?: "UNKNOWN"
return ToolResult.Success(
invocationId = request.invocationId,
output = "Updated $id [$status]",
metadata = mapOf("taskId" to id, "status" to status),
)
}
private suspend fun applyFieldEdits(request: ToolRequest, taskId: TaskId) {
val title = request.stringParam("title")
val goal = request.stringParam("goal")
if (title != null || goal != null) service.edit(taskId, title, goal)
if (request.parameters.containsKey("acceptance_criteria")) {
service.setAcceptanceCriteria(taskId, request.listParam("acceptance_criteria"))
}
if (request.parameters.containsKey("affected_paths")) {
service.setAffectedPaths(taskId, request.listParam("affected_paths"))
}
}
private suspend fun applyAction(taskId: TaskId, action: String, request: ToolRequest): Boolean {
val claimant = request.stringParam("claimant") ?: request.sessionId.value
val reason = request.stringParam("reason")
when (action) {
"claim" -> service.claim(taskId, claimant)
"release" -> service.release(taskId)
"block" -> service.block(taskId, reason ?: "blocked")
"unblock" -> service.unblock(taskId)
"submit_for_review" -> service.submitForReview(taskId)
"complete" -> service.complete(taskId)
"reopen" -> service.reopen(taskId)
"cancel" -> service.cancel(taskId, reason)
else -> return false
}
return true
}
private fun parseLinkType(raw: String?): TaskLinkType? =
if (raw == null) TaskLinkType.RELATES_TO
else TaskLinkType.entries.firstOrNull { it.name == raw.uppercase() }
/** Explicit kind when given (null if unrecognised); otherwise inferred from the target id. */
private fun resolveTargetKind(raw: String?, target: String): TaskTargetKind? =
if (raw != null) TaskTargetKind.entries.firstOrNull { it.name == raw.uppercase() }
else inferTargetKind(target)
private fun inferTargetKind(target: String): TaskTargetKind =
if (ADR_ID.matches(target) || target.endsWith(".md")) TaskTargetKind.DOC else TaskTargetKind.TASK
private fun fail(request: ToolRequest, reason: String): ToolResult.Failure =
ToolResult.Failure(request.invocationId, reason, recoverable = false)
}
@@ -0,0 +1,11 @@
package com.correx.infrastructure.tools.task
import com.correx.core.events.events.ToolRequest
/** A non-blank string parameter, or null when absent/blank/not a string. */
internal fun ToolRequest.stringParam(name: String): String? =
(parameters[name] as? String)?.takeIf { it.isNotBlank() }
/** A string-list parameter (JSON array), coerced element-wise; empty when absent. */
internal fun ToolRequest.listParam(name: String): List<String> =
(parameters[name] as? List<*>)?.mapNotNull { it?.toString()?.takeIf(String::isNotBlank) } ?: emptyList()
@@ -0,0 +1,168 @@
package com.correx.infrastructure.tools.task
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.stores.EventStore
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.tasks.TaskContextAssembler
import com.correx.core.tasks.TaskService
import com.correx.core.tasks.TaskStatus
import com.correx.core.tools.contract.ToolResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class TaskToolsTest {
private val service = TaskService(InMemoryEventStore())
private val create = TaskCreateTool(service)
private val update = TaskUpdateTool(service)
private val delete = TaskDeleteTool(service)
private val context = TaskContextTool(TaskContextAssembler(service))
private var counter = 0
private fun request(tool: String, params: Map<String, Any>) = ToolRequest(
invocationId = ToolInvocationId("inv-${counter++}"),
sessionId = SessionId("s-1"),
stageId = StageId("stage-1"),
toolName = tool,
parameters = params,
)
private suspend fun createTask(): String {
val result = create.execute(
request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "stay authed")),
)
assertTrue(result is ToolResult.Success)
return (result as ToolResult.Success).metadata.getValue("taskId")
}
@Test
fun `task_create creates a task and returns its id`() = runBlocking {
val id = createTask()
assertEquals("auth-1", id)
assertEquals(TaskStatus.TODO, service.getTask(com.correx.core.events.types.TaskId(id))?.state?.status)
}
@Test
fun `task_create rejects missing required fields`() = runBlocking {
val result = create.execute(request("task_create", mapOf("project" to "auth")))
assertTrue(result is ToolResult.Failure)
}
@Test
fun `task_update applies status action, edits, link and note`() = runBlocking {
val id = createTask()
val taskId = com.correx.core.events.types.TaskId(id)
val result = update.execute(
request(
"task_update",
mapOf(
"id" to id,
"action" to "claim",
"claimant" to "claude-opus",
"title" to "JWT refresh flow",
"link_target" to "adr-7",
"link_type" to "implements",
"note" to "starting",
),
),
)
assertTrue(result is ToolResult.Success)
val state = service.getTask(taskId)!!.state
assertEquals(TaskStatus.IN_PROGRESS, state.status)
assertEquals("claude-opus", state.claimant)
assertEquals("JWT refresh flow", state.title)
assertEquals("adr-7", state.links.single().targetId)
assertEquals("starting", state.notes.single().body)
}
@Test
fun `task_update rejects an unknown action`() = runBlocking {
val id = createTask()
val result = update.execute(request("task_update", mapOf("id" to id, "action" to "frobnicate")))
assertTrue(result is ToolResult.Failure)
}
@Test
fun `task_update on a missing task fails`() = runBlocking {
val result = update.execute(request("task_update", mapOf("id" to "auth-999", "title" to "x")))
assertTrue(result is ToolResult.Failure)
}
@Test
fun `task_context returns a rendered bundle with dependencies`() = runBlocking {
val id = createTask()
update.execute(
request("task_update", mapOf("id" to id, "link_target" to "adr-7", "link_type" to "implements")),
)
val result = context.execute(request("task_context", mapOf("id" to id)))
assertTrue(result is ToolResult.Success)
val output = (result as ToolResult.Success).output
assertTrue(output.startsWith("task $id [TODO]"))
assertTrue(output.contains("adr-7 (IMPLEMENTS)"))
}
@Test
fun `task_context on a missing task fails`() = runBlocking {
assertTrue(context.execute(request("task_context", mapOf("id" to "auth-999"))) is ToolResult.Failure)
}
@Test
fun `task_delete tombstones the task`() = runBlocking {
val id = createTask()
val deleted = delete.execute(request("task_delete", mapOf("id" to id)))
assertTrue(deleted is ToolResult.Success)
assertNull(service.getTask(com.correx.core.events.types.TaskId(id)))
// Deleting again now fails: it is gone from active views.
assertTrue(delete.execute(request("task_delete", mapOf("id" to id))) is ToolResult.Failure)
}
}
/** Minimal append-capable in-memory store for tool tests. */
private class InMemoryEventStore : EventStore {
private val events = mutableListOf<StoredEvent>()
private var global = 0L
private val perSession = mutableMapOf<SessionId, Long>()
override suspend fun append(event: NewEvent): StoredEvent {
global += 1
val seq = (perSession[event.metadata.sessionId] ?: 0L) + 1
perSession[event.metadata.sessionId] = seq
val stored = StoredEvent(event.metadata, global, seq, event.payload)
events += stored
return stored
}
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = events.map { append(it) }
override fun read(sessionId: SessionId): List<StoredEvent> =
events.filter { it.metadata.sessionId == sessionId }.sortedBy { it.sessionSequence }
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
read(sessionId).filter { it.sessionSequence >= fromSequence }
override fun lastSequence(sessionId: SessionId): Long? = read(sessionId).maxOfOrNull { it.sessionSequence }
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
override suspend fun lastGlobalSequence(): Long = global
override fun allEvents(): Sequence<StoredEvent> = events.asSequence()
override fun allSessionIds(): Set<SessionId> = events.map { it.metadata.sessionId }.toSet()
}