Skip to content

Commit 4dad82b

Browse files
committed
timestamp for past runs
1 parent b73a536 commit 4dad82b

14 files changed

Lines changed: 236 additions & 32 deletions

File tree

‎cli/src/main/kotlin/com/dk/zopf/cli/Commands.kt‎

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.dk.zopf.model.WorkflowIssue
55
import com.dk.zopf.runtime.UpdateCheck
66
import com.dk.zopf.runtime.issues
77
import com.dk.zopf.runtime.money
8+
import com.dk.zopf.runtime.stamp
89
import com.dk.zopf.runtime.updateChecksSilenced
910
import com.dk.zopf.store.AppPaths
1011
import com.dk.zopf.store.BrokenWorkflow
@@ -18,8 +19,6 @@ import java.io.PrintStream
1819
import java.nio.file.Path
1920
import java.time.Duration
2021
import java.time.Instant
21-
import java.time.ZoneId
22-
import java.time.format.DateTimeFormatter
2322

2423
val LIST_OPTIONS = setOf("workspace")
2524
val VALIDATE_OPTIONS = setOf("workspace")
@@ -195,10 +194,4 @@ internal fun WorkflowIssue.render(): String {
195194

196195
private fun RunRecord.cost(): Double? = nodes.mapNotNull { it.costUsd }.takeIf { it.isNotEmpty() }?.sum()
197196

198-
private fun String.readable(): String =
199-
runCatching {
200-
DateTimeFormatter
201-
.ofPattern("yyyy-MM-dd HH:mm")
202-
.withZone(ZoneId.systemDefault())
203-
.format(Instant.parse(this))
204-
}.getOrElse { this }
197+
private fun String.readable(): String = runCatching { stamp(Instant.parse(this)) }.getOrElse { this }

‎core/src/commonMain/kotlin/com/dk/zopf/runtime/Formatting.kt‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.dk.zopf.runtime
22

33
import java.time.Duration
4+
import java.time.Instant
5+
import java.time.ZoneId
6+
import java.time.format.DateTimeFormatter
47
import java.util.Locale
58

69
fun format(duration: Duration): String {
@@ -12,6 +15,23 @@ fun format(duration: Duration): String {
1215
}
1316
}
1417

18+
private val TimeOfDay = DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT)
19+
private val DateAndTime = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm", Locale.ROOT)
20+
21+
fun stamp(
22+
at: Instant,
23+
zone: ZoneId = ZoneId.systemDefault(),
24+
): String = DateAndTime.format(at.atZone(zone))
25+
26+
fun format(
27+
at: Instant,
28+
now: Instant = Instant.now(),
29+
zone: ZoneId = ZoneId.systemDefault(),
30+
): String {
31+
val local = at.atZone(zone)
32+
return if (local.toLocalDate() == now.atZone(zone).toLocalDate()) TimeOfDay.format(local) else stamp(at, zone)
33+
}
34+
1535
fun money(usd: Double): String = String.format(Locale.ROOT, "$%.4f", usd)
1636

