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:
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
+70
@@ -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
|
||||
}
|
||||
}
|
||||
+18
@@ -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()
|
||||
}
|
||||
+19
@@ -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: h1–h3 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)
|
||||
}
|
||||
}
|
||||
|
||||
+68
-6
@@ -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(),
|
||||
)
|
||||
+36
@@ -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
|
||||
}
|
||||
}
|
||||
+53
@@ -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())
|
||||
}
|
||||
}
|
||||
+33
@@ -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)
|
||||
}
|
||||
}
|
||||
+107
@@ -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."))
|
||||
}
|
||||
}
|
||||
+33
@@ -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(
|
||||
|
||||
+136
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+72
@@ -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"))
|
||||
}
|
||||
}
|
||||
+30
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user