feat(cas): content-addressed artifact store (steps 1–8)

New core:artifacts-store interface + infrastructure/artifacts-cas
implementation: segment files + SQLite index, Blake3 hashing,
group-commit fsync via flushBefore, recovery tail-scan, manual
compactor, and oldest-first disk-cap evictor.

Inference events now carry promptArtifactId / responseArtifactId;
orchestrators put bytes before emitting. SqliteEventStore wraps
its txn in artifactStore.flushBefore so segment data is fsynced
before the event commit, making the crash window non-corrupting
(TailScanner re-indexes orphan tail records on reopen).

Compactor and evictor are mutually exclusive via maintenanceMutex.
Step 9 (cloud sync) deferred to a later epic.

See docs/reviews/2026-05-18-cas-steps-1-8-review.md for the final
review.
This commit is contained in:
2026-05-18 12:22:38 +04:00
parent bbff73108e
commit 219e2c762e
60 changed files with 2042 additions and 165 deletions
@@ -16,6 +16,11 @@ fun activeSessionWidget(session: SessionSummary?): Paragraph {
val lines = buildList<Line> {
add(Line.from(Span.raw("stage: ${session.currentStage ?: "—"}")))
session.lastOutput?.let { add(Line.from(Span.raw("last output: $it"))) }
session.lastResponseText?.takeIf { it.isNotEmpty() }?.let { text ->
add(Line.from(Span.raw("")))
add(Line.from(Span.raw("response:")))
text.lineSequence().forEach { add(Line.from(Span.raw(it))) }
}
}
Text.from(lines)
}
@@ -100,15 +100,7 @@ object SessionsReducer {
)
is ServerMessage.StageCompleted -> touchSession(sessions, msg.sessionId.value, null, clock)
is ServerMessage.StageFailed -> touchSession(sessions, msg.sessionId.value, null, clock)
is ServerMessage.InferenceCompleted -> sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
s.copy(lastOutput = msg.outputSummary, lastEventAt = clock())
} else {
s
}
},
)
is ServerMessage.InferenceCompleted -> applyInferenceCompleted(sessions, msg, clock)
is ServerMessage.ToolCompleted -> sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
@@ -121,6 +113,24 @@ object SessionsReducer {
else -> sessions
}
private fun applyInferenceCompleted(
sessions: SessionsState,
msg: ServerMessage.InferenceCompleted,
clock: () -> Long,
): SessionsState = sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
s.copy(
lastOutput = msg.outputSummary,
lastResponseText = msg.responseText.takeIf { it.isNotEmpty() },
lastEventAt = clock(),
)
} else {
s
}
},
)
private fun touchSession(
sessions: SessionsState,
sessionId: String,
@@ -17,6 +17,7 @@ data class SessionSummary(
val lastEventAt: Long,
val currentStage: String?,
val lastOutput: String?,
val lastResponseText: String? = null,
)
data class ApprovalInfo(