1737
fun spend(

‎core/src/commonMain/kotlin/com/dk/zopf/runtime/NodeRun.kt‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,13 @@ class NodeRun(
180180
get() =
181181
if (nodeType != NodeType.AGENT) null else AgentProviders.of(provider ?: AgentProviderId.CLAUDE)
182182

183+
val agentLabel: String?
184+
get() =
185+
agent?.let { provider ->
186+
listOfNotNull(provider.id.label, model?.takeIf { provider.capabilities.modelSelection })
187+
.joinToString(" · ")
188+
}
189+
183190
val canTakeOver: Boolean
184191
get() = agent?.capabilities?.resumeInTerminal == true && sessionId != null && cwd != null
185192

‎core/src/commonMain/kotlin/com/dk/zopf/runtime/WorkflowRun.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ class WorkflowRun(
116116
nodeId = node.nodeId,
117117
type = node.nodeType,
118118
provider = node.provider,
119+
model = node.model,
119120
status = node.status.name,
120121
startedAt = node.startedAt.toString(),
121122
finishedAt = node.finishedAt?.toString(),
@@ -135,6 +136,7 @@ class WorkflowRun(
135136
record.nodes.forEach { archived ->
136137
val node = node(archived.nodeId) ?: return@forEach
137138
node.sessionId = archived.sessionId
139+
node.model = archived.model
138140
node.costUsd = archived.costUsd
139141
node.exitCode = archived.exitCode
140142
node.command = archived.command

‎core/src/commonMain/kotlin/com/dk/zopf/store/RunArchive.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ data class NodeRunRecord(
3434
val nodeId: String,
3535
val type: NodeType,
3636
val provider: AgentProviderId? = null,
37+
val model: String? = null,
3738
val status: String,
3839
val startedAt: String,
3940
val finishedAt: String? = null,

‎core/src/commonTest/kotlin/com/dk/zopf/runtime/AgentNodeTest.kt‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,28 @@ class NodeRunTest {
293293
return run().apply { ClaudeEvents.parseAll(text).forEach(::consume) }
294294
}
295295

296+
private fun labelled(
297+
provider: AgentProviderId?,
298+
model: String?,
299+
type: NodeType = NodeType.AGENT,
300+
) = NodeRun("r", "demo", "analyze", "Analyze", type, Paths.get("/tmp"), provider = provider)
301+
.also { it.showing(model = model) }
302+
303+
@Test
304+
fun `a run is labelled with the cli and the model it took`() {
305+
val rows =
306+
listOf(
307+
labelled(AgentProviderId.CLAUDE, "claude-opus-5") to "Claude Code · claude-opus-5",
308+
labelled(AgentProviderId.CODEX, null) to "codex",
309+
labelled(AgentProviderId.DSH, "deepseek-chat") to "DeepSeek Harness",
310+
labelled(null, "claude-opus-5", NodeType.SHELL) to null,
311+
)
312+
313+
rows.forEach { (run, expected) ->
314+
assertEquals(expected, run.agentLabel, "${run.nodeType} ${run.provider}")
315+
}
316+
}
317+
296318
@Test
297319
fun `a schema'd turn answers in fields and keeps the object`() {
298320
val output = replayFixture("/claude-structured-stream.jsonl").output()

‎core/src/commonTest/kotlin/com/dk/zopf/runtime/RunArchiveTest.kt‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.dk.zopf.runtime
22

3+
import com.dk.zopf.model.AgentProviderId
34
import com.dk.zopf.model.NodeType
45
import com.dk.zopf.model.Workflow
56
import com.dk.zopf.model.WorkflowEdge
@@ -66,6 +67,19 @@ class RunHistoryTest {
6667
)
6768
}
6869

70+
@Test
71+
fun `an archived agent node comes back with its cli and model`() {
72+
val run = WorkflowRun("run-1", "demo", null, isInteractive = false)
73+
run.nodes +=
74+
NodeRun("run-1:analyze", "demo", "analyze", "Analyze", NodeType.AGENT, null, provider = AgentProviderId.CODEX)
75+
.showing(status = RunStatus.SUCCEEDED, model = "gpt-5.5")
76+
77+
val restored = WorkflowRun.restored(run.record(), Restore.SETTLED).node("analyze")
78+
79+
assertEquals(AgentProviderId.CODEX, restored?.provider)
80+
assertEquals("gpt-5.5", restored?.model)
81+
}
82+
6983
@Test
7084
fun `replaying a transcript leaves the verdict alone`() {
7185
val archiveRoot = tempDir()

‎core/src/commonTest/kotlin/com/dk/zopf/runtime/RuntimeBasicsTest.kt‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import com.dk.zopf.model.modelOptions
1313
import com.dk.zopf.model.withDefaultsFrom
1414
import com.dk.zopf.store.AppSettings
1515
import java.nio.file.Paths
16+
import java.time.ZoneId
17+
import java.time.ZonedDateTime
1618
import kotlin.io.path.readText
1719
import kotlin.test.Test
1820
import kotlin.test.assertContains
@@ -552,4 +554,22 @@ class RunSummaryTest {
552554

553555
assertEquals("Running · 3/4 · b", run.summary())
554556
}
557+
558+
@Test
559+
fun `a run is stamped with when it started`() {
560+
val zone = ZoneId.of("Europe/Berlin")
561+
val now = ZonedDateTime.of(2026, 9, 10, 18, 0, 0, 0, zone)
562+
val rows =
563+
listOf(
564+
ZonedDateTime.of(2026, 9, 10, 14, 32, 0, 0, zone) to "14:32",
565+
ZonedDateTime.of(2026, 9, 10, 0, 5, 0, 0, zone) to "00:05",
566+
ZonedDateTime.of(2026, 9, 9, 23, 55, 0, 0, zone) to "2026-09-09 23:55",
567+
ZonedDateTime.of(2026, 1, 5, 8, 0, 0, 0, zone) to "2026-01-05 08:00",
568+
ZonedDateTime.of(2025, 12, 31, 23, 59, 0, 0, zone) to "2025-12-31 23:59",
569+
)
570+
571+
rows.forEach { (started, expected) ->
572+
assertEquals(expected, format(started.toInstant(), now.toInstant(), zone), started.toString())
573+
}
574+
}
555575
}

‎shared/src/commonMain/kotlin/com/dk/zopf/ui/AppState.kt‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ enum class DialogRequest { NEW_WORKFLOW, NEW_CONNECTOR }
5959

6060
class AppState(
6161
val registry: WorkspaceRegistry = WorkspaceRegistry(),
62+
private val archiveRoot: Path = AppPaths.runsDir,
6263
) {
6364
var workspaces by mutableStateOf<List<OpenWorkspace>>(emptyList())
6465
private set
@@ -103,7 +104,7 @@ class AppState(
103104
RunRegistry(
104105
scope,
105106
settings,
106-
archiveRoot = AppPaths.runsDir,
107+
archiveRoot = archiveRoot,
107108
notifier = MacNotifier(),
108109
isForeground = { windowFocused },
109110
onActivate = { runId -> onActivateRun(runId) },
@@ -183,7 +184,7 @@ class AppState(
183184
scope.launch(Dispatchers.IO) {
184185
val keep = settings.current.keepRuns
185186
if (keep <= 0) return@launch
186-
val gone = RunArchive.prune(keep = keep)
187+
val gone = RunArchive.prune(root = archiveRoot, keep = keep)
187188
if (gone.isEmpty()) return@launch
188189

189190
gone.forEach { RunArchive.delete(it) }

‎shared/src/commonMain/kotlin/com/dk/zopf/ui/runs/RunConsole.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ private fun RunHeader(
141141
Text(
142142
buildString {
143143
append(run.workflowName)
144+
run.agentLabel?.let { append(" · $it") }
144145
append(" · ")
145146
append(run.status.label)
146147
append(" · ")

0 commit comments

Comments
 (0)