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:
2026-06-26 11:30:41 +00:00
259 changed files with 17681 additions and 674 deletions
+1
View File
@@ -20,6 +20,7 @@ dependencies {
implementation project(':core:events')
implementation project(':core:approvals')
implementation project(':core:sessions')
implementation project(':core:tasks')
implementation project(':core:kernel')
implementation project(':core:journal')
implementation project(':core:inference')
@@ -3,6 +3,7 @@ package com.correx.apps.server
import com.correx.apps.server.health.HealthInspectionService
import com.correx.apps.server.routes.providerRoutes
import com.correx.apps.server.routes.sessionRoutes
import com.correx.apps.server.routes.taskRoutes
import com.correx.apps.server.routes.workflowRoutes
import com.correx.apps.server.ws.GlobalStreamHandler
import io.ktor.http.HttpStatusCode
@@ -57,5 +58,6 @@ fun Application.configureServer(module: ServerModule) {
sessionRoutes(module)
workflowRoutes(module)
providerRoutes(module)
taskRoutes(module)
}
}
@@ -48,6 +48,7 @@ import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.tasks.TaskService
import com.correx.core.tools.registry.ToolRegistry
import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.validation.artifact.ArtifactPayloadValidator
@@ -61,6 +62,10 @@ import com.correx.core.toolintent.rules.ExecInterpreterRule
import com.correx.core.toolintent.rules.ManifestContainmentRule
import com.correx.core.toolintent.rules.NetworkHostRule
import com.correx.core.toolintent.rules.PathContainmentRule
import com.correx.core.toolintent.rules.ReadBeforeWriteRule
import com.correx.core.toolintent.rules.ReferenceExistsRule
import com.correx.core.toolintent.rules.StaleWriteRule
import com.correx.core.toolintent.rules.WriteScopeRule
import com.correx.apps.server.freestyle.FreestyleDriver
import com.correx.apps.server.inference.summarizeWithInference
import com.correx.core.kernel.orchestration.JournalCompactionService
@@ -81,6 +86,7 @@ import com.correx.infrastructure.inference.commons.UnavailableProbe
import com.correx.core.inference.InferenceProvider
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
import com.correx.infrastructure.tools.DispatchingToolExecutor
import com.correx.infrastructure.tools.task.TaskTools
import com.correx.infrastructure.tools.FileEditConfig
import com.correx.infrastructure.tools.FileReadConfig
import com.correx.infrastructure.tools.FileWriteConfig
@@ -215,6 +221,32 @@ fun main() {
null
},
)
// Agents create/update/delete tasks through the tool system (tier-gated like any tool);
// the service appends to the same event log. Project is derived from the task id prefix.
// The knowledge retriever is late-bound: the L3 retriever isn't built until later in this
// composition root, so the holder is captured now and its delegate set once L3 exists (below).
val taskKnowledgeRetriever = com.correx.apps.server.memory.DeferredTaskKnowledgeRetriever()
// Inlines linked ADRs/docs into the context bundle. The repo root is known here, so unlike the
// L3 retriever this needs no late binding.
val taskDocumentResolver = com.correx.apps.server.tasks.FileTaskDocumentResolver(workspaceRoot)
// Inline ARTIFACT/SESSION link targets too: both read the event log (+ CAS) that already exists
// here, so — like the doc resolver — they need no late binding.
val taskArtifactResolver =
com.correx.apps.server.tasks.EventStoreTaskArtifactResolver(eventStore, artifactStore)
val taskSessionResolver =
com.correx.apps.server.tasks.SessionSummaryTaskSessionResolver(eventStore)
// Backs POST /tasks/sync-git: reads recent commits so a git hook / CI step can drive task status.
val gitCommitReader = com.correx.apps.server.tasks.GitCommandCommitReader(workspaceRoot)
val taskService = TaskService(eventStore)
val taskTools = TaskTools.forService(
taskService,
taskKnowledgeRetriever,
taskDocumentResolver,
taskArtifactResolver,
taskSessionResolver,
com.correx.apps.server.tasks.EventStoreSessionFactRecorder(eventStore),
com.correx.apps.server.tasks.EventStoreSessionWrites(eventStore),
)
val toolRegistry = InfrastructureModule.createToolRegistry(
buildToolConfig(
workspaceRoot,
@@ -223,6 +255,7 @@ fun main() {
toolsConfig,
researchToolConfig,
),
extraTools = taskTools,
)
val toolExecutor = InfrastructureModule.createToolExecutor(
registry = toolRegistry,
@@ -238,6 +271,10 @@ fun main() {
val toolCallAssessor = ToolCallAssessor(
rules = listOf(
PathContainmentRule(),
ReadBeforeWriteRule(),
ReferenceExistsRule(),
WriteScopeRule(),
StaleWriteRule(),
ManifestContainmentRule(),
ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()),
NetworkHostRule(
@@ -250,6 +287,7 @@ fun main() {
val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace ->
val wsRegistry = InfrastructureModule.createToolRegistry(
buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig),
extraTools = taskTools,
)
val wsExecutor = DispatchingToolExecutor(wsRegistry)
WorkspaceTools(registry = wsRegistry, executor = wsExecutor)
@@ -316,6 +354,10 @@ fun main() {
} else {
null
}
// L3 retriever now exists — bind it into the task context bundle's knowledge port so the
// task_context tool and GET /tasks/{id}/context return semantically-relevant snippets.
taskKnowledgeRetriever.delegate =
repoKnowledgeRetriever?.let { com.correx.apps.server.memory.RepoKnowledgeTaskRetriever(it) }
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
@@ -388,6 +430,16 @@ fun main() {
privilegedLocations = privilegedLocations,
allowedWorkspaceRoots = allowedWorkspaceRoots,
)
// Display-only architect contradiction surfacing (BACKLOG §B-§4): now live via the
// ServerModule.start() subscription on the architect's `design` ArtifactCreatedEvent (see
// ServerModule.handleArchitectArtifact). Gated on project.enabled because the checker queries the
// same L3 "project:<repoRoot>" namespace that ProjectMemoryService.persist populates.
val architectContradictionChecker: com.correx.apps.server.memory.ArchitectContradictionChecker? =
if (correxConfig.project.enabled) {
com.correx.apps.server.memory.ArchitectContradictionChecker(embedder, l3MemoryStore)
} else {
null
}
// Built from a config snapshot and reused by ConfigService's rebuild hook so toggling
// project.enabled / personalization.* applies live to the next session.
fun buildProjectMemory(cfg: CorrexConfig): com.correx.apps.server.memory.ProjectMemoryService? =
@@ -420,7 +472,7 @@ fun main() {
val freestyleDriver = FreestyleDriver(
eventStore = eventStore,
compiler = ExecutionPlanCompiler(artifactKindRegistry),
compiler = ExecutionPlanCompiler(artifactKindRegistry, toolRegistry.all().map { it.name }.toSet()),
planContent = { sid -> orchestrator.validatedArtifactContent(sid, ArtifactId("execution_plan")) },
config = defaultOrchestrationConfig,
runPhase2 = { sid, graph, cfg -> orchestrator.run(sid, graph, cfg) },
@@ -438,7 +490,7 @@ fun main() {
.subjects.mapValues { it.value.status }
com.correx.apps.server.health.HealthMonitor(
eventStore = eventStore,
probes = listOf(
probes = listOfNotNull(
com.correx.apps.server.health.DiskWatermarkProbe(
monitoredPaths = listOf(dataDir),
warnBytes = hc.diskWarnBytes,
@@ -479,11 +531,17 @@ fun main() {
workspaceResolver = workspaceResolver,
narrationMaxPerRun = correxConfig.router.narration.maxPerRun,
projectMemory = projectMemory,
architectContradictionChecker = architectContradictionChecker,
configHolder = configHolder,
freestyleDriver = freestyleDriver,
operatorProfile = operatorProfile,
profileAdaptationService = profileAdaptationService,
healthMonitor = healthMonitor,
taskKnowledgeRetriever = taskKnowledgeRetriever,
taskDocumentResolver = taskDocumentResolver,
taskArtifactResolver = taskArtifactResolver,
taskSessionResolver = taskSessionResolver,
gitCommitReader = gitCommitReader,
)
// Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived
// services. Built after the module so the rebuild hook can swap them in place.
@@ -11,13 +11,19 @@ import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.config.AgentInstructionsLoader
import com.correx.core.config.OperatorProfile
import com.correx.core.config.ProjectProfileLoader
import com.correx.apps.server.memory.ArchitectContradictionChecker
import com.correx.core.events.events.AgentInstructionsBoundEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.OperatorProfileBoundEvent
import com.correx.core.events.events.PossibleContradictionFlaggedEvent
import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
@@ -26,6 +32,9 @@ import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
@@ -87,6 +96,10 @@ class ServerModule(
// Cross-session, repo-scoped memory. Null disables the hooks entirely (tests / static path).
// Rebuilt by ConfigService when project.enabled is toggled live.
projectMemory: com.correx.apps.server.memory.ProjectMemoryService? = null,
// Display-only architect contradiction surfacing (BACKLOG §B-§4). Null disables the hook
// (tests / project.enabled=false). Unlike projectMemory there is no live config-rebuild hook:
// a server restart re-reads project.enabled, which is enough for this informational flag.
private val architectContradictionChecker: ArchitectContradictionChecker? = null,
// Live, swappable config. Null only in tests that don't exercise config editing; defaults to a
// holder seeded from defaults so callers always have a value to read.
val configHolder: com.correx.core.config.ConfigHolder =
@@ -102,6 +115,17 @@ class ServerModule(
// Continuous health watch (observability-spec §4). Null disables it (tests / health.enabled=false);
// records degraded/restored edge events on the system session, started in [start].
private val healthMonitor: com.correx.apps.server.health.HealthMonitor? = null,
// Semantic enrichment for the task context bundle (GET /tasks/{id}/context). Null disables it.
val taskKnowledgeRetriever: com.correx.core.tasks.TaskKnowledgeRetriever? = null,
// Resolves linked ADRs/docs inline in the task context bundle. Null leaves them as raw links.
val taskDocumentResolver: com.correx.core.tasks.TaskDocumentResolver? = null,
// Resolves linked artifacts inline in the task context bundle. Null leaves them as raw links.
val taskArtifactResolver: com.correx.core.tasks.TaskArtifactResolver? = null,
// Resolves linked agent sessions inline in the task context bundle. Null leaves them as raw links.
val taskSessionResolver: com.correx.core.tasks.TaskSessionResolver? = null,
// Reads recent commits for POST /tasks/sync-git (git-driven status). Null disables the repo read
// (the endpoint then only acts on commits supplied in the request body).
val gitCommitReader: com.correx.core.tasks.GitCommitReader? = null,
) {
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
orchestrator = orchestrator,
@@ -187,6 +211,80 @@ class ServerModule(
// Continuous, edge-triggered health watch (observability-spec §4). Live-only like narration:
// probes read the environment and record degraded/restored events; replay reads those facts.
healthMonitor?.start(moduleScope)
// Display-only architect contradiction surfacing (BACKLOG §B-§4). Live-only like the hooks
// above: subscribeAll() replays nothing and ServerModule is never built under replay, so a
// restart never re-fires the flag (invariant #8). Never halts a stage — failures are logged
// and swallowed (runCatching) and the flag is emitted purely for the operator to eyeball.
architectContradictionChecker?.let { checker ->
eventStore.subscribeAll()
.filter {
it.payload is ArtifactCreatedEvent &&
(it.payload as ArtifactCreatedEvent).stageId.value == ARCHITECT_STAGE_ID
}
.onEach { stored ->
runCatching {
handleArchitectArtifact(checker, stored.payload as ArtifactCreatedEvent)
}.onFailure { log.warn("architect contradiction check failed: {}", it.message) }
}
.launchIn(moduleScope)
}
}
/**
* Runs the architect contradiction check for a freshly-created `design` artifact and, when the
* checker surfaces a non-null flag, appends the [PossibleContradictionFlaggedEvent]. Display-only:
* the flag is informational and never halts the stage. Factored out of the subscription so the
* extraction/dispatch path is unit-testable without a live event flow.
*
* @return the appended flag, or null when the artifact text could not be resolved or the checker
* found nothing near the architect's decision.
*/
internal suspend fun handleArchitectArtifact(
checker: ArchitectContradictionChecker,
event: ArtifactCreatedEvent,
): PossibleContradictionFlaggedEvent? {
val decisionText = resolveArchitectDecisionText(event) ?: return null
val flag = checker.check(event.sessionId, event.stageId, decisionText) ?: return null
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(java.util.UUID.randomUUID().toString()),
sessionId = event.sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = flag,
),
)
return flag
}
/**
* Resolves the architect's decision text for [event] from the CAS-stored `design` artifact.
* Finds the content hash by scanning the session for the [ArtifactContentStoredEvent] matching
* the artifact id (emitted at inference time, before [ArtifactCreatedEvent]), fetches the bytes,
* and prefers the design schema's `approach` field ("the chosen approach and why") as the
* decision summary; falls back to the whole artifact text, capped at [DECISION_TEXT_CAP] chars.
*
* @return the decision text, or null when no content hash, bytes, or text could be resolved.
*/
private suspend fun resolveArchitectDecisionText(event: ArtifactCreatedEvent): String? {
val contentHash = eventStore.read(event.sessionId)
.asSequence()
.map { it.payload }
.filterIsInstance<ArtifactContentStoredEvent>()
.firstOrNull { it.artifactId == event.artifactId }
?.contentHash ?: return null
val text = artifactStore.get(contentHash)?.toString(Charsets.UTF_8)?.takeIf { it.isNotBlank() }
?: return null
val approach = runCatching {
(lenientJson.parseToJsonElement(text).jsonObject["approach"] as? JsonPrimitive)
?.takeIf { it.isString }?.content
}.getOrNull()?.takeIf { it.isNotBlank() }
return (approach ?: text).take(DECISION_TEXT_CAP)
}
/**
@@ -237,6 +335,7 @@ class ServerModule(
)
}
bindProjectProfile(sessionId)
bindAgentInstructions(sessionId)
runCatching {
val result = orchestrator.run(sessionId, graph, sessionConfig)
freestyleHandoff(sessionId, graph, result)
@@ -320,6 +419,37 @@ class ServerModule(
)
}
/**
* Bind standing agent instructions (CLAUDE.md / AGENTS.md at the workspace root) as an
* event so stages, router chat triage, and replay read the recorded snapshot, never the
* live file (invariants #8/#9). Mirrors [bindProjectProfile].
*/
suspend fun bindAgentInstructions(sessionId: SessionId) {
val workspaceRoot = runCatching {
sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot
}.getOrNull() ?: projectMemory?.repoRoot() ?: return
val instructions = withContext(Dispatchers.IO) { AgentInstructionsLoader.load(workspaceRoot) }
if (instructions.isEmpty()) return
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(java.util.UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = AgentInstructionsBoundEvent(
sessionId = sessionId,
workspaceRoot = workspaceRoot,
sources = instructions.sources,
content = instructions.content,
),
),
)
}
/**
* Phase-2 handoff for freestyle sessions, shared by every launcher that can finish a
* planning graph (fresh run and all resume paths). Locks the plan and executes it,
@@ -438,4 +568,14 @@ class ServerModule(
.forEach { req -> approvalCoordinator.registerPendingRequest(req.id, sessionId) }
}
}
companion object {
/** Workflow stage id that produces the `design` artifact (examples/workflows/role_pipeline.toml). */
const val ARCHITECT_STAGE_ID = "architect"
/** Cap on the architect decision text embedded when no `approach` field is present. */
private const val DECISION_TEXT_CAP = 4000
private val lenientJson = Json { ignoreUnknownKeys = true; isLenient = true }
}
}
@@ -0,0 +1,70 @@
package com.correx.apps.server.memory
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 com.correx.core.inference.Embedder
import com.correx.core.router.l3.L3MemoryStore
import com.correx.core.router.l3.L3Query
/**
* Display-only architect contradiction surfacing (BACKLOG §B-§4). When the architect records a
* decision, this embeds the decision text, retrieves semantically-near PRIOR decisions/ADRs from
* L3, and — if any clear the similarity threshold — returns a [PossibleContradictionFlaggedEvent]
* for the operator to eyeball. v1 is purely informational: there is no LLM judge and the caller
* emits the flag without ever halting or failing the stage.
*
* Namespace convention: distilled decision-journal lines are persisted into L3 by
* [ProjectMemoryService] under `turnId = "project:<repoRoot>"` (trailing-`:` delimiter). This is
* the only decision-bearing L3 namespace that exists today, so [decisionNamespacePrefix] defaults
* to `"project:"` — a `startsWith` prefix, matching the trailing-`:` delimiter convention used by
* [L3RepoKnowledgeRetriever]'s `"repomap:<repoRoot>:"` filter. Hits are also constrained to PRIOR
* sessions (`entry.sessionId != sessionId`) so the architect never flags its own in-flight run.
*/
class ArchitectContradictionChecker(
private val embedder: Embedder,
private val l3MemoryStore: L3MemoryStore,
private val k: Int = DEFAULT_K,
private val scoreThreshold: Double = DEFAULT_SCORE_THRESHOLD,
private val decisionNamespacePrefix: String = DEFAULT_DECISION_NAMESPACE_PREFIX,
) {
/**
* @return a [PossibleContradictionFlaggedEvent] listing related prior decisions, or null when
* none clear the threshold. The CALLER emits it (display-only) — this never halts.
*/
suspend fun check(
sessionId: SessionId,
stageId: StageId,
decisionText: String,
): PossibleContradictionFlaggedEvent? {
if (decisionText.isBlank()) return null
val vector = embedder.embed(decisionText)
val related = l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR))
.filter { it.entry.turnId.startsWith(decisionNamespacePrefix) }
.filter { it.entry.sessionId != sessionId }
.filter { it.score >= scoreThreshold }
.take(k)
.map {
RelatedDecision(
summary = it.entry.text,
score = it.score.toDouble(),
source = it.entry.turnId,
)
}
if (related.isEmpty()) return null
return PossibleContradictionFlaggedEvent(
sessionId = sessionId,
stageId = stageId,
decisionSummary = decisionText,
related = related,
)
}
companion object {
const val DEFAULT_K = 5
const val DEFAULT_SCORE_THRESHOLD = 0.75
const val DEFAULT_DECISION_NAMESPACE_PREFIX = "project:"
private const val RETRIEVAL_OVERSAMPLE_FACTOR = 4
}
}
@@ -0,0 +1,18 @@
package com.correx.apps.server.memory
import com.correx.core.tasks.KnowledgeHit
import com.correx.core.tasks.TaskKnowledgeRetriever
/**
* Late-bound holder for the task knowledge retriever. The task tools are built at tool-registry
* construction time, which is before the L3 retriever exists in the composition root; this holder
* is created early, captured by the tools and the route, and has its [delegate] set once the L3
* retriever is available. Until then (or when L3 is disabled) it returns no hits.
*/
class DeferredTaskKnowledgeRetriever : TaskKnowledgeRetriever {
@Volatile
var delegate: TaskKnowledgeRetriever? = null
override suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit> =
delegate?.retrieve(query, limit) ?: emptyList()
}
@@ -0,0 +1,19 @@
package com.correx.apps.server.memory
import com.correx.apps.server.health.SYSTEM_SESSION
import com.correx.core.kernel.orchestration.RepoKnowledgeRetriever
import com.correx.core.tasks.KnowledgeHit
import com.correx.core.tasks.TaskKnowledgeRetriever
/**
* Bridges the task context bundle's [TaskKnowledgeRetriever] port to the existing L3 vector
* retriever ([RepoKnowledgeRetriever], typically [L3RepoKnowledgeRetriever]). Tasks are not
* session-scoped for retrieval, so the system session is used (the L3 repo retriever ignores it).
*/
class RepoKnowledgeTaskRetriever(
private val delegate: RepoKnowledgeRetriever,
) : TaskKnowledgeRetriever {
override suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit> =
delegate.retrieve(SYSTEM_SESSION, query, limit)
.map { KnowledgeHit(source = it.path, text = it.text, score = it.score.toDouble()) }
}
@@ -84,6 +84,12 @@ class RepoMapIndexer(
"ts" to Regex("""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum)\s+(?<name>[A-Za-z_$][A-Za-z0-9_$]*)"""),
// GDScript: column-0 declarations only, so indented inner-class members never match.
"gd" to Regex("""(?m)^(?:static\s+)?(?:func|class_name|class|signal|enum)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
// C# / Godot Mono: top-level type declarations with leading modifiers, kept conservative
// (no method capture — return-type heuristics there are noisy and risk false positives).
"cs" to Regex(
"""(?m)^\s*(?:public |private |protected |internal |static |sealed |abstract |""" +
"""partial )*(?:class|interface|struct|enum|record)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)""",
),
// Markdown: h1h3 headings as "symbols" — deeper levels are noise relative to retrieval value.
"md" to Regex("""(?m)^#{1,3}\s+(?<name>.+?)\s*$"""),
)
@@ -109,7 +109,7 @@ class RealWorkspaceStateProbe : WorkspaceStateProbe {
.toList()
}
val digest = MessageDigest.getInstance("SHA-256")
lines.forEach { digest.update(it.toByteArray()) }
digest.update(lines.joinToString("\n").toByteArray())
return digest.digest().joinToString("") { "%02x".format(it) }.take(FINGERPRINT_HEX_LENGTH)
}
}
@@ -1,8 +1,10 @@
package com.correx.apps.server.narration
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.StoredEvent
@@ -20,6 +22,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
class NarrationSubscriber(
@@ -28,7 +31,27 @@ class NarrationSubscriber(
private val scope: CoroutineScope,
private val maxPerRun: Int = DEFAULT_MAX_PER_RUN,
) {
private data class SessionLane(val channel: Channel<NarrationTrigger>, var used: Int)
/**
* A queued narration plus the pause-trigger bookkeeping needed to drop it if the
* pause it described resolves before the lane worker delivers it.
*
* [pauseKey] is non-null only for pause-trigger narrations. It is the resolving signal's
* scope: the originating approval request id when known (per-request supersession), or a
* session-wide sentinel for non-approval pauses (per-session supersession).
*/
private data class QueuedNarration(val trigger: NarrationTrigger, val pauseKey: String?)
/**
* Per-session lane. [pendingPauses] holds the pause keys still queued (not yet delivered
* by the worker). When an approval resolves or the orchestration resumes, the matching
* keys are removed so the worker skips the now-stale pause narration instead of blending
* it with the current status.
*/
private data class SessionLane(
val channel: Channel<QueuedNarration>,
var used: Int,
val pendingPauses: MutableSet<String> = Collections.newSetFromMap(ConcurrentHashMap()),
)
private val lanes = ConcurrentHashMap<String, SessionLane>()
@@ -50,6 +73,7 @@ class NarrationSubscriber(
instruction = approvalInstruction(p),
stageId = p.stageId?.value,
),
pauseKey = pauseKeyForRequest(p.requestId.value),
)
is StageCompletedEvent -> enqueue(
sid,
@@ -100,9 +124,16 @@ class NarrationSubscriber(
instruction = "Orchestration paused in stage ${p.stageId.value}: ${p.reason}. Inform the user.",
stageId = p.stageId.value,
),
pauseKey = SESSION_PAUSE_KEY,
)
}
}
// An approval resolving makes its queued pause narration stale: drop it (scoped to
// that request) so a resolved pause never narrates as if still pending.
is ApprovalDecisionResolvedEvent -> dropPendingPause(sid, pauseKeyForRequest(p.requestId.value))
// Resuming supersedes every pause still queued for the session (approval-pending or
// not): the lane is live again, so any pending "paused" narration is stale.
is OrchestrationResumedEvent -> dropAllPendingPauses(sid)
else -> Unit
}
}
@@ -122,20 +153,46 @@ class NarrationSubscriber(
}
}
private fun enqueue(sessionId: SessionId, trigger: NarrationTrigger) {
private fun enqueue(sessionId: SessionId, trigger: NarrationTrigger, pauseKey: String? = null) {
val lane = lanes.computeIfAbsent(sessionId.value) { startLane(sessionId) }
if (lane.used >= maxPerRun) {
log.debug("narration budget reached for session {}; skipping {}", sessionId.value, trigger.kind)
return
}
lane.used += 1
lane.channel.trySend(trigger)
if (pauseKey != null) {
lane.pendingPauses.add(pauseKey)
}
lane.channel.trySend(QueuedNarration(trigger, pauseKey))
}
/** Marks the pause for [pauseKey] resolved so the lane worker skips it if still queued. */
private fun dropPendingPause(sessionId: SessionId, pauseKey: String) {
lanes[sessionId.value]?.pendingPauses?.remove(pauseKey)
}
/** Marks every queued pause for the session resolved (used on resume). */
private fun dropAllPendingPauses(sessionId: SessionId) {
lanes[sessionId.value]?.pendingPauses?.clear()
}
private fun startLane(sessionId: SessionId): SessionLane {
val channel = Channel<NarrationTrigger>(capacity = Channel.UNLIMITED)
val channel = Channel<QueuedNarration>(capacity = Channel.UNLIMITED)
val lane = SessionLane(channel, used = 0)
scope.launch {
for (trigger in channel) {
for (queued in channel) {
// A pause whose key was dropped (approval resolved / resumed) is stale: skip it
// rather than narrate a paused state that no longer holds. removeIf-style check:
// claim the key so a re-enqueued pause with the same id is not also dropped.
if (queued.pauseKey != null && !lane.pendingPauses.remove(queued.pauseKey)) {
log.debug(
"skipping stale pause narration for session {} (key {})",
sessionId.value,
queued.pauseKey,
)
continue
}
val trigger = queued.trigger
runCatching { routerFacade.narrate(sessionId, trigger) }
.onFailure { e ->
if (e is CancellationException) throw e
@@ -148,12 +205,17 @@ class NarrationSubscriber(
}
}
}
return SessionLane(channel, used = 0)
return lane
}
companion object {
private const val DEFAULT_MAX_PER_RUN = 100
private const val MAX_PREVIEW_CHARS = 800
/** Supersession scope for pauses with no approval request (e.g. USER_REQUESTED). */
private const val SESSION_PAUSE_KEY = "__session_pause__"
private val log = LoggerFactory.getLogger(NarrationSubscriber::class.java)
private fun pauseKeyForRequest(requestId: String): String = "req:$requestId"
}
}
@@ -5,6 +5,7 @@ import com.correx.core.approvals.Tier
import com.correx.core.events.events.ClarificationAnswer
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.ClarificationRequestId
import com.correx.core.events.types.GrantId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.router.ChatMode
@@ -21,6 +22,11 @@ enum class ApprovalDecision {
enum class GrantScopeDto {
SESSION,
STAGE,
// Cross-session scopes (BACKLOG §grants). PROJECT auto-approves the bound tool for any session
// on the same workspace; GLOBAL for every session on this machine. Both are tool-bound and live
// in the cross-session grant ledger rather than a single session's stream.
PROJECT,
GLOBAL,
}
@Serializable
@@ -44,6 +50,10 @@ sealed class ClientMessage {
@Serializable
data class ListArtifacts(val sessionId: SessionId) : ClientMessage()
/** Operator request for the session workspace's file paths (the TUI `@` file-ref picker). */
@Serializable
data class ListFiles(val sessionId: SessionId) : ClientMessage()
/** Operator request for a session's derived metrics (replied to with a SessionStats). */
@Serializable
data class GetSessionStats(val sessionId: SessionId) : ClientMessage()
@@ -60,6 +70,14 @@ sealed class ClientMessage {
@Serializable
data class DiscardIdea(val ideaId: String) : ClientMessage()
/**
* Operator request to promote an idea into the bound session's curated project profile (added as
* a `conventions` entry in `<workspaceRoot>/.correx/project.toml`). Tombstones the idea like a
* discard; reply is a fresh IdeaList.
*/
@Serializable
data class PromoteIdea(val ideaId: String) : ClientMessage()
/** Operator request for the current editable config (replied to with a ConfigSnapshot). */
@Serializable
data object GetConfig : ClientMessage()
@@ -92,10 +110,20 @@ sealed class ClientMessage {
val permittedTiers: List<Tier>,
val reason: String,
val expiresAt: Instant? = null,
// Required for SESSION scope: binds this grant to a specific tool name.
// Required for SESSION/PROJECT/GLOBAL scope: binds this grant to a specific tool name.
// The projectId for a PROJECT grant is derived server-side from the session's bound
// workspace, never trusted from the client.
val toolName: String? = null,
) : ClientMessage()
/** Operator request to revoke a standing (PROJECT/GLOBAL) grant by id; reply is a fresh GrantList. */
@Serializable
data class RevokeGrant(val grantId: GrantId) : ClientMessage()
/** Operator request for the active cross-session grants (replied to with a GrantList). */
@Serializable
data object ListGrants : ClientMessage()
@Serializable
data class Ping(val timestamp: Long) : ClientMessage()
@@ -121,6 +121,23 @@ data class IdeaDto(
val capturedAtMs: Long,
)
/**
* One standing cross-session grant for the TUI grants viewer. [scope] is "PROJECT" or "GLOBAL";
* [tiers] are the tier names this grant auto-approves; [projectId] is the workspace path a PROJECT
* grant is bound to (null for GLOBAL); [expiresAtMs] is the epoch-ms expiry, or null if it never
* expires.
*/
@Serializable
data class GrantDto(
val grantId: String,
val scope: String,
val toolName: String?,
val projectId: String?,
val tiers: List<String>,
val reason: String,
val expiresAtMs: Long?,
)
internal fun RiskSummary.toDto(): RiskSummaryDto = RiskSummaryDto(
level = level.name,
factors = signals.map { signal ->
@@ -465,6 +465,19 @@ sealed interface ServerMessage {
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
/**
* The session workspace's file paths (reply to ListFiles), for the TUI `@` file-ref picker.
* A snapshot directory walk, not event-derived, so cursors are null.
*/
@Serializable
@SerialName("file.list")
data class FileList(
val sessionId: SessionId,
val paths: List<String>,
override val sequence: Long? = null,
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
/**
* The cross-session idea board (reply to ListIdeas / DiscardIdea). Not event-derived (a
* snapshot read folded from the whole event log), so cursors are null.
@@ -477,6 +490,18 @@ sealed interface ServerMessage {
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
/**
* The active cross-session (PROJECT/GLOBAL) grants (reply to ListGrants / CreateGrant /
* RevokeGrant). Not event-derived (a snapshot folded from the grant ledger), so cursors are null.
*/
@Serializable
@SerialName("grant.list")
data class GrantList(
val grants: List<GrantDto>,
override val sequence: Long? = null,
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
/**
* A session's derived metrics, returned in response to a GetSessionStats request. Not
* event-derived (it is a projection-replay snapshot), so cursors are null. [stats] is the same
@@ -0,0 +1,37 @@
package com.correx.apps.server.research
import com.correx.core.events.EventDispatcher
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.types.SessionId
/** Operator context recorded on the grant emitted by [approveSourceList]. */
internal const val BATCH_SOURCE_LIST_REASON = "batch source-list approval"
/**
* Batch fetch-approval at the source-list level (BACKLOG §D): the operator approves a whole research
* source list at once instead of clearing each `web_fetch` per-URL. Extracts the distinct hosts from
* [urls] (a source list's URLs, or its source_dossier source lines) and emits a single
* [EgressHostsGrantedEvent] for [sessionId]. That grant folds into the live per-session egress
* allowlist (commit ab7e4be) via [com.correx.core.sessions.projections.EgressAllowlistProjection], so
* subsequent fetches to those hosts auto-clear and no longer prompt.
*
* No-op when [urls] yields no parseable host: nothing to grant, so no event is emitted. Returns the
* granted host set (empty when nothing was emitted) for the caller to report back.
*/
suspend fun approveSourceList(
dispatcher: EventDispatcher,
sessionId: SessionId,
urls: List<String>,
): Set<String> {
val hosts = SourceListHosts.extract(urls)
if (hosts.isEmpty()) return emptySet()
dispatcher.emit(
payload = EgressHostsGrantedEvent(
sessionId = sessionId,
hosts = hosts,
reason = BATCH_SOURCE_LIST_REASON,
),
sessionId = sessionId,
)
return hosts
}
@@ -0,0 +1,34 @@
package com.correx.apps.server.research
import java.net.URI
/**
* Pure host extraction for batch source-list approval (BACKLOG §D). Given the URLs (or dossier
* source lines) of a research source list, returns the distinct, lower-cased hostnames the operator
* is approving egress to — the input set for a single [com.correx.core.events.events.EgressHostsGrantedEvent].
*
* Mirrors [com.correx.core.toolintent.rules.NetworkHostRule]'s URL parsing so the granted hosts line
* up with what the network gate later matches: absolute URLs only (`scheme://host`), parsed via
* [java.net.URI], host lower-cased, port dropped (URI.host excludes it). Entries that don't parse to
* a host — relative paths, free text, malformed URLs — are ignored rather than failing the batch.
*
* source_dossier entries start with the URL but may carry a trailing note
* ("https://example.com/x — explains Y"); the leading whitespace-delimited token is taken so those
* lines still yield their host.
*/
object SourceListHosts {
/**
* The distinct lower-cased hostnames drawn from [urls]. Deterministic; unparseable/relative
* entries are skipped. Duplicate hosts (and differing-case or differing-port variants of the
* same host) collapse to one.
*/
fun extract(urls: List<String>): Set<String> =
urls.mapNotNullTo(LinkedHashSet()) { hostOf(it) }
private fun hostOf(raw: String): String? {
val token = raw.trim().substringBefore(' ').substringBefore('\t')
if (!token.contains("://")) return null
return runCatching { URI(token).host?.lowercase() }.getOrNull()?.takeIf { it.isNotEmpty() }
}
}
@@ -2,6 +2,8 @@ package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule
import com.correx.apps.server.metrics.MetricsInspectionService
import com.correx.apps.server.research.approveSourceList
import com.correx.core.events.EventDispatcher
import com.correx.apps.server.protocol.SessionConfigDto
import com.correx.apps.server.replay.ReplayInspectionService
import com.correx.apps.server.serialization.payloadDiscriminator
@@ -56,6 +58,12 @@ data class SessionStateResponse(val sessionId: String, val status: String, val c
@Serializable
data class StartSessionResponse(val sessionId: String)
@Serializable
data class ApproveSourcesRequest(val urls: List<String>)
@Serializable
data class ApproveSourcesResponse(val grantedHosts: List<String>)
private val log = LoggerFactory.getLogger("com.correx.apps.server.routes.SessionRoutes")
fun Route.sessionRoutes(module: ServerModule) {
@@ -79,6 +87,7 @@ fun Route.sessionRoutes(module: ServerModule) {
route("/{id}") {
getSessionRoute(module)
cancelSessionRoute(module)
approveSourcesRoute(module)
undoSessionRoute(module)
resumeSessionRoute(module)
getEventsRoute(module)
@@ -129,6 +138,24 @@ private fun Route.cancelSessionRoute(module: ServerModule) {
}
}
// Batch fetch-approval at the source-list level (BACKLOG §D): the operator approves a research
// source list once and egress is granted for all its hosts in one EgressHostsGrantedEvent, so the
// session's subsequent web_fetches to those hosts auto-clear instead of prompting per URL. Body
// carries the source list's URLs (or its source_dossier source lines).
private fun Route.approveSourcesRoute(module: ServerModule) {
post("/approve-sources") {
val id = call.parameters["id"]
?: return@post call.respond(HttpStatusCode.BadRequest, "Missing session id")
val body = call.receive<ApproveSourcesRequest>()
val granted = approveSourceList(
dispatcher = EventDispatcher(module.eventStore),
sessionId = TypeId(id),
urls = body.urls,
)
call.respond(HttpStatusCode.OK, ApproveSourcesResponse(grantedHosts = granted.sorted()))
}
}
private fun Route.undoSessionRoute(module: ServerModule) {
post("/undo") {
val id = call.parameters["id"]
@@ -0,0 +1,343 @@
package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule
import com.correx.core.events.types.ProjectId
import com.correx.core.tasks.Commit
import com.correx.core.tasks.GitCommitReader
import com.correx.core.tasks.GitTaskSync
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.Task
import com.correx.core.tasks.TaskContextAssembler
import com.correx.core.tasks.TaskGraph
import com.correx.core.tasks.TaskHistory
import com.correx.core.tasks.TaskMarkdown
import com.correx.core.tasks.TaskSearch
import com.correx.core.tasks.TaskService
import com.correx.core.tasks.TaskStatus
import com.correx.core.tasks.TaskTargetKinds
import com.correx.core.utils.TypeId
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.RoutingContext
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.patch
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
@Serializable
data class TaskLinkDto(val targetId: String, val type: String, val targetKind: String)
@Serializable
data class TaskNoteDto(val author: String, val body: String, val at: String)
@Serializable
data class TaskResponse(
val id: String,
val key: String?,
val status: String,
val title: String?,
val goal: String?,
val acceptanceCriteria: List<String>,
val affectedPaths: List<String>,
val claimant: String?,
val links: List<TaskLinkDto>,
val notes: List<TaskNoteDto>,
val createdAt: String?,
val updatedAt: String?,
// Dependency-graph status for the board: ready = TODO with every dependency satisfied (workable
// now); blockedBy = the ids of unfinished tasks this one waits on (its unmet DEPENDS_ON/BLOCKS).
// Default to "not blocked / not ready" for the endpoints that don't resolve the graph.
val ready: Boolean = false,
val blockedBy: List<String> = emptyList(),
)
@Serializable
data class CreateTaskRequest(
val project: String,
val title: String,
val goal: String,
val acceptanceCriteria: List<String> = emptyList(),
val affectedPaths: List<String> = emptyList(),
// Skip the duplicate-title guard and create anyway.
val force: Boolean = false,
)
// Null fields are left untouched; a present list (even empty) replaces the stored one.
@Serializable
data class EditTaskRequest(
val title: String? = null,
val goal: String? = null,
val acceptanceCriteria: List<String>? = null,
val affectedPaths: List<String>? = null,
)
@Serializable
data class ClaimRequest(val claimant: String)
@Serializable
data class BlockRequest(val reason: String)
@Serializable
data class CancelRequest(val reason: String? = null)
@Serializable
data class LinkRequest(val targetId: String, val type: String = "RELATES_TO", val targetKind: String? = null)
@Serializable
data class NoteRequest(val body: String, val author: String? = null)
@Serializable
data class CommitDto(val sha: String = "", val message: String)
// When commits are supplied they are used verbatim; otherwise the server reads the last `limit`
// commits from the repo (when a GitCommitReader is wired).
@Serializable
data class SyncGitRequest(val commits: List<CommitDto> = emptyList(), val limit: Int = DEFAULT_SYNC_LIMIT)
private const val DEFAULT_SYNC_LIMIT = 20
/**
* Full REST surface for tasks. The write path mirrors the [TaskService] / `task_*` tools; both
* append to the event log, which stays the single source of truth (nothing is stored separately).
* A task's project is encoded in its id, so item routes need only the `{id}`. The list route takes
* optional `project` (cross-project board without it), `status`, and `q` (ranked text search);
* create requires `project` in the body. [GET /tasks/{id}/context] serves the agent bundle.
*/
fun Route.taskRoutes(module: ServerModule) {
val service = TaskService(module.eventStore)
val assembler = TaskContextAssembler(
service,
module.taskKnowledgeRetriever,
module.taskDocumentResolver,
module.taskArtifactResolver,
module.taskSessionResolver,
)
route("/tasks") {
taskCollectionRoutes(service)
taskSyncRoute(service, module.gitCommitReader)
route("/{id}") {
taskCrudRoutes(service, assembler)
taskTransitionRoutes(service)
taskGraphRoutes(service)
}
}
}
private fun Route.taskCollectionRoutes(service: TaskService) {
get {
// ?ready=true short-circuits to the dependency-aware workable set (TODO, no unmet blockers).
if (call.request.queryParameters["ready"]?.toBoolean() == true) {
val readyProject = call.request.queryParameters["project"]
val workable = service.ready(readyProject?.let { ProjectId(it) })
return@get call.respond(workable.map { it.toResponse().copy(ready = true) })
}
val statusRaw = call.request.queryParameters["status"]
val statusFilter = statusRaw?.let {
parseEnum<TaskStatus>(it) ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid status: $it")
}
// project is optional: scoped to one project when given, else the whole cross-project board.
val project = call.request.queryParameters["project"]
val base = (if (project != null) service.list(ProjectId(project)) else service.listAll())
.filter { statusFilter == null || it.state.status == statusFilter }
// q ranks by relevance; without it the board is most-recently-touched first.
val q = call.request.queryParameters["q"]
val ordered = if (q.isNullOrBlank()) {
base.sortedByDescending { it.state.updatedAt }
} else {
TaskSearch.search(base, q)
}
// Resolve ready/blockedBy against each task's FULL project board (not the filtered list — a
// status filter must not hide a finished blocker). One list() per distinct project, cached.
val boards = HashMap<ProjectId, List<Task>>()
fun enrich(t: Task): TaskResponse {
val pid = t.state.projectId ?: return t.toResponse()
val board = boards.getOrPut(pid) { service.list(pid) }
return t.toResponse().copy(
ready = TaskGraph.isReady(t, board),
blockedBy = TaskGraph.unmetBlockers(t, board).map { it.taskId.value },
)
}
call.respond(ordered.map(::enrich))
}
post {
val body = call.receive<CreateTaskRequest>()
val project = ProjectId(body.project)
// Duplicate-title guard (skippable with force): 409 with the look-alikes so the caller reuses one.
val duplicates = if (body.force) emptyList() else service.findDuplicates(project, body.title)
if (duplicates.isNotEmpty()) {
return@post call.respond(HttpStatusCode.Conflict, duplicates.map { it.toResponse() })
}
val task = service.createTask(
projectId = project,
title = body.title,
goal = body.goal,
acceptanceCriteria = body.acceptanceCriteria,
affectedPaths = body.affectedPaths,
)
call.respond(HttpStatusCode.Created, task.toResponse())
}
// Disposable Markdown projection of the board (whole board, or one project with ?project=).
get("/export") {
val project = call.request.queryParameters["project"]
val tasks = if (project != null) service.list(ProjectId(project)) else service.listAll()
call.respondText(TaskMarkdown.render(tasks), ContentType.Text.Plain)
}
}
// Git-driven status: a commit mention advances a task to IN_PROGRESS, a closing keyword
// ("fixes auth-12") advances it to DONE. Idempotent — wire this to a post-commit / post-merge hook
// or a CI step. Acts on commits in the body, or reads the repo's recent log when [reader] is set.
private fun Route.taskSyncRoute(service: TaskService, reader: GitCommitReader?) {
post("/sync-git") {
val body = runCatching { call.receive<SyncGitRequest>() }.getOrNull() ?: SyncGitRequest()
val commits = if (body.commits.isNotEmpty()) {
body.commits.map { Commit(it.sha, it.message) }
} else {
reader?.recent(body.limit) ?: emptyList()
}
call.respond(GitTaskSync(service).sync(commits))
}
}
private fun Route.taskCrudRoutes(service: TaskService, assembler: TaskContextAssembler) {
get {
val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.getTask(id))
}
patch {
val id = taskId() ?: return@patch call.respond(HttpStatusCode.BadRequest, "Missing task id")
if (service.getTask(id) == null) return@patch call.respond(HttpStatusCode.NotFound, "Task not found")
val body = call.receive<EditTaskRequest>()
if (body.title != null || body.goal != null) service.edit(id, body.title, body.goal)
if (body.acceptanceCriteria != null) service.setAcceptanceCriteria(id, body.acceptanceCriteria)
if (body.affectedPaths != null) service.setAffectedPaths(id, body.affectedPaths)
respondTask(service.getTask(id))
}
delete {
val id = taskId() ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing task id")
if (service.delete(id)) call.respond(HttpStatusCode.NoContent)
else call.respond(HttpStatusCode.NotFound, "Task not found")
}
get("/context") {
val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id")
val bundle = assembler.assemble(id) ?: return@get call.respond(HttpStatusCode.NotFound, "Task not found")
call.respond(bundle)
}
// Lifecycle audit trail from the event log; visible even after a soft-delete. Empty history
// means the task id never existed.
get("/history") {
val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id")
val events = service.history(id)
if (events.isEmpty()) return@get call.respond(HttpStatusCode.NotFound, "Task not found")
call.respondText(TaskHistory.render(events), ContentType.Text.Plain)
}
// Unfinished tasks this one is waiting on (its dependencies + inbound blocks).
get("/blockers") {
val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id")
if (service.getTask(id) == null) return@get call.respond(HttpStatusCode.NotFound, "Task not found")
call.respond(service.blockers(id).map { it.toResponse() })
}
}
private fun Route.taskTransitionRoutes(service: TaskService) {
post("/claim") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.claim(id, call.receive<ClaimRequest>().claimant))
}
post("/release") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.release(id))
}
post("/block") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.block(id, call.receive<BlockRequest>().reason))
}
post("/unblock") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.unblock(id))
}
post("/submit-for-review") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.submitForReview(id))
}
post("/complete") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.complete(id))
}
post("/reopen") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.reopen(id))
}
post("/cancel") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
val reason = runCatching { call.receive<CancelRequest>() }.getOrNull()?.reason
respondTask(service.cancel(id, reason))
}
}
private fun Route.taskGraphRoutes(service: TaskService) {
post("/links") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
val body = call.receive<LinkRequest>()
val type = parseEnum<TaskLinkType>(body.type)
?: return@post call.respond(HttpStatusCode.BadRequest, "Invalid link type: ${body.type}")
val kind = when (val raw = body.targetKind) {
null -> TaskTargetKinds.infer(body.targetId)
else -> parseEnum<TaskTargetKind>(raw)
?: return@post call.respond(HttpStatusCode.BadRequest, "Invalid target kind: $raw")
}
respondTask(service.link(id, body.targetId, type, kind))
}
delete("/links") {
val id = taskId() ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing task id")
val targetId = call.request.queryParameters["targetId"]
?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing 'targetId' query parameter")
val typeRaw = call.request.queryParameters["type"]
?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing 'type' query parameter")
val type = parseEnum<TaskLinkType>(typeRaw)
?: return@delete call.respond(HttpStatusCode.BadRequest, "Invalid link type: $typeRaw")
respondTask(service.unlink(id, targetId, type))
}
post("/notes") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
val body = call.receive<NoteRequest>()
val author = body.author?.let {
parseEnum<TaskNoteAuthor>(it) ?: return@post call.respond(HttpStatusCode.BadRequest, "Invalid author: $it")
} ?: TaskNoteAuthor.HUMAN
respondTask(service.addNote(id, author, body.body))
}
}
private fun RoutingContext.taskId(): TaskId? = call.parameters["id"]?.let { TypeId(it) }
private suspend fun RoutingContext.respondTask(task: Task?) {
if (task == null) call.respond(HttpStatusCode.NotFound, "Task not found")
else call.respond(task.toResponse())
}
private inline fun <reified T : Enum<T>> parseEnum(raw: String): T? =
enumValues<T>().firstOrNull { it.name.equals(raw, ignoreCase = true) }
private fun Task.toResponse(): TaskResponse = TaskResponse(
id = taskId.value,
key = state.key,
status = state.status.name,
title = state.title,
goal = state.goal,
acceptanceCriteria = state.acceptanceCriteria,
affectedPaths = state.affectedPaths,
claimant = state.claimant,
links = state.links.map { TaskLinkDto(it.targetId, it.type.name, it.targetKind.name) },
notes = state.notes.map { TaskNoteDto(it.author.name, it.body, it.at.toString()) },
createdAt = state.createdAt?.toString(),
updatedAt = state.updatedAt?.toString(),
)
@@ -0,0 +1,36 @@
package com.correx.apps.server.tasks
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.SessionWorkingTaskEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.TaskId
import com.correx.infrastructure.tools.task.SessionFactRecorder
import kotlinx.datetime.Clock
import java.util.UUID
/**
* Adapter for [SessionFactRecorder]: appends a [SessionWorkingTaskEvent] to the session's own stream
* when it claims (or re-scopes) a task, so [com.correx.core.toolintent.SessionContextProjection]
* folds it into the active task without scanning task streams.
*/
class EventStoreSessionFactRecorder(private val eventStore: EventStore) : SessionFactRecorder {
override suspend fun recordWorkingTask(sessionId: SessionId, taskId: TaskId, affectedPaths: List<String>) {
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = SessionWorkingTaskEvent(sessionId, taskId, affectedPaths),
),
)
}
}
@@ -0,0 +1,21 @@
package com.correx.apps.server.tasks
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
import com.correx.core.toolintent.SessionContextProjection
import com.correx.infrastructure.tools.task.SessionWrites
/**
* Adapter for the [SessionWrites] port: folds a session's events through
* [SessionContextProjection] and returns its writes (the recorded receipt.affectedEntities).
* Powers the cite-before-claim gate. Replay-safe (reads the log, never live state).
*/
class EventStoreSessionWrites(private val eventStore: EventStore) : SessionWrites {
override fun pathsWritten(sessionId: SessionId): Set<String> {
val projection = SessionContextProjection(sessionId)
return eventStore.read(sessionId)
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
.writes
}
}
@@ -0,0 +1,53 @@
package com.correx.apps.server.tasks
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId
import com.correx.core.tasks.ResolvedArtifact
import com.correx.core.tasks.TaskArtifactResolver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val EXCERPT_MAX = 280
/**
* Resolves ARTIFACT link targets for the task context bundle by scanning the event log for the
* artifact's lifecycle events: [ArtifactCreatedEvent]/[ArtifactContentStoredEvent] give the
* producing stage + session, and the content hash is dereferenced against the CAS [ArtifactStore]
* for an excerpt. Returns null when no events match the id — the link then stays a raw related link.
*/
class EventStoreTaskArtifactResolver(
private val eventStore: EventStore,
private val artifactStore: ArtifactStore,
private val excerptMax: Int = EXCERPT_MAX,
) : TaskArtifactResolver {
override suspend fun resolve(targetId: String): ResolvedArtifact? = withContext(Dispatchers.IO) {
val artifactId = ArtifactId(targetId)
var stage: String? = null
var sessionId: String? = null
var contentHash: ArtifactId? = null
eventStore.allEvents().forEach { stored ->
when (val p = stored.payload) {
is ArtifactCreatedEvent -> if (p.artifactId == artifactId) {
stage = p.stageId.value
sessionId = p.sessionId.value
}
is ArtifactContentStoredEvent -> if (p.artifactId == artifactId) {
stage = stage ?: p.stageId.value
sessionId = sessionId ?: p.sessionId.value
contentHash = p.contentHash
}
else -> Unit
}
}
if (stage == null && sessionId == null && contentHash == null) return@withContext null
val excerpt = contentHash?.let { hash ->
runCatching { artifactStore.get(hash) }.getOrNull()
?.toString(Charsets.UTF_8)?.trim()?.takeIf { it.isNotEmpty() }?.take(excerptMax)
}
ResolvedArtifact(stage = stage, sessionId = sessionId, excerpt = excerpt)
}
}
@@ -0,0 +1,91 @@
package com.correx.apps.server.tasks
import com.correx.core.tasks.ResolvedDocument
import com.correx.core.tasks.TaskDocumentResolver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.isRegularFile
import kotlin.io.path.name
import kotlin.io.path.readText
private const val EXCERPT_MAX = 280
private val ADR_ID = Regex("(?i)^adr[-_ ]?0*(\\d+)$")
private val ADR_FILE = Regex("^adr-0*(\\d+)")
private val FRONTMATTER_NAME = Regex("(?m)^name:\\s*\"?(.+?)\"?\\s*$")
private val FRONTMATTER_DESC = Regex("(?m)^description:\\s*\"?(.+?)\"?\\s*$")
/**
* Resolves link targets to repo documents for the task context bundle:
* - ADR ids ("adr-7", "ADR-0007", "adr 12") → `docs/decisions/adr-<n>-*.md` (matched by number,
* padding-agnostic);
* - explicit "*.md" paths under the repo root.
* Title is the frontmatter `name:` or first `# ` heading; excerpt is the frontmatter
* `description:` or the first prose paragraph, capped. Returns null for anything else.
*/
class FileTaskDocumentResolver(
private val repoRoot: Path,
private val decisionsDir: Path = repoRoot.resolve("docs/decisions"),
) : TaskDocumentResolver {
override suspend fun resolve(targetId: String): ResolvedDocument? = withContext(Dispatchers.IO) {
resolveAdr(targetId.trim()) ?: resolveMarkdownPath(targetId.trim())
}
private fun resolveAdr(targetId: String): ResolvedDocument? {
val num = ADR_ID.matchEntire(targetId)?.groupValues?.get(1)?.toIntOrNull() ?: return null
if (!decisionsDir.isDirectory()) return null
val file = Files.list(decisionsDir).use { stream ->
stream.filter { it.isRegularFile() && it.name.endsWith(".md") }
.filter { adrNumber(it.name) == num }
.findFirst()
.orElse(null)
} ?: return null
return readDocument(file)
}
private fun resolveMarkdownPath(targetId: String): ResolvedDocument? {
if (!targetId.endsWith(".md")) return null
val candidate = repoRoot.resolve(targetId).normalize()
if (!candidate.startsWith(repoRoot) || !candidate.exists() || !candidate.isRegularFile()) return null
return readDocument(candidate)
}
private fun adrNumber(fileName: String): Int? =
ADR_FILE.find(fileName.lowercase())?.groupValues?.get(1)?.toIntOrNull()
private fun readDocument(file: Path): ResolvedDocument {
val text = runCatching { file.readText() }.getOrDefault("")
return ResolvedDocument(
title = extractTitle(text) ?: file.name,
path = repoRoot.relativize(file).toString(),
excerpt = extractExcerpt(text),
)
}
private fun extractTitle(text: String): String? {
FRONTMATTER_NAME.find(text)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
return text.lineSequence().firstOrNull { it.startsWith("# ") }?.removePrefix("# ")?.trim()
}
private fun extractExcerpt(text: String): String {
val desc = FRONTMATTER_DESC.find(text)?.groupValues?.get(1)?.trim()
return (desc ?: firstParagraph(text)).take(EXCERPT_MAX).trim()
}
private fun firstParagraph(text: String): String =
stripFrontmatter(text).lineSequence()
.map { it.trim() }
.firstOrNull { it.isNotEmpty() && !it.startsWith("#") && !it.startsWith("---") }
?: ""
private fun stripFrontmatter(text: String): String {
if (!text.startsWith("---")) return text
val lines = text.lines()
val closeIdx = lines.drop(1).indexOfFirst { it.trim() == "---" }
return if (closeIdx >= 0) lines.drop(closeIdx + 2).joinToString("\n") else text
}
}
@@ -0,0 +1,56 @@
package com.correx.apps.server.tasks
import com.correx.core.tasks.Commit
import com.correx.core.tasks.GitCommitReader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.file.Path
import java.util.concurrent.TimeUnit
private const val RS = "" // ASCII record separator, between commits
private const val US = "" // ASCII unit separator, between sha and body
private const val GIT_TIMEOUT_SEC = 10L
/**
* Reads recent commits by shelling `git log` in the repo. Best-effort: any failure (git missing,
* not a repo, timeout, non-zero exit) yields an empty list so the sync endpoint degrades to a no-op
* rather than erroring. Parsing is split into the testable top-level [parseGitLog].
*/
class GitCommandCommitReader(private val repoRoot: Path) : GitCommitReader {
override suspend fun recent(limit: Int): List<Commit> = withContext(Dispatchers.IO) {
runCatching { runGitLog(limit) }.getOrDefault(emptyList())
}
private fun runGitLog(limit: Int): List<Commit> {
val process = ProcessBuilder(
"git", "-C", repoRoot.toString(), "log", "-n", limit.toString(), "--format=%H$US%B$RS",
).redirectErrorStream(false).start()
val output = process.inputStream.bufferedReader().use { it.readText() }
val finished = process.waitFor(GIT_TIMEOUT_SEC, TimeUnit.SECONDS)
return if (finished && process.exitValue() == 0) {
parseGitLog(output)
} else {
if (!finished) process.destroy()
emptyList()
}
}
}
/**
* Parses `git log --format=%H<US>%B<RS>` output into commits: records split on the record
* separator, then each split once on the unit separator into sha + message. Top-level + internal
* so it is unit-testable without invoking git.
*/
internal fun parseGitLog(raw: String): List<Commit> =
raw.split(RS)
.map { it.trim() }
.filter { it.isNotEmpty() }
.mapNotNull { record ->
val idx = record.indexOf(US)
if (idx < 0) {
null
} else {
Commit(sha = record.substring(0, idx).trim(), message = record.substring(idx + 1).trim())
}
}
@@ -0,0 +1,33 @@
package com.correx.apps.server.tasks
import com.correx.core.events.stores.EventStore
import com.correx.core.sessions.SessionSummaryProjector
import com.correx.core.tasks.ResolvedSession
import com.correx.core.tasks.TaskSessionResolver
import com.correx.core.utils.TypeId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Resolves SESSION link targets for the task context bundle by projecting the session's events into
* a [com.correx.core.sessions.SessionSummary] (status / intent / workflow). Returns null when the
* session has no events — the link then stays a raw related link.
*/
class SessionSummaryTaskSessionResolver(
private val eventStore: EventStore,
) : TaskSessionResolver {
private val projector = SessionSummaryProjector()
override suspend fun resolve(targetId: String): ResolvedSession? = withContext(Dispatchers.IO) {
val sessionId = TypeId(targetId)
val events = eventStore.read(sessionId)
if (events.isEmpty()) return@withContext null
val summary = projector.project(sessionId, events)
ResolvedSession(
status = summary.status,
intent = summary.intent,
workflowId = summary.workflowId,
)
}
}
@@ -0,0 +1,46 @@
package com.correx.apps.server.workspace
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.isRegularFile
import kotlin.io.path.name
import kotlin.io.path.relativeTo
/**
* Lists workspace-relative file paths under a root for the TUI's `@` file-reference picker.
*
* Deliberately distinct from `RepoMapIndexer`: that one reads every file's *contents* to extract
* symbols and only covers source/markdown extensions (it feeds project memory + context). The `@`
* picker wants *all* file types, by path only, as cheaply as possible — so this never opens a file.
* It does share the indexer's directory-ignore convention (skip any path segment in [ignoreDirs])
* so both agree on what "the workspace" is. Results are sorted and capped to bound the reply.
*/
object WorkspaceFiles {
val DEFAULT_IGNORE_DIRS = setOf(
".git", "node_modules", "build", "target", ".gradle", "dist", ".idea", ".correx", ".venv",
)
const val DEFAULT_LIMIT = 2000
private const val MAX_DEPTH = 12
fun list(
root: Path,
ignoreDirs: Set<String> = DEFAULT_IGNORE_DIRS,
limit: Int = DEFAULT_LIMIT,
): List<String> {
if (!Files.isDirectory(root)) return emptyList()
val out = ArrayList<String>()
Files.walk(root, MAX_DEPTH).use { stream ->
val iter = stream.iterator()
while (iter.hasNext()) {
val path = iter.next()
if (!path.isRegularFile()) continue
val rel = path.relativeTo(root)
if (rel.any { it.name in ignoreDirs }) continue
out.add(rel.toString())
}
}
out.sort()
return out.take(limit)
}
}
@@ -13,12 +13,14 @@ import com.correx.apps.server.protocol.WorkflowDto
import com.correx.apps.server.protocol.StageToolDecl
import com.correx.apps.server.protocol.ToolDecl
import com.correx.apps.server.workspace.WorkspaceResolution
import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID
import com.correx.core.approvals.GrantScope
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ApprovalGrantCreatedEvent
import com.correx.core.events.events.ApprovalGrantExpiredEvent
import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.IdeaDiscardedEvent
import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
@@ -30,6 +32,9 @@ import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.ProviderHealth
import com.correx.core.kernel.orchestration.WorkspaceContext
import com.correx.core.config.ProjectProfileLoader
import com.correx.core.config.ProjectProfileWriter
import com.correx.core.config.withConvention
import com.correx.core.router.IdeaReader
import com.correx.core.utils.TypeId
import com.correx.infrastructure.inference.commons.ResourceProbe
@@ -231,8 +236,10 @@ class GlobalStreamHandler(private val module: ServerModule) {
.onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) }
}
is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId))
is ClientMessage.ListFiles -> sendFrame(queries.listFiles(msg.sessionId))
is ClientMessage.ListIdeas -> sendFrame(queries.listIdeas())
is ClientMessage.DiscardIdea -> handleDiscardIdea(msg, sendFrame)
is ClientMessage.PromoteIdea -> handlePromoteIdea(msg, sendFrame)
is ClientMessage.GetSessionStats -> sendFrame(queries.sessionStats(msg.sessionId))
is ClientMessage.GetHealthChecks -> sendFrame(queries.healthChecks())
is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList()))
@@ -241,6 +248,8 @@ class GlobalStreamHandler(private val module: ServerModule) {
is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
is ClientMessage.ClarificationResponse -> handleClarificationResponse(msg)
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
is ClientMessage.RevokeGrant -> handleRevokeGrant(msg, sendFrame)
is ClientMessage.ListGrants -> sendFrame(queries.listGrants())
is ClientMessage.ChatInput -> {
// The USER and ROUTER turns are emitted as ChatTurnEvents inside onUserInput
// and reach the client as chat.turn frames via streamGlobal — no direct
@@ -337,6 +346,57 @@ class GlobalStreamHandler(private val module: ServerModule) {
sendFrame(queries.listIdeas())
}
// Promote an idea into the bound session's curated project profile (a `conventions` entry in
// <workspaceRoot>/.correx/project.toml), then tombstone it like a discard so it leaves the board.
// No-ops (just refresh the board) when the idea is missing or its capturing session has no bound
// workspace — promotion needs a workspace to write the profile into.
private suspend fun handlePromoteIdea(
msg: ClientMessage.PromoteIdea,
sendFrame: suspend (ServerMessage) -> Unit,
) {
runCatching {
val reader = IdeaReader(module.eventStore)
val sessionId = reader.sessionOf(msg.ideaId) ?: return@runCatching
val text = reader.textOf(msg.ideaId) ?: return@runCatching
val workspaceRoot = workspaceRootOf(sessionId) ?: return@runCatching
withContext(Dispatchers.IO) {
val updated = ProjectProfileLoader.load(workspaceRoot).withConvention(text)
ProjectProfileWriter.write(workspaceRoot, updated)
}
module.eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = IdeaPromotedEvent(
ideaId = msg.ideaId,
sessionId = sessionId,
text = text,
timestampMs = System.currentTimeMillis(),
),
),
)
}.onFailure {
log.error("promoteIdea failed for ideaId={}: {}", msg.ideaId, it.message, it)
}
sendFrame(queries.listIdeas())
}
// The workspace a session was bound to, from its latest SessionWorkspaceBoundEvent (invariant #9),
// or null if the session never bound one.
private fun workspaceRootOf(sessionId: SessionId): String? =
module.eventStore.read(sessionId)
.mapNotNull { it.payload as? SessionWorkspaceBoundEvent }
.lastOrNull()
?.workspaceRoot
private fun errorResponse(message: String) = ServerMessage.ProtocolError(message)
private fun encodeError(message: String): Frame.Text =
@@ -346,18 +406,20 @@ class GlobalStreamHandler(private val module: ServerModule) {
msg: ClientMessage.CreateGrant,
sendFrame: suspend (ServerMessage) -> Unit,
) {
// Validate: tiers must be non-empty and bounded to T2 (server-side ceiling).
// T3/T4 are destructive/escalated tiers that must never be auto-approved via grants.
// No tier ceiling: the operator explicitly opted out of the prior T2 cap, so a grant may
// authorize any tier (including the destructive T3/T4). Every operator-creatable scope is
// still tool-bound (below) so a grant never becomes a blanket "approve everything".
if (msg.permittedTiers.isEmpty()) {
sendFrame(errorResponse("CreateGrant: permittedTiers must not be empty"))
return
}
val maxGrantableTier = Tier.T2
val overBroad = msg.permittedTiers.filter { it.level > maxGrantableTier.level }
if (overBroad.isNotEmpty()) {
sendFrame(errorResponse("CreateGrant: tiers ${overBroad.map { it.name }} exceed maximum grantable tier ${maxGrantableTier.name}"))
return
}
suspend fun requireToolName(scopeLabel: String): String? =
msg.toolName?.takeIf { it.isNotBlank() }
?: run {
sendFrame(errorResponse("CreateGrant: $scopeLabel scope requires toolName to prevent blanket approval"))
null
}
val scope = when (msg.scope) {
GrantScopeDto.SESSION -> {
@@ -365,12 +427,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
sendFrame(errorResponse("CreateGrant: SESSION scope must not include stageId"))
return
}
// Require toolName — blanket session grants (no operation scope) are rejected.
val toolName = msg.toolName?.takeIf { it.isNotBlank() } ?: run {
sendFrame(errorResponse("CreateGrant: SESSION scope requires toolName to prevent blanket approval"))
return
}
GrantScope.SESSION(toolName)
GrantScope.SESSION(requireToolName("SESSION") ?: return)
}
GrantScopeDto.STAGE -> {
val sid = msg.stageId ?: run {
@@ -379,11 +436,25 @@ class GlobalStreamHandler(private val module: ServerModule) {
}
GrantScope.STAGE(sid)
}
GrantScopeDto.PROJECT -> {
// projectId is derived from the session's bound workspace, never trusted from the client.
val projectId = queries.projectIdForSession(msg.sessionId) ?: run {
sendFrame(errorResponse("CreateGrant: PROJECT scope requires a session with a bound workspace"))
return
}
GrantScope.PROJECT(projectId, requireToolName("PROJECT") ?: return)
}
GrantScopeDto.GLOBAL -> GrantScope.GLOBAL(requireToolName("GLOBAL") ?: return)
}
// SESSION/STAGE grants live in the originating session's stream (they die with it); PROJECT
// and GLOBAL grants outlive any session, so they go to the shared cross-session grant ledger.
val ledgered = scope is GrantScope.PROJECT || scope is GrantScope.GLOBAL
val streamId = if (ledgered) GRANT_LEDGER_SESSION_ID else msg.sessionId
val event = NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = msg.sessionId,
sessionId = streamId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
@@ -397,11 +468,44 @@ class GlobalStreamHandler(private val module: ServerModule) {
expiresAt = msg.expiresAt,
sessionId = msg.sessionId,
stageId = (scope as? GrantScope.STAGE)?.stageId,
projectId = null,
toolName = (scope as? GrantScope.SESSION)?.toolName,
projectId = (scope as? GrantScope.PROJECT)?.projectId,
toolName = scope.toolNameOrNull(),
),
)
module.eventStore.append(event)
// Echo the refreshed standing-grant list so the TUI can confirm a PROJECT/GLOBAL grant landed.
if (ledgered) sendFrame(queries.listGrants())
}
/**
* Revokes a standing (PROJECT/GLOBAL) grant by appending an [ApprovalGrantExpiredEvent] to the
* grant ledger — the reducer drops it, so the gate stops honouring it across all sessions while
* the create/revoke pair stays in the audit log. Replies with the refreshed grant list.
*/
private suspend fun handleRevokeGrant(
msg: ClientMessage.RevokeGrant,
sendFrame: suspend (ServerMessage) -> Unit,
) {
val event = NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = GRANT_LEDGER_SESSION_ID,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = ApprovalGrantExpiredEvent(grantId = msg.grantId),
)
module.eventStore.append(event)
sendFrame(queries.listGrants())
}
private fun GrantScope.toolNameOrNull(): String? = when (this) {
is GrantScope.SESSION -> toolName
is GrantScope.PROJECT -> toolName
is GrantScope.GLOBAL -> toolName
is GrantScope.STAGE -> null
}
private suspend fun handleStartChatSession(
@@ -6,8 +6,14 @@ import com.correx.apps.server.health.HealthInspectionService
import com.correx.apps.server.metrics.MetricsInspectionService
import com.correx.apps.server.protocol.ArtifactSummaryDto
import com.correx.apps.server.protocol.ConfigFieldDto
import com.correx.apps.server.protocol.GrantDto
import com.correx.apps.server.protocol.IdeaDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.workspace.WorkspaceFiles
import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID
import com.correx.core.approvals.GrantScope
import com.correx.core.approvals.ProjectIdentity
import com.correx.core.events.types.ProjectId
import com.correx.core.router.IdeaReader
import com.correx.core.config.EditableConfig
import com.correx.core.events.events.ArtifactContentStoredEvent
@@ -15,11 +21,14 @@ import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.SessionId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import java.nio.file.Paths
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
@@ -62,6 +71,70 @@ class StreamQueries(private val module: ServerModule) {
return ServerMessage.HealthChecks(health = report)
}
/**
* The session workspace's file paths for the `@` picker. Resolves the bound workspace root from
* the recorded [SessionWorkspaceBoundEvent] (invariant #9) and lists it by path. Pure snapshot
* read — no events appended (replay-neutral). Empty when the session has no bound workspace.
*/
suspend fun listFiles(sessionId: SessionId): ServerMessage.FileList {
val paths = withContext(Dispatchers.IO) {
workspaceRootOf(sessionId)?.let { WorkspaceFiles.list(Paths.get(it)) } ?: emptyList()
}
return ServerMessage.FileList(sessionId = sessionId, paths = paths)
}
// The workspace a session was bound to, from its latest SessionWorkspaceBoundEvent, or null.
private fun workspaceRootOf(sessionId: SessionId): String? =
module.eventStore.read(sessionId)
.mapNotNull { it.payload as? SessionWorkspaceBoundEvent }
.lastOrNull()
?.workspaceRoot
/**
* The stable [ProjectId] a session's bound workspace resolves to (the same derivation the
* approval gate uses), or null when the session has no bound workspace. Used to scope a PROJECT
* grant to the right repository without trusting a client-supplied id.
*/
fun projectIdForSession(sessionId: SessionId): ProjectId? =
workspaceRootOf(sessionId)?.let { ProjectIdentity.of(it) }
/**
* The active cross-session grants as a snapshot frame, folded from the grant ledger via the
* approval reducer. Expired grants are dropped so the viewer only lists ones still in force.
* Pure read — no events appended (replay-neutral).
*/
fun listGrants(): ServerMessage.GrantList {
val now = Clock.System.now()
val grants = module.approvalRepository.getApprovalState(GRANT_LEDGER_SESSION_ID).grants.values
.filter { grant -> grant.expiresAt.let { it == null || it > now } }
.map { grant ->
GrantDto(
grantId = grant.id.value,
scope = grant.scope.kindName(),
toolName = grant.scope.toolNameOrNull(),
projectId = (grant.scope as? GrantScope.PROJECT)?.projectId?.value,
tiers = grant.permittedTiers.map { it.name }.sorted(),
reason = grant.reason,
expiresAtMs = grant.expiresAt?.toEpochMilliseconds(),
)
}
return ServerMessage.GrantList(grants = grants)
}
private fun GrantScope.kindName(): String = when (this) {
is GrantScope.SESSION -> "SESSION"
is GrantScope.STAGE -> "STAGE"
is GrantScope.PROJECT -> "PROJECT"
is GrantScope.GLOBAL -> "GLOBAL"
}
private fun GrantScope.toolNameOrNull(): String? = when (this) {
is GrantScope.SESSION -> toolName
is GrantScope.PROJECT -> toolName
is GrantScope.GLOBAL -> toolName
is GrantScope.STAGE -> null
}
/**
* Reads a session's events to assemble its artifact listing (creation order preserved),
* resolving each artifact's stored bytes via the CAS artifact store. Pure snapshot read —
@@ -0,0 +1,276 @@
package com.correx.apps.server
import com.correx.apps.server.memory.ArchitectContradictionChecker
import com.correx.apps.server.registry.ProviderRegistry
import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.apps.server.registry.WorkflowSummary
import com.correx.apps.server.undo.SessionUndoService
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.context.builder.DefaultContextPackBuilder
import com.correx.core.context.compression.DefaultContextCompressor
import com.correx.core.events.EventDispatcher
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.PossibleContradictionFlaggedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.EventId
import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.Embedder
import com.correx.core.inference.InferenceProjector
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRepository
import com.correx.core.inference.ProviderHealth
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationProjector
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.kernel.orchestration.OrchestratorEngines
import com.correx.core.kernel.orchestration.OrchestratorRepositories
import com.correx.core.kernel.retry.DefaultRetryCoordinator
import com.correx.core.risk.DefaultRiskAssessor
import com.correx.core.router.l3.L3Hit
import com.correx.core.router.l3.L3MemoryEntry
import com.correx.core.router.l3.L3MemoryStore
import com.correx.core.router.l3.L3Query
import com.correx.core.router.model.RouterConfig
import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.infrastructure.InfrastructureModule
import com.correx.infrastructure.inference.commons.UnavailableProbe
import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.infrastructure.tools.FileEditConfig
import com.correx.infrastructure.tools.FileReadConfig
import com.correx.infrastructure.tools.FileWriteConfig
import com.correx.infrastructure.tools.ShellConfig
import com.correx.infrastructure.tools.ToolConfig
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.Clock
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.util.UUID
/** Always returns the same vector so the canned L3 store fully controls the scores. */
private class OnesEmbedder(override val dimension: Int = 8) : Embedder {
override suspend fun embed(text: String): FloatArray = FloatArray(dimension) { 1f }
}
/** Returns canned hits regardless of the query vector. */
private class CannedL3MemoryStore(private val hits: List<L3Hit>) : L3MemoryStore {
override suspend fun store(entry: L3MemoryEntry) = Unit
override suspend fun query(query: L3Query): List<L3Hit> = hits.sortedByDescending { it.score }.take(query.k)
override suspend fun existsByTurnIdPrefix(prefix: String): Boolean = hits.any { it.entry.turnId.startsWith(prefix) }
override suspend fun close() = Unit
}
/** Minimal in-memory CAS keyed by content hash. */
private class MapArtifactStore(private val bytesByHash: Map<String, ByteArray>) : ArtifactStore {
override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("00".repeat(32))
override suspend fun get(id: ArtifactId): ByteArray? = bytesByHash[id.value]
override suspend fun flushBefore(commit: suspend () -> Unit) { commit() }
}
/**
* Exercises [ServerModule.handleArchitectArtifact]: the extraction/dispatch helper that backs the
* display-only architect contradiction subscription (BACKLOG §B-§4). A full subscription/integration
* test is not required — these assert the helper fetches the design artifact text and emits (or
* skips) the flag. The surrounding module is built with in-memory infra (no real model needed).
*/
class ArchitectContradictionHookTest {
private val session = SessionId("new-session")
private val priorSession = SessionId("prior-session")
private val architectStage = StageId("architect")
private val designArtifact = ArtifactId("design")
private val contentHash = ArtifactId("cafebabe")
private fun append(es: EventStore, payload: EventPayload, sid: SessionId = session) = runBlocking {
es.append(
NewEvent(
EventMetadata(EventId(UUID.randomUUID().toString()), sid, Clock.System.now(), 1, null, null),
payload,
),
)
}
private fun priorDecisionHit(text: String, score: Float) = L3Hit(
entry = L3MemoryEntry(
id = UUID.randomUUID().toString(),
sessionId = priorSession,
turnId = "project:/repo",
text = text,
vector = FloatArray(8) { 1f },
timestampMs = 0L,
),
score = score,
)
private fun flagsIn(es: EventStore): List<PossibleContradictionFlaggedEvent> =
es.read(session).map { it.payload }.filterIsInstance<PossibleContradictionFlaggedEvent>()
private fun buildModule(
eventStore: EventStore,
artifactStore: ArtifactStore,
checker: ArchitectContradictionChecker?,
tempDir: Path,
): ServerModule {
val provider = InfrastructureModule.createLlamaCppProvider(
modelId = "test-model",
modelPath = "/dev/null",
baseUrl = "http://127.0.0.1:1",
)
val providerRegistry = InfrastructureModule.createProviderRegistry(listOf(provider))
val inferenceRouter = com.correx.core.inference.DefaultInferenceRouter(
providerRegistry,
com.correx.infrastructure.inference.FirstAvailableRoutingStrategy(),
)
val toolConfig = ToolConfig(
shell = ShellConfig(enabled = false, allowedExecutables = emptySet(), workingDir = tempDir),
fileRead = FileReadConfig(enabled = false, allowedPaths = setOf(tempDir)),
fileWrite = FileWriteConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir),
fileEdit = FileEditConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir),
)
val toolRegistry = InfrastructureModule.createToolRegistry(toolConfig)
val toolExecutor = InfrastructureModule.createToolExecutor(
registry = toolRegistry,
eventDispatcher = EventDispatcher(eventStore),
workDir = tempDir,
artifactStore = null,
)
val engines = OrchestratorEngines(
transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) },
contextPackBuilder = DefaultContextPackBuilder(DefaultContextCompressor()),
inferenceRouter = inferenceRouter,
validationPipeline = ValidationPipeline(validators = emptyList()),
approvalEngine = com.correx.core.approvals.domain.DefaultApprovalEngine(),
riskAssessor = DefaultRiskAssessor(),
toolRegistry = toolRegistry,
toolExecutor = toolExecutor,
)
val repositories = OrchestratorRepositories(
eventStore = eventStore,
inferenceRepository = InferenceRepository(DefaultEventReplayer(eventStore, InferenceProjector())),
orchestrationRepository = OrchestrationRepository(
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
),
sessionRepository = DefaultSessionRepository(
DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
),
artifactRepository = InfrastructureModule.createArtifactRepository(eventStore),
approvalRepository = InfrastructureModule.createApprovalRepository(eventStore),
)
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
retryCoordinator = DefaultRetryCoordinator(eventStore),
artifactStore = artifactStore,
tokenizer = provider.tokenizer,
decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore),
)
val routerFacade = InfrastructureModule.createRouterFacade(
eventStore = eventStore,
inferenceRouter = inferenceRouter,
config = RouterConfig(),
tokenizer = provider.tokenizer,
)
val noopProviderRegistry = object : ProviderRegistry {
override fun listAll() = emptyList<InferenceProvider>()
override suspend fun healthCheckAll() = emptyMap<ProviderId, ProviderHealth>()
}
val noopWorkflowRegistry = object : WorkflowRegistry {
override fun listAll(): List<WorkflowSummary> = emptyList()
override fun find(workflowId: String): WorkflowGraph? = null
}
return ServerModule(
orchestrator = orchestrator,
eventStore = eventStore,
artifactStore = artifactStore,
sessionRepository = repositories.sessionRepository,
workflowRegistry = noopWorkflowRegistry,
providerRegistry = noopProviderRegistry,
defaultOrchestrationConfig = OrchestrationConfig(sandboxRoot = tempDir),
routerFacade = routerFacade,
orchestrationRepository = repositories.orchestrationRepository,
approvalRepository = repositories.approvalRepository,
toolRegistry = toolRegistry,
sessionUndoService = SessionUndoService(eventStore, artifactStore, bootRoots = setOf(tempDir)),
resourceProbe = UnavailableProbe,
architectContradictionChecker = checker,
)
}
@Test
fun `emits the flag when the checker surfaces a near prior decision`(@TempDir tempDir: Path) = runBlocking {
val es = InMemoryEventStore()
val designJson = """{"approach":"Use Postgres for the event store.","components":["db.kt"]}"""
val artifacts = MapArtifactStore(mapOf(contentHash.value to designJson.toByteArray()))
// ArtifactContentStored precedes ArtifactCreated for the same artifactId (inference-time).
append(es, ArtifactContentStoredEvent(designArtifact, contentHash, session, architectStage))
val created = ArtifactCreatedEvent(designArtifact, session, architectStage, schemaVersion = 1)
append(es, created)
val checker = ArchitectContradictionChecker(
OnesEmbedder(),
CannedL3MemoryStore(listOf(priorDecisionHit("Decided to use SQLite for the event store.", 0.9f))),
)
val flag = buildModule(es, artifacts, checker, tempDir).handleArchitectArtifact(checker, created)
assertNotNull(flag, "expected a contradiction flag")
assertEquals(session, flag!!.sessionId)
assertEquals(architectStage, flag.stageId)
// The design schema's `approach` field is preferred as the decision text.
assertEquals("Use Postgres for the event store.", flag.decisionSummary)
assertEquals("Decided to use SQLite for the event store.", flag.related.single().summary)
// The flag is appended to the session stream (display-only, but recorded for the operator).
assertEquals(1, flagsIn(es).size)
}
@Test
fun `returns null and appends nothing when the checker finds no related decision`(
@TempDir tempDir: Path,
) = runBlocking {
val es = InMemoryEventStore()
val designJson = """{"approach":"Use Postgres for the event store.","components":["db.kt"]}"""
val artifacts = MapArtifactStore(mapOf(contentHash.value to designJson.toByteArray()))
append(es, ArtifactContentStoredEvent(designArtifact, contentHash, session, architectStage))
val created = ArtifactCreatedEvent(designArtifact, session, architectStage, schemaVersion = 1)
append(es, created)
val checker = ArchitectContradictionChecker(OnesEmbedder(), CannedL3MemoryStore(emptyList()))
val flag = buildModule(es, artifacts, checker, tempDir).handleArchitectArtifact(checker, created)
assertNull(flag)
assertEquals(0, flagsIn(es).size)
}
@Test
fun `returns null when no content hash was recorded for the artifact`(@TempDir tempDir: Path) = runBlocking {
val es = InMemoryEventStore()
val created = ArtifactCreatedEvent(designArtifact, session, architectStage, schemaVersion = 1)
append(es, created)
val checker = ArchitectContradictionChecker(
OnesEmbedder(),
CannedL3MemoryStore(listOf(priorDecisionHit("Decided to use SQLite.", 0.9f))),
)
val flag = buildModule(es, MapArtifactStore(emptyMap()), checker, tempDir)
.handleArchitectArtifact(checker, created)
assertNull(flag)
}
}
@@ -0,0 +1,107 @@
package com.correx.apps.server.memory
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.Embedder
import com.correx.core.router.l3.L3Hit
import com.correx.core.router.l3.L3MemoryEntry
import com.correx.core.router.l3.L3MemoryStore
import com.correx.core.router.l3.L3Query
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
import java.util.UUID
private class ContradictionOnesEmbedder(override val dimension: Int = 8) : Embedder {
override suspend fun embed(text: String): FloatArray = FloatArray(dimension) { 1f }
}
/** Returns canned hits regardless of the query vector, so scores can be controlled precisely. */
private class CannedL3MemoryStore(private val hits: List<L3Hit>) : L3MemoryStore {
override suspend fun store(entry: L3MemoryEntry) = Unit
override suspend fun query(query: L3Query): List<L3Hit> = hits.sortedByDescending { it.score }.take(query.k)
override suspend fun existsByTurnIdPrefix(prefix: String): Boolean = hits.any { it.entry.turnId.startsWith(prefix) }
override suspend fun close() = Unit
}
class ArchitectContradictionCheckerTest {
private val newSession = SessionId("new-session")
private val priorSession = SessionId("prior-session")
private val stageId = StageId("architect")
private fun hit(text: String, score: Float, turnId: String, sessionId: SessionId = priorSession) =
L3Hit(
entry = L3MemoryEntry(
id = UUID.randomUUID().toString(),
sessionId = sessionId,
turnId = turnId,
text = text,
vector = FloatArray(8) { 1f },
timestampMs = 0L,
),
score = score,
)
@Test
fun `flags a related prior decision above threshold`() = runBlocking {
val store = CannedL3MemoryStore(
listOf(hit("Decided to use SQLite for the event store.", score = 0.9f, turnId = "project:/repo")),
)
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
val flag = checker.check(newSession, stageId, "Use Postgres for the event store.")
assertTrue(flag != null, "expected a flag")
assertEquals(newSession, flag!!.sessionId)
assertEquals(stageId, flag.stageId)
assertEquals("Use Postgres for the event store.", flag.decisionSummary)
assertEquals(1, flag.related.size)
val related = flag.related.single()
assertEquals("Decided to use SQLite for the event store.", related.summary)
assertEquals("project:/repo", related.source)
assertEquals(0.9, related.score, 1e-6)
}
@Test
fun `returns null when there are no hits`() = runBlocking {
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), CannedL3MemoryStore(emptyList()))
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store."))
}
@Test
fun `returns null when all hits are below threshold`() = runBlocking {
val store = CannedL3MemoryStore(
listOf(hit("Loosely related prior note.", score = 0.5f, turnId = "project:/repo")),
)
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store, scoreThreshold = 0.75)
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store."))
}
@Test
fun `filters out hits outside the decision namespace`() = runBlocking {
val store = CannedL3MemoryStore(
listOf(
// Above threshold but a repo-map entry, not a decision — must be excluded.
hit("Foo.kt: ClassA, funcB", score = 0.95f, turnId = "repomap:/repo:abc"),
),
)
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store."))
}
@Test
fun `filters out the architect's own in-flight session`() = runBlocking {
val store = CannedL3MemoryStore(
listOf(hit("Same-session decision.", score = 0.95f, turnId = "project:/repo", sessionId = newSession)),
)
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store."))
}
}
@@ -12,6 +12,7 @@ import com.correx.core.events.types.SessionId
import com.correx.infrastructure.persistence.InMemoryEventStore
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Path
@@ -121,4 +122,36 @@ class ProjectMemoryServiceReuseTest {
"entries should be embedded with stateKey tag",
)
}
@Test
fun `repoRoot prefix does not collide across roots — repo vs repo2`(): Unit = runBlocking {
val es = InMemoryEventStore()
val l3 = InMemoryL3MemoryStore()
val probe = FakeWorkspaceStateProbe(WorkspaceState("git:hash1", "git", "main", false))
val embedder = ConstantEmbedderReuse()
// Index /repo and /repo2 into the same shared L3 store with the same stateKey.
// The trailing-':' delimiter on the repomap: tag must keep their entries from bleeding
// across roots: "/repo" must NOT match tags written for "/repo2".
val repoEntries = listOf(RepoMapEntry(path = "src/Repo.kt", score = 1.0, symbols = listOf("RepoClass")))
val repo2Entries = listOf(RepoMapEntry(path = "src/Repo2.kt", score = 1.0, symbols = listOf("Repo2Class")))
service(es, l3, CountingIndexer(repoEntries), probe, root = "/repo")
.indexAndRecord(SessionId("session-collide-repo"), "/repo")
service(es, l3, CountingIndexer(repo2Entries), probe, root = "/repo2")
.indexAndRecord(SessionId("session-collide-repo2"), "/repo2")
// existsByTurnIdPrefix with the delimiter must not match the other root.
assertTrue(l3.existsByTurnIdPrefix("repomap:/repo:"), "tag for /repo should exist")
assertTrue(l3.existsByTurnIdPrefix("repomap:/repo2:"), "tag for /repo2 should exist")
// The retriever scoped to /repo must return only /repo's entry, never /repo2's.
val hits = L3RepoKnowledgeRetriever(embedder = embedder, l3MemoryStore = l3, repoRoot = "/repo")
.retrieve(SessionId("session-collide-query"), "anything", k = 10)
assertTrue(hits.isNotEmpty(), "retriever for /repo should find /repo entries")
val text = hits.joinToString(" ") { it.text }
assertTrue(text.contains("src/Repo.kt"), "should contain /repo entry; got: $text")
assertFalse(text.contains("src/Repo2.kt"), "must NOT contain /repo2 entry (prefix collision); got: $text")
}
}
@@ -75,6 +75,42 @@ class RepoMapIndexerTest {
assertFalse(entry.symbols.contains("help"), "indented inner method must not be matched")
}
@Test
fun `extracts C# (Godot Mono) top-level type symbols`(@TempDir root: Path) {
root.resolve("Player.cs").writeText(
"""
using Godot;
namespace Game;
public sealed partial class Player : CharacterBody2D
{
private int _health = 100;
public void TakeDamage(int amount)
{
_health -= amount;
}
}
internal interface IDamageable
{
void TakeDamage(int amount);
}
public enum State { Idle, Running }
""".trimIndent(),
)
val entry = RepoMapIndexer().index(root).single()
assertEquals("Player.cs", entry.path)
assertTrue(
entry.symbols.containsAll(listOf("Player", "IDamageable", "State")),
"missing symbols in: ${entry.symbols}",
)
assertFalse(entry.symbols.contains("TakeDamage"), "method/field bodies must not be matched")
}
@Test
fun `extracts markdown headings as symbols`(@TempDir root: Path) {
root.resolve("design.md").writeText(
@@ -1,18 +1,23 @@
package com.correx.apps.server.narration
import com.correx.core.approvals.ApprovalOutcome
import com.correx.core.approvals.ApprovalStatus
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.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ApprovalDecisionId
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
@@ -24,10 +29,12 @@ import com.correx.core.router.ChatMode
import com.correx.core.router.RouterFacade
import com.correx.core.router.model.NarrationTrigger
import com.correx.core.router.model.RouterResponse
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.runBlocking
@@ -231,4 +238,133 @@ class NarrationSubscriberTest {
"instruction must include the source",
)
}
/**
* Router facade that blocks the lane worker on the first narration until [release] completes,
* letting the test enqueue further events (and resolve the pause) while the pause narration is
* still pending in the channel.
*/
private class GatingRouterFacade : RouterFacade {
val calls = CopyOnWriteArrayList<NarrationTrigger>()
val release = CompletableDeferred<Unit>()
// Signals that the worker has pulled the first (warmup) trigger and is now blocked,
// so any narrations enqueued afterwards are guaranteed to still be queued.
val blocked = CompletableDeferred<Unit>()
private var first = true
override suspend fun onUserInput(sessionId: SessionId, input: String, mode: ChatMode): RouterResponse =
error("unused in narration tests")
override suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger) {
if (first) {
first = false
blocked.complete(Unit)
release.await()
}
calls.add(trigger)
}
}
private fun approvalRequested(requestId: String): ApprovalRequestedEvent = ApprovalRequestedEvent(
requestId = ApprovalRequestId(requestId),
tier = Tier.T2,
validationReportId = ValidationReportId("vr-1"),
riskSummaryId = null,
sessionId = sessionId,
stageId = StageId("write_script"),
projectId = null,
toolName = "file_write",
preview = "--- a/x.sh\n+++ b/x.sh",
)
private fun approvalResolved(requestId: String): ApprovalDecisionResolvedEvent = ApprovalDecisionResolvedEvent(
decisionId = ApprovalDecisionId("dec-1"),
requestId = ApprovalRequestId(requestId),
outcome = ApprovalOutcome.APPROVED,
status = ApprovalStatus.COMPLETED,
tier = Tier.T2,
resolutionTimestamp = timestamp,
reason = null,
)
@Test
fun `resolved approval drops the still-queued pause narration`(): Unit = runBlocking {
val facade = GatingRouterFacade()
NarrationSubscriber(fakeStore, facade, scope).start()
while (liveFlow.subscriptionCount.value == 0) yield()
liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L))
// Warmup narration the worker will block on, keeping later narrations queued.
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L))
// Wait until the worker is parked on the warmup before enqueuing the pause, so the pause
// is provably still queued (not yet delivered) when its approval resolves.
withTimeout(2_000L) { facade.blocked.await() }
// Pause is enqueued, then its approval resolves before the worker can drain it.
liveFlow.emit(storedEvent(approvalRequested("req-1"), seq = 3L))
liveFlow.emit(storedEvent(approvalResolved("req-1"), seq = 4L))
// A current, unrelated narration arrives after resolution and must still surface.
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("b"), TransitionId("t2")), seq = 5L))
// Let the (single, FIFO) collector drain its buffer so the resolve is handled pre-release.
delay(100L)
// Release the worker; it drains the warmup, skips the stale pause, narrates the second stage.
facade.release.complete(Unit)
withTimeout(2_000L) { while (facade.calls.size < 2) yield() }
delay(100L) // allow a stray (stale) pause narration to arrive if the drop failed
assertEquals(listOf("stage_completed", "stage_completed"), facade.calls.map { it.kind })
assertTrue(facade.calls.none { it.kind == "paused" }, "stale pause narration must be dropped")
}
@Test
fun `unrelated approval resolution leaves the pending pause intact`(): Unit = runBlocking {
val facade = GatingRouterFacade()
NarrationSubscriber(fakeStore, facade, scope).start()
while (liveFlow.subscriptionCount.value == 0) yield()
liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L))
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L))
withTimeout(2_000L) { facade.blocked.await() }
// Pause for req-1, but a *different* request (req-2) resolves: req-1's pause must survive.
liveFlow.emit(storedEvent(approvalRequested("req-1"), seq = 3L))
liveFlow.emit(storedEvent(approvalResolved("req-2"), seq = 4L))
delay(100L)
facade.release.complete(Unit)
withTimeout(2_000L) { while (facade.calls.size < 2) yield() }
assertEquals(listOf("stage_completed", "paused"), facade.calls.map { it.kind })
}
@Test
fun `resume drops every queued pause narration for the session`(): Unit = runBlocking {
val facade = GatingRouterFacade()
NarrationSubscriber(fakeStore, facade, scope).start()
while (liveFlow.subscriptionCount.value == 0) yield()
liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L))
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L))
withTimeout(2_000L) { facade.blocked.await() }
// A non-approval pause (session-scoped) plus an approval pause, both superseded by resume.
liveFlow.emit(
storedEvent(OrchestrationPausedEvent(sessionId, StageId("a"), "USER_REQUESTED"), seq = 3L),
)
liveFlow.emit(storedEvent(approvalRequested("req-1"), seq = 4L))
liveFlow.emit(storedEvent(OrchestrationResumedEvent(sessionId, StageId("a")), seq = 5L))
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("b"), TransitionId("t2")), seq = 6L))
delay(100L)
facade.release.complete(Unit)
withTimeout(2_000L) { while (facade.calls.size < 2) yield() }
delay(100L)
assertEquals(listOf("stage_completed", "stage_completed"), facade.calls.map { it.kind })
assertTrue(facade.calls.none { it.kind == "paused" }, "resume must drop all queued pauses")
}
}
@@ -316,6 +316,32 @@ class ServerMessageSerializationTest {
assertEquals("i1", (decoded as ClientMessage.DiscardIdea).ideaId)
}
@Test
fun `PromoteIdea decodes from the client wire format`() {
val wire = """{"type":"com.correx.apps.server.protocol.ClientMessage.PromoteIdea","ideaId":"i1"}"""
val decoded = ProtocolSerializer.decodeClientMessage(wire)
assert(decoded is ClientMessage.PromoteIdea) { "expected PromoteIdea, got $decoded" }
assertEquals("i1", (decoded as ClientMessage.PromoteIdea).ideaId)
}
@Test
fun `FileList encodes type file_list and paths`() {
val msg = ServerMessage.FileList(sessionId = SessionId("s1"), paths = listOf("README.md", "src/Main.kt"))
val jsonStr = ProtocolSerializer.encodeServerMessage(msg)
assert(jsonStr.contains("\"type\":\"file.list\"")) { "expected type=file.list" }
val decoded = json.decodeFromString<ServerMessage.FileList>(jsonStr)
assertEquals(listOf("README.md", "src/Main.kt"), decoded.paths)
}
@Test
fun `ListFiles decodes from the client wire format`() {
val wire = """{"type":"com.correx.apps.server.protocol.ClientMessage.ListFiles","sessionId":"s1"}"""
val decoded = ProtocolSerializer.decodeClientMessage(wire)
assert(decoded is ClientMessage.ListFiles) { "expected ListFiles, got $decoded" }
assertEquals("s1", (decoded as ClientMessage.ListFiles).sessionId.value)
}
@Test
fun `ClarificationResponse decodes from the client wire format`() {
val wire = """
@@ -0,0 +1,63 @@
package com.correx.apps.server.research
import com.correx.core.events.EventDispatcher
import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.types.SessionId
import com.correx.infrastructure.persistence.InMemoryEventStore
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class SourceListApprovalTest {
private val sessionId = SessionId("s1")
private fun grants(store: InMemoryEventStore, session: SessionId = sessionId): List<EgressHostsGrantedEvent> =
store.read(session).map { it.payload }.filterIsInstance<EgressHostsGrantedEvent>()
@Test
fun `emits exactly one EgressHostsGrantedEvent with the extracted hosts for the session`() = runTest {
val store = InMemoryEventStore()
val granted = approveSourceList(
dispatcher = EventDispatcher(store),
sessionId = sessionId,
urls = listOf(
"https://example.com/a",
"https://example.com/b",
"https://docs.kotlinlang.org/spec",
),
)
assertEquals(setOf("example.com", "docs.kotlinlang.org"), granted)
val emitted = grants(store)
assertEquals(1, emitted.size)
val event = emitted.single()
assertEquals(sessionId, event.sessionId)
assertEquals(setOf("example.com", "docs.kotlinlang.org"), event.hosts)
assertEquals(BATCH_SOURCE_LIST_REASON, event.reason)
}
@Test
fun `grant is recorded under the right session`() = runTest {
val store = InMemoryEventStore()
approveSourceList(EventDispatcher(store), sessionId, listOf("https://only-mine.com/x"))
assertEquals(1, grants(store).size)
assertTrue(grants(store, SessionId("other")).isEmpty())
}
@Test
fun `no parseable host emits no event`() = runTest {
val store = InMemoryEventStore()
val granted = approveSourceList(
dispatcher = EventDispatcher(store),
sessionId = sessionId,
urls = listOf("/relative", "just text", ""),
)
assertTrue(granted.isEmpty())
assertTrue(grants(store).isEmpty())
}
}
@@ -0,0 +1,69 @@
package com.correx.apps.server.research
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class SourceListHostsTest {
@Test
fun `distinct hosts are extracted from multiple urls`() {
val hosts = SourceListHosts.extract(
listOf(
"https://example.com/a",
"https://docs.rust-lang.org/book",
"http://api.github.com/repos",
),
)
assertEquals(setOf("example.com", "docs.rust-lang.org", "api.github.com"), hosts)
}
@Test
fun `duplicate hosts collapse`() {
val hosts = SourceListHosts.extract(
listOf(
"https://example.com/one",
"https://example.com/two",
"http://example.com/three",
),
)
assertEquals(setOf("example.com"), hosts)
}
@Test
fun `ports are stripped`() {
val hosts = SourceListHosts.extract(listOf("https://example.com:8443/path"))
assertEquals(setOf("example.com"), hosts)
}
@Test
fun `host case is normalised to lower case`() {
val hosts = SourceListHosts.extract(
listOf("https://Example.COM/a", "https://EXAMPLE.com/b"),
)
assertEquals(setOf("example.com"), hosts)
}
@Test
fun `unparseable and relative entries are ignored`() {
val hosts = SourceListHosts.extract(
listOf(
"/local/path",
"not a url at all",
"ftp ://broken",
"",
"https://kept.com/x",
),
)
assertEquals(setOf("kept.com"), hosts)
}
@Test
fun `dossier source lines yield their leading url host`() {
val hosts = SourceListHosts.extract(
listOf("https://example.com/x — explains Y; bears on sub-question 2"),
)
assertEquals(setOf("example.com"), hosts)
assertTrue("example.com" in hosts)
}
}
@@ -0,0 +1,482 @@
package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule
import com.correx.apps.server.configureServer
import com.correx.apps.server.registry.ProviderRegistry
import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.apps.server.registry.WorkflowSummary
import com.correx.apps.server.undo.SessionUndoService
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.context.builder.DefaultContextPackBuilder
import com.correx.core.context.compression.DefaultContextCompressor
import com.correx.core.events.EventDispatcher
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId
import com.correx.core.inference.InferenceProjector
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRepository
import com.correx.core.inference.ProviderHealth
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationProjector
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.kernel.orchestration.OrchestratorEngines
import com.correx.core.kernel.orchestration.OrchestratorRepositories
import com.correx.core.kernel.retry.DefaultRetryCoordinator
import com.correx.core.events.types.ProviderId
import com.correx.core.risk.DefaultRiskAssessor
import com.correx.core.router.model.RouterConfig
import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.infrastructure.InfrastructureModule
import com.correx.infrastructure.inference.commons.UnavailableProbe
import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.infrastructure.tools.FileEditConfig
import com.correx.infrastructure.tools.FileReadConfig
import com.correx.infrastructure.tools.FileWriteConfig
import com.correx.infrastructure.tools.ShellConfig
import com.correx.infrastructure.tools.ToolConfig
import io.ktor.client.HttpClient
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class TaskRoutesTest {
private val testJson = Json { ignoreUnknownKeys = true }
private suspend fun HttpResponse.json(): JsonObject =
testJson.parseToJsonElement(bodyAsText()) as JsonObject
private suspend fun HttpClient.createTask(project: String = "demo"): JsonObject =
post("/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"project":"$project","title":"JWT refresh","goal":"users stay authed"}""")
}.json()
@Test
fun `POST creates a task and GET lists and fetches it`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
val created = client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody(
"""{"project":"demo","title":"JWT refresh","goal":"stay authed",
"acceptanceCriteria":["rotates"],"affectedPaths":["auth/**"]}""",
)
}
assertEquals(HttpStatusCode.Created, created.status)
val body = created.json()
assertEquals("demo-1", body["id"]!!.jsonPrimitive.content)
assertEquals("TODO", body["status"]!!.jsonPrimitive.content)
assertEquals("rotates", body["acceptanceCriteria"]!!.jsonArray.single().jsonPrimitive.content)
val list = client.get("/tasks?project=demo")
assertEquals(HttpStatusCode.OK, list.status)
val rows = testJson.parseToJsonElement(list.bodyAsText()) as JsonArray
assertEquals(1, rows.size)
assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content)
// No project → cross-project board lists it too.
val all = client.get("/tasks")
assertEquals(HttpStatusCode.OK, all.status)
val allRows = testJson.parseToJsonElement(all.bodyAsText()) as JsonArray
assertEquals(1, allRows.size)
assertEquals("demo-1", (allRows[0] as JsonObject)["id"]!!.jsonPrimitive.content)
val fetched = client.get("/tasks/demo-1")
assertEquals(HttpStatusCode.OK, fetched.status)
assertEquals("JWT refresh", fetched.json()["title"]!!.jsonPrimitive.content)
}
}
@Test
fun `GET tasks reports dependency-graph ready and blockedBy`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask() // demo-1 (TODO, no deps)
client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"project":"demo","title":"Rotate keys","goal":"rotate signing keys"}""")
} // demo-2
// demo-2 DEPENDS_ON demo-1 (TASK kind inferred from the id).
client.post("/tasks/demo-2/links") {
contentType(ContentType.Application.Json)
setBody("""{"targetId":"demo-1","type":"DEPENDS_ON"}""")
}
val rows = testJson.parseToJsonElement(client.get("/tasks?project=demo").bodyAsText()) as JsonArray
val byId = rows.associate { (it as JsonObject)["id"]!!.jsonPrimitive.content to it }
val one = byId.getValue("demo-1") as JsonObject
val two = byId.getValue("demo-2") as JsonObject
// default-valued fields are omitted from JSON (encodeDefaults=false), which the TUI reads
// back as zero values — so tolerate absence here too.
fun readyOf(o: JsonObject) = o["ready"]?.jsonPrimitive?.content == "true"
fun blockedByOf(o: JsonObject): List<String> =
(o["blockedBy"] as? JsonArray)?.map { it.jsonPrimitive.content } ?: emptyList()
// demo-1: TODO with nothing in its way → ready, no blockers.
assertTrue(readyOf(one))
assertTrue(blockedByOf(one).isEmpty())
// demo-2: waits on the unfinished demo-1 → not ready, blockedBy = [demo-1].
assertTrue(!readyOf(two))
assertEquals(listOf("demo-1"), blockedByOf(two))
}
}
@Test
fun `lifecycle transitions move a task to DONE`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask()
val claimed = client.post("/tasks/demo-1/claim") {
contentType(ContentType.Application.Json)
setBody("""{"claimant":"claude-opus"}""")
}
assertEquals("IN_PROGRESS", claimed.json()["status"]!!.jsonPrimitive.content)
assertEquals("claude-opus", claimed.json()["claimant"]!!.jsonPrimitive.content)
val reviewed = client.post("/tasks/demo-1/submit-for-review").json()
assertEquals("IN_REVIEW", reviewed["status"]!!.jsonPrimitive.content)
val completed = client.post("/tasks/demo-1/complete").json()
assertEquals("DONE", completed["status"]!!.jsonPrimitive.content)
}
}
@Test
fun `PATCH edits fields and POST links and notes mutate the graph`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask()
val patched = client.patch("/tasks/demo-1") {
contentType(ContentType.Application.Json)
setBody("""{"title":"JWT refresh v2"}""")
}
assertEquals("JWT refresh v2", patched.json()["title"]!!.jsonPrimitive.content)
// No explicit kind: an ADR id infers to DOC.
val linked = client.post("/tasks/demo-1/links") {
contentType(ContentType.Application.Json)
setBody("""{"targetId":"adr-7","type":"IMPLEMENTS"}""")
}
val link = linked.json()["links"]!!.jsonArray.single() as JsonObject
assertEquals("adr-7", link["targetId"]!!.jsonPrimitive.content)
assertEquals("IMPLEMENTS", link["type"]!!.jsonPrimitive.content)
assertEquals("DOC", link["targetKind"]!!.jsonPrimitive.content)
val noted = client.post("/tasks/demo-1/notes") {
contentType(ContentType.Application.Json)
setBody("""{"body":"kickoff"}""")
}
val note = noted.json()["notes"]!!.jsonArray.single() as JsonObject
assertEquals("kickoff", note["body"]!!.jsonPrimitive.content)
assertEquals("HUMAN", note["author"]!!.jsonPrimitive.content)
// Unlink via query params clears the edge.
val unlinked = client.delete("/tasks/demo-1/links?targetId=adr-7&type=IMPLEMENTS")
assertTrue(unlinked.json()["links"]!!.jsonArray.isEmpty())
}
}
@Test
fun `DELETE soft-deletes and the task disappears from reads`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask()
assertEquals(HttpStatusCode.NoContent, client.delete("/tasks/demo-1").status)
assertEquals(HttpStatusCode.NotFound, client.get("/tasks/demo-1").status)
assertEquals(HttpStatusCode.NotFound, client.delete("/tasks/demo-1").status)
}
}
@Test
fun `GET tasks with q returns only ranked search hits`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask() // demo-1: "JWT refresh" / "users stay authed"
client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"project":"demo","title":"Rotate keys","goal":"rotate signing keys"}""")
}
val res = client.get("/tasks?q=jwt")
assertEquals(HttpStatusCode.OK, res.status)
val rows = testJson.parseToJsonElement(res.bodyAsText()) as JsonArray
assertEquals(1, rows.size)
assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content)
}
}
@Test
fun `POST sync-git advances tasks from commit messages`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask() // demo-1, TODO
val res = client.post("/tasks/sync-git") {
contentType(ContentType.Application.Json)
setBody("""{"commits":[{"sha":"abc1234567","message":"fixes demo-1"}]}""")
}
assertEquals(HttpStatusCode.OK, res.status)
val change = (res.json()["changes"] as JsonArray).single() as JsonObject
assertEquals("demo-1", change["key"]!!.jsonPrimitive.content)
assertEquals("TODO", change["from"]!!.jsonPrimitive.content)
assertEquals("DONE", change["to"]!!.jsonPrimitive.content)
assertEquals("DONE", client.get("/tasks/demo-1").json()["status"]!!.jsonPrimitive.content)
}
}
@Test
fun `GET tasks export renders the board as markdown`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask() // demo-1
val res = client.get("/tasks/export")
assertEquals(HttpStatusCode.OK, res.status)
val md = res.bodyAsText()
assertTrue(md.startsWith("# Tasks"))
assertTrue(md.contains("**demo-1**"))
}
}
@Test
fun `context bundle is served for a known task`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask()
val bundle = client.get("/tasks/demo-1/context")
assertEquals(HttpStatusCode.OK, bundle.status)
assertEquals("demo-1", bundle.json()["id"]!!.jsonPrimitive.content)
assertEquals(HttpStatusCode.NotFound, client.get("/tasks/demo-999/context").status)
}
}
@Test
fun `ready returns unblocked TODO tasks and blockers explains a wait`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask() // demo-1, TODO
client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"project":"demo","title":"second","goal":"g"}""")
}
// demo-2 depends on demo-1 (id infers to a TASK target).
client.post("/tasks/demo-2/links") {
contentType(ContentType.Application.Json)
setBody("""{"targetId":"demo-1","type":"DEPENDS_ON"}""")
}
val ready = client.get("/tasks?ready=true")
assertEquals(HttpStatusCode.OK, ready.status)
val rows = testJson.parseToJsonElement(ready.bodyAsText()) as JsonArray
assertEquals(1, rows.size)
assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content)
val blockers = client.get("/tasks/demo-2/blockers")
assertEquals(HttpStatusCode.OK, blockers.status)
val blk = testJson.parseToJsonElement(blockers.bodyAsText()) as JsonArray
assertEquals("demo-1", (blk.single() as JsonObject)["id"]!!.jsonPrimitive.content)
}
}
@Test
fun `GET history renders the lifecycle timeline`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask() // demo-1
client.post("/tasks/demo-1/claim") {
contentType(ContentType.Application.Json)
setBody("""{"claimant":"claude-opus"}""")
}
val res = client.get("/tasks/demo-1/history")
assertEquals(HttpStatusCode.OK, res.status)
val timeline = res.bodyAsText()
assertTrue(timeline.contains("created demo-1"))
assertTrue(timeline.contains("claimed by claude-opus"))
// An id that never existed has no history → 404.
assertEquals(HttpStatusCode.NotFound, client.get("/tasks/demo-999/history").status)
}
}
@Test
fun `POST rejects a duplicate title with 409 and force overrides`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask() // demo-1 "JWT refresh"
val dup = client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"project":"demo","title":"jwt refresh","goal":"g"}""")
}
assertEquals(HttpStatusCode.Conflict, dup.status)
val rows = testJson.parseToJsonElement(dup.bodyAsText()) as JsonArray
assertEquals("demo-1", (rows.single() as JsonObject)["id"]!!.jsonPrimitive.content)
val forced = client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"project":"demo","title":"jwt refresh","goal":"g","force":true}""")
}
assertEquals(HttpStatusCode.Created, forced.status)
}
}
@Test
fun `bad requests are rejected`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask()
assertEquals(HttpStatusCode.BadRequest, client.get("/tasks?status=NONSENSE").status)
assertEquals(HttpStatusCode.NotFound, client.get("/tasks/nope-1").status)
val badLink = client.post("/tasks/demo-1/links") {
contentType(ContentType.Application.Json)
setBody("""{"targetId":"x","type":"NONSENSE"}""")
}
assertEquals(HttpStatusCode.BadRequest, badLink.status)
}
}
// --- harness ----------------------------------------------------------------------------
private fun buildModule(tempDir: Path): ServerModule {
val eventStore: EventStore = InMemoryEventStore()
val artifactStore = object : ArtifactStore {
override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("noop")
override suspend fun get(id: ArtifactId): ByteArray? = null
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
}
val provider = InfrastructureModule.createLlamaCppProvider(
modelId = "test-model",
modelPath = "/dev/null",
baseUrl = "http://127.0.0.1:1",
)
val providerRegistry = InfrastructureModule.createProviderRegistry(listOf(provider))
val inferenceRouter = com.correx.core.inference.DefaultInferenceRouter(
providerRegistry,
com.correx.infrastructure.inference.FirstAvailableRoutingStrategy(),
)
val toolConfig = ToolConfig(
shell = ShellConfig(enabled = false, allowedExecutables = emptySet(), workingDir = tempDir),
fileRead = FileReadConfig(enabled = false, allowedPaths = setOf(tempDir)),
fileWrite = FileWriteConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir),
fileEdit = FileEditConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir),
)
val toolRegistry = InfrastructureModule.createToolRegistry(toolConfig)
val eventDispatcher = EventDispatcher(eventStore)
val toolExecutor = InfrastructureModule.createToolExecutor(
registry = toolRegistry,
eventDispatcher = eventDispatcher,
workDir = tempDir,
artifactStore = null,
)
val engines = OrchestratorEngines(
transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) },
contextPackBuilder = DefaultContextPackBuilder(DefaultContextCompressor()),
inferenceRouter = inferenceRouter,
validationPipeline = ValidationPipeline(validators = emptyList()),
approvalEngine = com.correx.core.approvals.domain.DefaultApprovalEngine(),
riskAssessor = DefaultRiskAssessor(),
toolRegistry = toolRegistry,
toolExecutor = toolExecutor,
)
val repositories = OrchestratorRepositories(
eventStore = eventStore,
inferenceRepository = InferenceRepository(DefaultEventReplayer(eventStore, InferenceProjector())),
orchestrationRepository = OrchestrationRepository(
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
),
sessionRepository = DefaultSessionRepository(
DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
),
artifactRepository = InfrastructureModule.createArtifactRepository(eventStore),
approvalRepository = InfrastructureModule.createApprovalRepository(eventStore),
)
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
retryCoordinator = DefaultRetryCoordinator(eventStore),
artifactStore = artifactStore,
tokenizer = provider.tokenizer,
decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore),
)
val routerFacade = InfrastructureModule.createRouterFacade(
eventStore = eventStore,
inferenceRouter = inferenceRouter,
config = RouterConfig(),
tokenizer = provider.tokenizer,
)
val noopProviderRegistry = object : ProviderRegistry {
override fun listAll() = emptyList<InferenceProvider>()
override suspend fun healthCheckAll() = emptyMap<ProviderId, ProviderHealth>()
}
val noopWorkflowRegistry = object : WorkflowRegistry {
override fun listAll(): List<WorkflowSummary> = emptyList()
override fun find(workflowId: String): WorkflowGraph? = null
}
val sessionUndoService = SessionUndoService(
eventStore = eventStore,
artifactStore = artifactStore,
bootRoots = setOf(tempDir),
)
return ServerModule(
orchestrator = orchestrator,
eventStore = eventStore,
artifactStore = artifactStore,
sessionRepository = repositories.sessionRepository,
workflowRegistry = noopWorkflowRegistry,
providerRegistry = noopProviderRegistry,
defaultOrchestrationConfig = OrchestrationConfig(sandboxRoot = tempDir),
routerFacade = routerFacade,
orchestrationRepository = repositories.orchestrationRepository,
approvalRepository = repositories.approvalRepository,
toolRegistry = toolRegistry,
sessionUndoService = sessionUndoService,
resourceProbe = UnavailableProbe,
)
}
}
@@ -0,0 +1,72 @@
package com.correx.apps.server.tasks
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.io.path.createDirectories
import kotlin.io.path.writeText
class FileTaskDocumentResolverTest {
@TempDir
lateinit var repo: Path
private fun writeAdr() {
val dir = repo.resolve("docs/decisions").also { it.createDirectories() }
dir.resolve("adr-0007-redis.md").writeText(
"""
---
name: "ADR 7 Redis Sessions"
description: "use redis for session storage"
---
# ADR 7: use redis for session storage
**status:** accepted
## decision
Redis backs session storage.
""".trimIndent(),
)
}
@Test
fun `resolves an ADR id padding-agnostically`() = runBlocking {
writeAdr()
val resolver = FileTaskDocumentResolver(repo)
val byShort = resolver.resolve("adr-7")
val byPadded = resolver.resolve("ADR-0007")
assertEquals("ADR 7 Redis Sessions", byShort?.title)
assertEquals("use redis for session storage", byShort?.excerpt)
assertEquals("docs/decisions/adr-0007-redis.md", byShort?.path)
assertEquals(byShort, byPadded)
}
@Test
fun `unknown ADR resolves to null`() = runBlocking {
writeAdr()
assertNull(FileTaskDocumentResolver(repo).resolve("adr-999"))
}
@Test
fun `resolves an explicit markdown path and falls back to heading and first paragraph`() = runBlocking {
repo.resolve("docs/notes").createDirectories()
repo.resolve("docs/notes/plan.md").writeText("# Plan\n\nThe plan body.\n")
val doc = FileTaskDocumentResolver(repo).resolve("docs/notes/plan.md")
assertEquals("Plan", doc?.title)
assertEquals("The plan body.", doc?.excerpt)
assertEquals("docs/notes/plan.md", doc?.path)
}
@Test
fun `non-document target resolves to null`() = runBlocking {
assertNull(FileTaskDocumentResolver(repo).resolve("ext-123"))
}
}
@@ -0,0 +1,30 @@
package com.correx.apps.server.tasks
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class GitCommandCommitReaderTest {
private val us = ""
private val rs = ""
@Test
fun `parseGitLog splits records and sha from message`() {
val raw = "abc123${us}fix auth-1: rotate$rs\ndef456${us}wip on auth-2$rs\n"
val commits = parseGitLog(raw)
assertEquals(2, commits.size)
assertEquals("abc123", commits[0].sha)
assertEquals("fix auth-1: rotate", commits[0].message)
assertEquals("def456", commits[1].sha)
assertEquals("wip on auth-2", commits[1].message)
}
@Test
fun `parseGitLog tolerates blank output and malformed records`() {
assertTrue(parseGitLog("").isEmpty())
assertTrue(parseGitLog("no-separator-here$rs").isEmpty())
}
}
@@ -0,0 +1,83 @@
package com.correx.apps.server.tasks
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.infrastructure.persistence.InMemoryEventStore
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.Clock
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import java.util.UUID
class TaskLinkResolverTest {
private val store: EventStore = InMemoryEventStore()
private suspend fun append(sessionId: SessionId, payload: com.correx.core.events.events.EventPayload) {
store.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = payload,
),
)
}
@Test
fun `session resolver projects status and intent and is null for an unknown id`() = runBlocking {
val sessionId = SessionId("sess-9")
append(sessionId, InitialIntentEvent(sessionId, "build auth"))
append(sessionId, WorkflowStartedEvent(sessionId, "role_pipeline", StageId("architect")))
append(sessionId, WorkflowCompletedEvent(sessionId, StageId("review"), totalStages = 3))
val resolver = SessionSummaryTaskSessionResolver(store)
val resolved = resolver.resolve("sess-9")!!
assertEquals("COMPLETED", resolved.status)
assertEquals("build auth", resolved.intent)
assertEquals("role_pipeline", resolved.workflowId)
assertNull(resolver.resolve("sess-unknown"))
}
@Test
fun `artifact resolver reads stage, session and content excerpt and is null for an unknown id`() = runBlocking {
val sessionId = SessionId("sess-9")
val artifactId = ArtifactId("art-1")
val contentHash = ArtifactId("hash-1")
val artifactStore = FakeArtifactStore(mapOf(contentHash to "approach: use redis".toByteArray()))
append(sessionId, ArtifactContentStoredEvent(artifactId, contentHash, sessionId, StageId("architect")))
append(sessionId, ArtifactCreatedEvent(artifactId, sessionId, StageId("architect"), schemaVersion = 1))
val resolver = EventStoreTaskArtifactResolver(store, artifactStore)
val resolved = resolver.resolve("art-1")!!
assertEquals("architect", resolved.stage)
assertEquals("sess-9", resolved.sessionId)
assertEquals("approach: use redis", resolved.excerpt)
assertNull(resolver.resolve("art-unknown"))
}
private class FakeArtifactStore(private val blobs: Map<ArtifactId, ByteArray>) : ArtifactStore {
override suspend fun put(bytes: ByteArray): ArtifactId = error("unused")
override suspend fun get(id: ArtifactId): ByteArray? = blobs[id]
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
}
}
@@ -0,0 +1,55 @@
package com.correx.apps.server.workspace
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.createDirectories
import kotlin.io.path.writeText
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class WorkspaceFilesTest {
@Test
fun `lists all file types by relative path, sorted`(@TempDir root: Path) {
root.resolve("src").createDirectories()
root.resolve("src/Main.kt").writeText("fun main() {}")
root.resolve("config.toml").writeText("a = 1") // not a source ext — must still appear
root.resolve("README.md").writeText("# hi")
val paths = WorkspaceFiles.list(root)
assertEquals(listOf("README.md", "config.toml", "src/Main.kt"), paths)
}
@Test
fun `skips ignored directories`(@TempDir root: Path) {
root.resolve(".git").createDirectories()
root.resolve(".git/HEAD").writeText("ref")
root.resolve("node_modules/pkg").createDirectories()
root.resolve("node_modules/pkg/index.js").writeText("x")
root.resolve("keep.txt").writeText("y")
val paths = WorkspaceFiles.list(root)
assertEquals(listOf("keep.txt"), paths)
assertFalse(paths.any { it.startsWith(".git") || it.startsWith("node_modules") })
}
@Test
fun `caps the result count`(@TempDir root: Path) {
repeat(10) { root.resolve("f$it.txt").writeText("x") }
val paths = WorkspaceFiles.list(root, limit = 3)
assertEquals(3, paths.size)
assertTrue(paths.all { it.endsWith(".txt") })
}
@Test
fun `missing root yields empty`(@TempDir root: Path) {
val missing = root.resolve("nope")
assertTrue(Files.notExists(missing))
assertEquals(emptyList(), WorkspaceFiles.list(missing))
}
}