From 085d226f682946a99ae03267e5363821afbc2f3d Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Sun, 14 Jun 2026 22:48:05 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Show lane lifecycle metrics across dashboard surfaces","authority":"manual"} --- README.md | 20 +- .../Sources/DecodexApp/AccountPanelView.swift | 770 ++++++++++++-- .../DecodexApp/DecodexServerBridge.swift | 51 +- .../DecodexApp/OperatorSnapshotModels.swift | 206 ++++ .../DecodexAppTests/AccountModelTests.swift | 127 +++ .../DecodexServerBridgeTests.swift | 10 + apps/decodex/src/agent/app_server.rs | 7 + .../src/orchestrator/operator_dashboard.html | 985 +++++++++++++++--- apps/decodex/src/orchestrator/status.rs | 343 +++++- apps/decodex/src/orchestrator/tests.rs | 3 +- .../tests/operator/status/dashboard.rs | 356 +++++-- .../tests/operator/status/history.rs | 123 ++- .../tests/operator/status/http.rs | 17 +- .../tests/operator/status/running_lanes.rs | 5 + .../tests/operator/status/text.rs | 2 - .../tests/operator/status_support.rs | 184 ++-- apps/decodex/src/orchestrator/types.rs | 58 +- apps/decodex/src/state.rs | 2 +- apps/decodex/src/state/internal.rs | 179 +++- apps/decodex/src/state/models.rs | 10 + apps/decodex/src/state/store.rs | 34 + apps/decodex/src/state/tests.rs | 66 +- dev/operator-dashboard-mock.mjs | 410 +++++++- docs/reference/operator-control-plane.md | 21 +- 24 files changed, 3486 insertions(+), 503 deletions(-) diff --git a/README.md b/README.md index 32faf1872..bec3d8680 100644 --- a/README.md +++ b/README.md @@ -301,21 +301,35 @@ dashboard controls flow through the `/dashboard/control` WebSocket. The HTTP sur kept to dashboard pages/assets, `GET /livez`, and the local account-control API used by Decodex App. -For dashboard UI development, use the mock operator dashboard server: +For dashboard UI development, use one mock operator dashboard server for both the +browser dashboard and Decodex App preview: ```sh node dev/operator-dashboard-mock.mjs --listen-address 127.0.0.1:57399 node dev/operator-dashboard-mock.mjs --listen-address 127.0.0.1:57399 --use-codex-auth ``` +That single mock listener serves `GET /dashboard`, `GET /api/accounts`, and the +dashboard authority WebSocket at `ws://127.0.0.1:57399/dashboard/control`. When +previewing Decodex App against the mock, point the App at the same base URL with +`DECODEX_APP_SERVER_URL=http://127.0.0.1:57399`; do not start a second mock server for +the App. This environment variable is authoritative: when it is set, Decodex App +connects only to that server and reports an error instead of falling back to the +default `127.0.0.1:8192` runtime. + +```sh +DECODEX_APP_SERVER_URL=http://127.0.0.1:57399 open -n target/decodex-app/Decodex.app +``` + Use hidden `decodex serve --dev --listen-address ` only when developing local account/app snapshot APIs against real runtime state while explicitly avoiding scheduler activity. Dev mode deliberately does not register projects, poll Linear, dispatch work, or accept `--config`. Decodex App's normal fallback server is ordinary `decodex serve --listen-address 127.0.0.1:8192`; the CLI owns the default scheduler cadences. App launch connects to an -existing live default listener instead of starting a duplicate server. For -dashboard-only UI work, prefer the mock server above. +existing live default listener instead of starting a duplicate server only when +`DECODEX_APP_SERVER_URL` is unset. For dashboard and App preview UI work, prefer the +single mock server above. The dashboard semantics and local-vs-external state boundary live in `docs/reference/operator-control-plane.md`. diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index a79c33454..1e58ffac0 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -1864,7 +1864,7 @@ struct AccountRunChipView: View { .foregroundStyle(tint.opacity(colorScheme == .dark ? 0.88 : 0.76)) .frame(width: AccountRunChipLayout.iconWidth) - Text(run.compactTitle) + Text(chipTitle) .font(PanelFont.runChipTitle) .foregroundStyle(PanelPalette.primaryText(colorScheme).opacity(0.92)) .lineLimit(1) @@ -1929,6 +1929,10 @@ struct AccountRunChipView: View { return "play.fill" } + private var chipTitle: String { + panelTrimmed(run.issueIdentifier) ?? "Run" + } + private var tint: Color { if run.hasAttentionTone { return PanelPalette.warning(colorScheme) @@ -2833,45 +2837,102 @@ struct NoticeView: View { } } +private struct OperatorLaneReadoutWidthKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +private struct OperatorLaneReadoutWidthReader: View { + var body: some View { + GeometryReader { proxy in + Color.clear + .preference(key: OperatorLaneReadoutWidthKey.self, value: proxy.size.width) + } + } +} + struct OperatorLanePopoverView: View { let run: OperatorRunStatus let currentTime: Date + @State private var readoutWidth: CGFloat = 0 var body: some View { - VStack(alignment: .leading, spacing: 6) { - header + VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 3) { + if let projectTitle { + measuredReadout { + OperatorLaneReadoutRow( + title: "Project", + items: [OperatorLaneReadoutItem(label: "project", value: projectTitle)] + ) + } + } - if hasReadoutContent { - OperatorLaneReadoutDivider() + measuredReadout { + OperatorLaneReadoutRow( + title: "Activity", + items: [OperatorLaneReadoutItem(label: nil, value: currentSummary)] + ) + } + + if let modelProgress { + measuredReadout { + OperatorLaneProgressReadoutRow( + title: modelProgress.title, + percent: modelProgress.percent, + elapsed: modelProgress.elapsed, + total: modelProgress.total, + barShare: modelProgress.barShare + ) + } + } } - if let modelBucket { - OperatorLaneProgressReadoutRow( - title: "Model", - percent: bucketPercent(modelBucket), - elapsed: formatActivityDuration(bucketWallSeconds(modelBucket)) ?? "0s", - total: formatActivityDuration(totalWallSeconds) ?? "0s", - barShare: bucketShare(modelBucket) - ) - if detailBuckets.isEmpty == false || contextReadoutRows.isEmpty == false { + if modelProgress != nil, + totalOverviewMetrics.isEmpty == false + || detailBuckets.isEmpty == false + || lifecycleTableRows.isEmpty == false + { + alignedReadout { OperatorLaneReadoutDivider() } } VStack(alignment: .leading, spacing: 3) { + if totalOverviewMetrics.isEmpty == false { + measuredReadout { + OperatorTotalMetricsView(metrics: totalOverviewMetrics) + } + } + ForEach(detailBuckets) { bucket in - OperatorLaneReadoutRow(title: rawPanelToken(bucket.name), items: bucketReadoutItems(bucket)) + measuredReadout { + OperatorLaneReadoutRow(title: rawPanelToken(bucket.name), items: bucketReadoutItems(bucket)) + } } - if contextReadoutRows.isEmpty == false { - OperatorLaneReadoutDivider() - ForEach(contextReadoutRows) { row in - OperatorLaneReadoutRow(title: row.title, items: row.items) + if lifecycleTableRows.isEmpty == false { + if totalOverviewMetrics.isEmpty == false || detailBuckets.isEmpty == false { + alignedReadout { + OperatorLaneReadoutDivider() + } + } + measuredReadout { + OperatorLifecycleTableView(rows: lifecycleTableRows) } } - if detailBuckets.isEmpty, contextReadoutRows.isEmpty, fallbackRunReadoutItems.isEmpty == false { - OperatorLaneReadoutRow(title: "Run", items: fallbackRunReadoutItems) + if detailBuckets.isEmpty, + totalOverviewMetrics.isEmpty, + lifecycleTableRows.isEmpty, + fallbackRunReadoutItems.isEmpty == false + { + measuredReadout { + OperatorLaneReadoutRow(title: "Run", items: fallbackRunReadoutItems) + } } } } @@ -2879,6 +2940,13 @@ struct OperatorLanePopoverView: View { .padding(.vertical, 7) .fixedSize(horizontal: true, vertical: false) .accessibilityLabel("Lane activity for \(run.compactTitle)") + .onPreferenceChange(OperatorLaneReadoutWidthKey.self) { width in + guard abs(width - readoutWidth) > 0.5 else { + return + } + + readoutWidth = width + } } private var activity: OperatorChildAgentActivity? { @@ -2908,21 +2976,34 @@ struct OperatorLanePopoverView: View { return rawPanelToken(label) } - private var header: some View { - OperatorLaneHeaderReadoutView( - status: currentSummary, - project: projectTitle - ) - } - private var projectTitle: String? { panelTrimmed(run.projectDisplayName) ?? panelTrimmed(run.projectID) } + private var alignedWidth: CGFloat? { + readoutWidth > 0 ? readoutWidth : nil + } + + private func measuredReadout( + @ViewBuilder _ content: () -> Content + ) -> some View { + content() + .background(OperatorLaneReadoutWidthReader()) + .frame(width: alignedWidth, alignment: .leading) + } + + private func alignedReadout( + @ViewBuilder _ content: () -> Content + ) -> some View { + content() + .frame(width: alignedWidth, alignment: .leading) + } + private var hasReadoutContent: Bool { - modelBucket != nil + modelProgress != nil + || totalOverviewMetrics.isEmpty == false || detailBuckets.isEmpty == false - || contextReadoutRows.isEmpty == false + || lifecycleTableRows.isEmpty == false || fallbackRunReadoutItems.isEmpty == false } @@ -2949,7 +3030,7 @@ struct OperatorLanePopoverView: View { value: "\(formatCompactCount(activity.outputTokensCumulative)) tok" ), OperatorLaneReadoutItem( - label: "tool calls", + label: "tools", value: formatCompactCount(activity.toolCallCount) ), ] @@ -2957,8 +3038,11 @@ struct OperatorLanePopoverView: View { if let largestOutput = activity.largestToolOutputBytes, largestOutput > 0 { items.append( OperatorLaneReadoutItem( - label: "largest output", - value: formatCompactBytes(largestOutput) + label: "max output", + value: formatLargestOutput( + bytes: largestOutput, + tool: activity.largestToolOutputTool + ) ) ) } @@ -2972,13 +3056,58 @@ struct OperatorLanePopoverView: View { } } + private var modelProgress: OperatorModelProgressReadout? { + if let lifecycleMetrics = run.lifecycleMetrics, + let modelSeconds = lifecycleModelSeconds(lifecycleMetrics.buckets) + { + let totalSeconds = max( + 1, + lifecycleMetrics.wallSeconds, + lifecycleMetrics.buckets.reduce(0) { $0 + max(0, $1.wallSeconds) }, + modelSeconds + ) + let share = CGFloat(modelSeconds) / CGFloat(totalSeconds) + + return OperatorModelProgressReadout( + title: "Inference", + percent: Int((Double(modelSeconds) / Double(totalSeconds) * 100).rounded()), + elapsed: formatActivityDuration(modelSeconds) ?? "0s", + total: formatActivityDuration(totalSeconds) ?? "0s", + barShare: min(1, max(0.02, share)) + ) + } + + guard let modelBucket else { + return nil + } + + return OperatorModelProgressReadout( + title: "Inference", + percent: bucketPercent(modelBucket), + elapsed: formatActivityDuration(bucketWallSeconds(modelBucket)) ?? "0s", + total: formatActivityDuration(totalWallSeconds) ?? "0s", + barShare: bucketShare(modelBucket) + ) + } + private var detailBuckets: [OperatorChildAgentBucket] { - orderedBuckets.filter { bucket in + guard run.lifecycleMetrics == nil else { + return [] + } + + return orderedBuckets.filter { bucket in bucket.name.caseInsensitiveCompare("Model") != .orderedSame + && detailBucketIsVisible(bucket) && bucketReadoutItems(bucket).isEmpty == false } } + private func detailBucketIsVisible(_ bucket: OperatorChildAgentBucket) -> Bool { + let normalizedName = bucket.name.lowercased() + + return normalizedName.contains("protocol") || normalizedName.contains("tracker") + } + private var orderedBuckets: [OperatorChildAgentBucket] { bucketRows.sorted { left, right in let leftPriority = bucketPriority(left.name) @@ -2999,51 +3128,227 @@ struct OperatorLanePopoverView: View { } } - private var contextReadoutRows: [OperatorLaneReadoutLine] { - let rows = [ - OperatorLaneReadoutLine(title: "Context", items: contextTokenReadoutItems), - OperatorLaneReadoutLine(title: "Tools", items: contextToolReadoutItems), - ] + private var totalOverviewMetrics: [OperatorTotalMetric] { + guard let lifecycleMetrics = run.lifecycleMetrics else { + return [] + } - return rows.filter { $0.items.isEmpty == false } + return [ + contextMetric(lifecycleMetrics), + toolsMetric(lifecycleMetrics), + trackerMetric(lifecycleMetrics), + protocolMetric(lifecycleMetrics), + ].compactMap { $0 } } - private var contextTokenReadoutItems: [OperatorLaneReadoutItem] { - guard let activity else { - return [] + private func contextMetric(_ metrics: OperatorLifecycleMetrics) -> OperatorTotalMetric? { + var items = [OperatorLaneReadoutItem]() + if metrics.inputTokensCumulative > 0 { + items.append(OperatorLaneReadoutItem(label: "input", value: formatCompactCount(metrics.inputTokensCumulative))) + } + if metrics.outputTokensCumulative > 0 { + items.append(OperatorLaneReadoutItem(label: "output", value: formatCompactCount(metrics.outputTokensCumulative))) } + let current = metrics.inputTokensCurrent ?? activity?.inputTokensCurrent + let peak = metrics.inputTokensPeak ?? activity?.inputTokensMax + if let current { + items.append(OperatorLaneReadoutItem(label: "current window", value: formatCompactCount(current))) + } + if let peak, peak != current { + items.append(OperatorLaneReadoutItem(label: "peak window", value: formatCompactCount(peak))) + } + + guard items.isEmpty == false else { + return nil + } + + return OperatorTotalMetric( + title: "Context", + items: items + ) + } + + private func toolsMetric(_ metrics: OperatorLifecycleMetrics) -> OperatorTotalMetric? { var items = [OperatorLaneReadoutItem]() - if let current = activity.inputTokensCurrent { - items.append(OperatorLaneReadoutItem(label: "current", value: "\(formatCompactCount(current)) tok")) + if metrics.toolCallCount > 0 { + items.append(OperatorLaneReadoutItem(label: "tools", value: formatCompactCount(metrics.toolCallCount))) } - if let peak = activity.inputTokensMax, peak != activity.inputTokensCurrent { - items.append(OperatorLaneReadoutItem(label: "peak", value: "\(formatCompactCount(peak)) tok")) + + if let largestOutput = metrics.largestToolOutputBytes, largestOutput > 0 { + items.append( + OperatorLaneReadoutItem( + label: "max output", + value: formatLargestOutput( + bytes: largestOutput, + tool: metrics.largestToolOutputTool + ) + ) + ) } - if activity.inputTokensCumulative > 0 { - items.append(OperatorLaneReadoutItem(label: "input", value: "\(formatCompactCount(activity.inputTokensCumulative)) tok")) + + guard items.isEmpty == false else { + return nil } - return items + return OperatorTotalMetric( + title: "Tools", + items: items + ) } - private var contextToolReadoutItems: [OperatorLaneReadoutItem] { - guard let activity else { - return [] + private func trackerMetric(_ metrics: OperatorLifecycleMetrics) -> OperatorTotalMetric? { + guard let bucket = lifecycleBucket(named: "Tracker", in: metrics.buckets) else { + return nil } var items = [OperatorLaneReadoutItem]() - if activity.toolCallCount > 0 { - items.append(OperatorLaneReadoutItem(label: "tool calls", value: formatCompactCount(activity.toolCallCount))) + if bucket.eventCount > 0 { + items.append(OperatorLaneReadoutItem(label: "events", value: formatCompactCount(bucket.eventCount))) } - if let largestOutput = activity.largestToolOutputBytes, largestOutput > 0 { - items.append(OperatorLaneReadoutItem(label: "largest output", value: formatCompactBytes(largestOutput))) + if bucket.toolCallCount > 0 { + items.append(OperatorLaneReadoutItem(label: "tools", value: formatCompactCount(bucket.toolCallCount))) } - if let largestTool = panelTrimmed(activity.largestToolOutputTool) { - items.append(OperatorLaneReadoutItem(label: "largest tool", value: largestTool)) + if bucket.outputBytes > 0 { + items.append(OperatorLaneReadoutItem(label: "output bytes", value: formatCompactBytes(bucket.outputBytes))) } - return items + guard items.isEmpty == false else { + return nil + } + + return OperatorTotalMetric( + title: "Tracker", + items: items + ) + } + + private func protocolMetric(_ metrics: OperatorLifecycleMetrics) -> OperatorTotalMetric? { + guard metrics.protocolEventCount > 0 || metrics.childEventCount > 0 else { + return nil + } + + var items = [OperatorLaneReadoutItem]() + if metrics.protocolEventCount > 0 { + items.append(OperatorLaneReadoutItem(label: "events", value: formatCompactCount(metrics.protocolEventCount))) + } + if metrics.childEventCount > 0 { + items.append(OperatorLaneReadoutItem(label: "child events", value: formatCompactCount(metrics.childEventCount))) + } + + return OperatorTotalMetric( + title: "Protocol", + items: items + ) + } + + private var lifecycleTableRows: [OperatorLifecycleTableRow] { + guard let lifecycleMetrics = run.lifecycleMetrics else { + return [] + } + + return lifecycleMetrics.phases.map { phase in + lifecycleTableRow( + stage: panelTrimmed(phase.label) + ?? panelTrimmed(phase.phase).map(rawPanelToken) + ?? "Phase", + attemptCount: phase.attemptCount, + wallSeconds: phase.wallSeconds, + buckets: phase.buckets, + inputTokens: phase.inputTokensCumulative, + outputTokens: phase.outputTokensCumulative, + toolCallCount: phase.toolCallCount, + largestOutputBytes: phase.largestToolOutputBytes, + largestOutputTool: phase.largestToolOutputTool + ) + } + } + + private func lifecycleTableRow( + stage: String, + attemptCount: Int, + wallSeconds: Int, + buckets: [OperatorLifecycleMetricBucket], + inputTokens: Int, + outputTokens: Int, + toolCallCount: Int, + largestOutputBytes: Int?, + largestOutputTool: String? + ) -> OperatorLifecycleTableRow { + let runtimeSeconds = lifecycleModelSeconds(buckets) ?? 0 + let runtime = runtimeShareParts( + seconds: runtimeSeconds, + totalSeconds: lifecycleWallSeconds( + wallSeconds: wallSeconds, + buckets: buckets, + runtimeSeconds: runtimeSeconds + ) + ) + let largestOutput = formatLargestOutput(bytes: largestOutputBytes, tool: largestOutputTool) + + return OperatorLifecycleTableRow( + stage: stage, + attempts: attemptCount > 0 ? formatCompactCount(attemptCount) : "-", + runtime: runtime.text, + inputTokens: inputTokens > 0 ? formatCompactCount(inputTokens) : "-", + outputTokens: outputTokens > 0 ? formatCompactCount(outputTokens) : "-", + toolCalls: toolCallCount > 0 ? formatCompactCount(toolCallCount) : "-", + largestOutput: largestOutput + ) + } + + private func formatLargestOutput(bytes: Int?, tool: String?) -> String { + guard let bytes, bytes > 0 else { + return "-" + } + guard let tool = panelTrimmed(tool) else { + return formatCompactBytes(bytes) + } + + return "\(formatCompactBytes(bytes))(\(tool))" + } + + private func lifecycleWallSeconds( + wallSeconds: Int, + buckets: [OperatorLifecycleMetricBucket], + runtimeSeconds: Int + ) -> Int { + max( + 1, + wallSeconds, + buckets.reduce(0) { $0 + max(0, $1.wallSeconds) }, + runtimeSeconds + ) + } + + private func runtimeShareParts( + seconds: Int, + totalSeconds: Int + ) -> (percent: String, elapsed: String, total: String, ratio: String, text: String) { + guard seconds > 0 else { + return ("-", "-", "-", "-", "-") + } + + let total = max(1, totalSeconds, seconds) + let percent = Int((Double(seconds) / Double(total) * 100).rounded()) + let elapsed = formatActivityDuration(seconds) ?? "0s" + let totalText = formatActivityDuration(total) ?? "0s" + let compactElapsed = elapsed.replacingOccurrences(of: " ", with: "") + let compactTotal = totalText.replacingOccurrences(of: " ", with: "") + let ratio = "\(compactElapsed)/\(compactTotal)" + return ("\(percent)%", elapsed, totalText, ratio, "\(ratio)(\(percent)%)") + } + + private func lifecycleModelSeconds(_ buckets: [OperatorLifecycleMetricBucket]) -> Int? { + buckets.first { bucket in + bucket.name.caseInsensitiveCompare("Model") == .orderedSame + }?.wallSeconds + } + + private func lifecycleBucket(named name: String, in buckets: [OperatorLifecycleMetricBucket]) -> OperatorLifecycleMetricBucket? { + buckets.first { bucket in + bucket.name.caseInsensitiveCompare(name) == .orderedSame + } } private var bucketRows: [OperatorChildAgentBucket] { @@ -3079,7 +3384,7 @@ struct OperatorLanePopoverView: View { } } else { if bucket.toolCallCount > 0 { - items.append(OperatorLaneReadoutItem(label: "tool calls", value: formatCompactCount(bucket.toolCallCount))) + items.append(OperatorLaneReadoutItem(label: "tools", value: formatCompactCount(bucket.toolCallCount))) } if bucket.outputBytes > 0 { items.append(OperatorLaneReadoutItem(label: "output bytes", value: formatCompactBytes(bucket.outputBytes))) @@ -3158,52 +3463,249 @@ struct OperatorLaneHeaderReadoutView: View { } } -struct OperatorLaneReadoutLine: Identifiable { +struct OperatorModelProgressReadout { + let title: String + let percent: Int + let elapsed: String + let total: String + let barShare: CGFloat +} + +struct OperatorTotalMetric: Identifiable { let title: String let items: [OperatorLaneReadoutItem] var id: String { title } + + var accessibilityText: String { + let itemText = items.map(\.accessibilityText).joined(separator: ", ") + + return itemText.isEmpty ? title : "\(title), \(itemText)" + } +} + +struct OperatorTotalMetricsView: View { + let metrics: [OperatorTotalMetric] + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Grid( + alignment: .leading, + horizontalSpacing: OperatorLaneReadoutLayout.totalValueLabelSpacing, + verticalSpacing: OperatorLaneReadoutLayout.itemRowSpacing + ) { + ForEach(metrics) { metric in + GridRow(alignment: .firstTextBaseline) { + OperatorLaneReadoutLabelView(title: metric.title) + .gridColumnAlignment(.leading) + ForEach(metricCells(for: metric)) { cell in + metricCell(cell) + } + } + } + } + .fixedSize(horizontal: true, vertical: false) + .accessibilityLabel(accessibilityText) + } + + private var accessibilityText: String { + metrics.map(\.accessibilityText).joined(separator: "; ") + } + + private func metricCells(for metric: OperatorTotalMetric) -> [OperatorTotalMetricGridCell] { + (0.. some View { + if cell.isPlaceholder { + Color.clear + .frame(width: 1, height: 1) + .gridColumnAlignment(cell.role == .value ? .trailing : .leading) + .accessibilityHidden(true) + } else { + Text(cell.text) + .font(cell.role == .value ? OperatorLanePopoverStyle.valueFont : OperatorLanePopoverStyle.metaFont) + .foregroundStyle(cell.role == .value + ? OperatorLanePopoverStyle.primaryText(colorScheme) + : OperatorLanePopoverStyle.mutedText(colorScheme)) + .monospacedDigit() + .lineLimit(1) + .allowsTightening(true) + .padding(.leading, cell.role == .value && cell.slot > 0 + ? OperatorLaneReadoutLayout.totalSegmentSpacing + : 0) + .gridColumnAlignment(cell.role == .value ? .trailing : .leading) + .help(cell.accessibilityText ?? cell.text) + } + } +} + +private struct OperatorTotalMetricGridCell: Identifiable { + let id: String + let slot: Int + let text: String + let accessibilityText: String? + let role: OperatorTotalMetricGridCellRole + let isPlaceholder: Bool +} + +private enum OperatorTotalMetricGridCellRole { + case value + case label +} + +struct OperatorLifecycleTableRow: Identifiable { + let stage: String + let attempts: String + let runtime: String + let inputTokens: String + let outputTokens: String + let toolCalls: String + let largestOutput: String + + var id: String { + stage + } +} + +struct OperatorLifecycleTableView: View { + let rows: [OperatorLifecycleTableRow] + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Grid( + alignment: .leading, + horizontalSpacing: OperatorLaneReadoutLayout.lifecycleTableColumnSpacing, + verticalSpacing: OperatorLaneReadoutLayout.itemRowSpacing + ) { + GridRow(alignment: .firstTextBaseline) { + headerCell("Stage", alignment: .leading) + headerCell("attempts", alignment: .trailing) + headerCell("inference", alignment: .trailing) + headerCell("input", alignment: .trailing) + headerCell("output", alignment: .trailing) + headerCell("tools", alignment: .trailing) + headerCell("max output", alignment: .trailing) + } + ForEach(rows) { row in + GridRow(alignment: .firstTextBaseline) { + tableCell(row.stage, alignment: .leading) + tableCell(row.attempts, alignment: .trailing) + tableCell(row.runtime, alignment: .trailing) + tableCell(row.inputTokens, alignment: .trailing) + tableCell(row.outputTokens, alignment: .trailing) + tableCell(row.toolCalls, alignment: .trailing) + tableCell(row.largestOutput, alignment: .trailing) + } + } + } + .padding(.top, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityLabel(accessibilityText) + } + + private var accessibilityText: String { + rows.map { row in + "\(row.stage), attempts \(row.attempts), inference \(row.runtime), input \(row.inputTokens), output \(row.outputTokens), tools \(row.toolCalls), max output \(row.largestOutput)" + } + .joined(separator: "; ") + } + + private func headerCell( + _ text: String, + alignment: HorizontalAlignment + ) -> some View { + Text(text) + .font(OperatorLanePopoverStyle.metaFont) + .foregroundStyle(OperatorLanePopoverStyle.tableHeaderText(colorScheme)) + .lineLimit(1) + .allowsTightening(true) + .gridColumnAlignment(alignment) + } + + private func tableCell( + _ text: String, + alignment: HorizontalAlignment + ) -> some View { + Text(text) + .font(OperatorLanePopoverStyle.metaFont) + .foregroundStyle(OperatorLanePopoverStyle.primaryText(colorScheme)) + .monospacedDigit() + .lineLimit(1) + .allowsTightening(true) + .gridColumnAlignment(alignment) + } } private enum OperatorLaneReadoutLayout { - static let titleWidth: CGFloat = 62 + static let titleWidth: CGFloat = 92 static let columnSpacing: CGFloat = 7 static let itemRowSpacing: CGFloat = 2 static let progressTrackWidth: CGFloat = 84 + static let totalSegmentSpacing: CGFloat = 13 + static let totalValueLabelSpacing: CGFloat = 4 + static let metricColumnCount = 4 + static let lifecycleTableColumnSpacing: CGFloat = 18 } private enum OperatorLanePopoverStyle { static let titleFont = PanelFont.laneTitle static let projectFont = PanelFont.laneDetail - static let labelFont = PanelFont.usageLabel - static let valueFont = PanelFont.lanePopoverMeta - static let metaFont = PanelFont.tertiary - static let separatorFont = PanelFont.tertiary + static let labelFont = PanelFont.lanePopoverLabel + static let valueFont = PanelFont.lanePopoverValue + static let metaFont = PanelFont.lanePopoverMeta + static let separatorFont = PanelFont.lanePopoverMeta static func primaryText(_ colorScheme: ColorScheme) -> Color { - Color.primary.opacity(colorScheme == .dark ? 0.82 : 0.76) + Color.primary.opacity(colorScheme == .dark ? 0.94 : 0.88) } static func secondaryText(_ colorScheme: ColorScheme) -> Color { - Color.secondary.opacity(colorScheme == .dark ? 0.76 : 0.7) + Color.secondary.opacity(colorScheme == .dark ? 0.92 : 0.86) } static func mutedText(_ colorScheme: ColorScheme) -> Color { - Color.secondary.opacity(colorScheme == .dark ? 0.55 : 0.48) + Color.secondary.opacity(colorScheme == .dark ? 0.78 : 0.68) + } + + static func tableHeaderText(_ colorScheme: ColorScheme) -> Color { + Color.secondary.opacity(colorScheme == .dark ? 0.72 : 0.62) } static func separator(_ colorScheme: ColorScheme) -> Color { - Color.secondary.opacity(colorScheme == .dark ? 0.13 : 0.18) + Color.secondary.opacity(colorScheme == .dark ? 0.18 : 0.22) } static func progressTrack(_ colorScheme: ColorScheme) -> Color { - Color.secondary.opacity(colorScheme == .dark ? 0.14 : 0.16) + Color.secondary.opacity(colorScheme == .dark ? 0.2 : 0.22) } static func progressFill(_ colorScheme: ColorScheme) -> Color { - PanelPalette.routeAccent(colorScheme).opacity(colorScheme == .dark ? 0.76 : 0.68) + PanelPalette.routeAccent(colorScheme).opacity(colorScheme == .dark ? 0.86 : 0.76) } } @@ -3228,28 +3730,53 @@ struct OperatorLaneReadoutItem: Identifiable { return value } + var accessibilityText: String { + if let label { + return "\(label) \(displayValue)" + } + + return displayValue + } + fileprivate var summaryRuns: [OperatorLaneReadoutTextRun] { switch label?.lowercased() { case "wall": return [.meta("wall "), .value(displayValue)] + case "project": + return [.meta(displayValue)] + case "attempts": + return [.value(displayValue), .meta(displayValue == "1" ? " attempt" : " attempts")] + case "captured": + return [.value(displayValue), .meta(" captured")] + case "missing": + return [.value(displayValue), .meta(" missing")] case "events": return [.value(displayValue), .meta(" events")] - case "input": + case "child events": + return [.value(displayValue), .meta(" child events")] + case "input", "input tokens": return [.value(displayValue), .meta(" input")] - case "output": + case "output", "output tokens": return [.value(displayValue), .meta(" output")] case "current": return [.value(displayValue), .meta(" current")] + case "current window", "current window tokens": + return [.value(displayValue), .meta(" current window")] case "peak": return [.value(displayValue), .meta(" peak")] - case "tool calls": - return [.value(displayValue), .meta(" calls")] + case "peak window", "peak window tokens": + return [.value(displayValue), .meta(" peak window")] + case "tools", "tool calls": + return [.value(displayValue), .meta(" tools")] case "output bytes": return [.value(displayValue), .meta(" output")] - case "largest output": - return [.value(displayValue), .meta(" max")] - case "largest tool": - return [.value(displayValue)] + case "max output", "max tool output", "largest output": + if let source = splitLargestOutputSource(displayValue) { + return [.value("\(source.output)(\(source.tool))"), .meta(" max output")] + } + return [.value(displayValue), .meta(" max output")] + case "largest tool", "source": + return [.value(displayValue), .meta(" source")] default: if let label { return [.meta("\(label) "), .value(displayValue)] @@ -3258,11 +3785,31 @@ struct OperatorLaneReadoutItem: Identifiable { } } + fileprivate var summaryCharacterCount: Int { + summaryRuns.reduce(0) { total, run in + total + run.text.count + } + } + func matchesLabel(_ expected: String) -> Bool { label?.caseInsensitiveCompare(expected) == .orderedSame } } +fileprivate func splitLargestOutputSource(_ value: String) -> (output: String, tool: String)? { + guard let range = value.range(of: " from ") else { + return nil + } + + let output = String(value[.. String { private func formatCompactBytes(_ value: Int) -> String { let absoluteValue = max(0, Double(value)) if absoluteValue >= 1_073_741_824 { - return "\(formatCompactDecimal(absoluteValue / 1_073_741_824))GB" + return "\(formatCompactDecimal(absoluteValue / 1_073_741_824))GiB" } if absoluteValue >= 1_048_576 { - return "\(formatCompactDecimal(absoluteValue / 1_048_576))MB" + return "\(formatCompactDecimal(absoluteValue / 1_048_576))MiB" } if absoluteValue >= 1_024 { - return "\(formatCompactDecimal(absoluteValue / 1_024))KB" + return "\(formatCompactDecimal(absoluteValue / 1_024))KiB" } return "\(max(0, value))B" diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift b/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift index ee29458b8..f57280479 100644 --- a/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift +++ b/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift @@ -103,17 +103,10 @@ actor DecodexServerBridge { } private func ensureServer() async throws -> URL { - if let serverBaseURL, hasFreshLiveCheck(serverBaseURL) { - return serverBaseURL - } - - if let serverBaseURL, await probeServer(serverBaseURL) == .live { - noteLive(serverBaseURL) - - return serverBaseURL - } - - if let configured = configuredServerURL() { + if let configured = try configuredServerURL() { + if serverBaseURL == configured, hasFreshLiveCheck(configured) { + return configured + } switch await probeServer(configured) { case .live: noteLive(configured) @@ -124,10 +117,22 @@ actor DecodexServerBridge { "Decodex server at \(configured.absoluteString) is reachable but not app-compatible: \(reason)" ) case .unreachable: - break + throw DecodexAppBridgeError.invalidResponse( + "Configured Decodex App server \(configured.absoluteString) is unreachable. Start that server or unset DECODEX_APP_SERVER_URL." + ) } } + if let serverBaseURL, hasFreshLiveCheck(serverBaseURL) { + return serverBaseURL + } + + if let serverBaseURL, await probeServer(serverBaseURL) == .live { + noteLive(serverBaseURL) + + return serverBaseURL + } + switch await probeServer(defaultBaseURL) { case .live: noteLive(defaultBaseURL) @@ -156,10 +161,28 @@ actor DecodexServerBridge { throw DecodexAppBridgeError.launchFailed("Decodex server did not become ready on \(defaultListenAddress)") } - private func configuredServerURL() -> URL? { + private func configuredServerURL() throws -> URL? { let value = ProcessInfo.processInfo.environment["DECODEX_APP_SERVER_URL"] ?? "" - return value.isEmpty ? nil : URL(string: value) + return try Self.configuredServerURL(from: value) + } + + static func configuredServerURL(from value: String) throws -> URL? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty == false else { + return nil + } + guard let url = URL(string: trimmed), + let scheme = url.scheme?.lowercased(), + (scheme == "http" || scheme == "https"), + url.host != nil + else { + throw DecodexAppBridgeError.invalidResponse( + "DECODEX_APP_SERVER_URL must be an absolute http(s) URL" + ) + } + + return url } private func hasFreshLiveCheck(_ baseURL: URL) -> Bool { diff --git a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift index d36d3e752..3959159e1 100644 --- a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift +++ b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift @@ -308,6 +308,104 @@ struct OperatorPostReviewLaneStatus: Decodable, Sendable { } } +struct OperatorLifecycleMetricBucket: Decodable, Sendable { + let name: String + let wallSeconds: Int + let eventCount: Int + let toolCallCount: Int + let inputTokens: Int + let outputTokens: Int + let outputBytes: Int + + enum CodingKeys: String, CodingKey { + case name + case wallSeconds = "wall_seconds" + case eventCount = "event_count" + case toolCallCount = "tool_call_count" + case inputTokens = "input_tokens" + case outputTokens = "output_tokens" + case outputBytes = "output_bytes" + } +} + +struct OperatorLifecycleMetricPhase: Decodable, Sendable { + let phase: String? + let label: String? + let attemptCount: Int + let runCount: Int + let capturedAttemptCount: Int + let missingAttemptCount: Int + let protocolEventCount: Int + let childEventCount: Int + let wallSeconds: Int + let toolCallCount: Int + let inputTokensCurrent: Int? + let inputTokensPeak: Int? + let inputTokensCumulative: Int + let outputTokensCumulative: Int + let largestToolOutputBytes: Int? + let largestToolOutputTool: String? + let buckets: [OperatorLifecycleMetricBucket] + + enum CodingKeys: String, CodingKey { + case phase + case label + case attemptCount = "attempt_count" + case runCount = "run_count" + case capturedAttemptCount = "captured_attempt_count" + case missingAttemptCount = "missing_attempt_count" + case protocolEventCount = "protocol_event_count" + case childEventCount = "child_event_count" + case wallSeconds = "wall_seconds" + case toolCallCount = "tool_call_count" + case inputTokensCurrent = "input_tokens_current" + case inputTokensPeak = "input_tokens_peak" + case inputTokensCumulative = "input_tokens_cumulative" + case outputTokensCumulative = "output_tokens_cumulative" + case largestToolOutputBytes = "largest_tool_output_bytes" + case largestToolOutputTool = "largest_tool_output_tool" + case buckets + } +} + +struct OperatorLifecycleMetrics: Decodable, Sendable { + let attemptCount: Int + let runCount: Int + let capturedAttemptCount: Int + let missingAttemptCount: Int + let protocolEventCount: Int + let childEventCount: Int + let wallSeconds: Int + let toolCallCount: Int + let inputTokensCurrent: Int? + let inputTokensPeak: Int? + let inputTokensCumulative: Int + let outputTokensCumulative: Int + let largestToolOutputBytes: Int? + let largestToolOutputTool: String? + let buckets: [OperatorLifecycleMetricBucket] + let phases: [OperatorLifecycleMetricPhase] + + enum CodingKeys: String, CodingKey { + case attemptCount = "attempt_count" + case runCount = "run_count" + case capturedAttemptCount = "captured_attempt_count" + case missingAttemptCount = "missing_attempt_count" + case protocolEventCount = "protocol_event_count" + case childEventCount = "child_event_count" + case wallSeconds = "wall_seconds" + case toolCallCount = "tool_call_count" + case inputTokensCurrent = "input_tokens_current" + case inputTokensPeak = "input_tokens_peak" + case inputTokensCumulative = "input_tokens_cumulative" + case outputTokensCumulative = "output_tokens_cumulative" + case largestToolOutputBytes = "largest_tool_output_bytes" + case largestToolOutputTool = "largest_tool_output_tool" + case buckets + case phases + } +} + struct OperatorRunStatus: Decodable, Identifiable, Sendable { let projectID: String? let projectDisplayName: String? @@ -335,6 +433,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { let worktreePath: String? let suspectedStall: Bool let childAgentActivity: OperatorChildAgentActivity? + let lifecycleMetrics: OperatorLifecycleMetrics? let account: OperatorRunAccountSummary? let accounts: [OperatorRunAccountSummary] @@ -379,6 +478,38 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { return "Active" } + func compactActivitySummary(at now: Date) -> String? { + guard let activity = childAgentActivity else { + return nil + } + + var parts = [String]() + if let modelBucket = activity.buckets.first(where: { $0.name.caseInsensitiveCompare("Model") == .orderedSame }) { + let modelSeconds = activity.wallSeconds(for: modelBucket, at: now) + if modelSeconds > 0, let formatted = formatOperatorActivityDuration(modelSeconds) { + parts.append("Model \(formatted)") + } + } else if activity.wallSeconds(at: now) > 0, + let formatted = formatOperatorActivityDuration(activity.wallSeconds(at: now)) + { + parts.append("Activity \(formatted)") + } + + if activity.inputTokensCumulative > 0 || activity.outputTokensCumulative > 0 { + parts.append( + "in \(formatOperatorCompactCount(activity.inputTokensCumulative)) / out \(formatOperatorCompactCount(activity.outputTokensCumulative))" + ) + } + if activity.toolCallCount > 0 { + parts.append("\(formatOperatorCompactCount(activity.toolCallCount)) tools") + } + if let largestOutput = activity.largestToolOutputBytes, largestOutput > 0 { + parts.append("\(formatOperatorCompactBytes(largestOutput)) output") + } + + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + var hasAttentionTone: Bool { suspectedStall || attemptStatus == "waiting_for_review" @@ -473,6 +604,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { worktreePath: activity.worktreePath ?? worktreePath, suspectedStall: activity.suspectedStall || suspectedStall, childAgentActivity: activity.childAgentActivity ?? childAgentActivity, + lifecycleMetrics: activity.lifecycleMetrics ?? lifecycleMetrics, account: activity.account ?? account, accounts: activity.accounts.isEmpty ? accounts : activity.accounts ) @@ -521,6 +653,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { case worktreePath = "worktree_path" case suspectedStall = "suspected_stall" case childAgentActivity = "child_agent_activity" + case lifecycleMetrics = "lifecycle_metrics" case account case accounts case codexAccount = "codex_account" @@ -559,6 +692,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { OperatorChildAgentActivity.self, forKey: .childAgentActivity ) + lifecycleMetrics = try container.decodeIfPresent(OperatorLifecycleMetrics.self, forKey: .lifecycleMetrics) account = try container.decodeIfPresent(OperatorRunAccountSummary.self, forKey: .account) ?? container.decodeIfPresent(OperatorRunAccountSummary.self, forKey: .codexAccount) accounts = try container.decodeIfPresent([OperatorRunAccountSummary].self, forKey: .accounts) @@ -593,6 +727,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { worktreePath: String?, suspectedStall: Bool, childAgentActivity: OperatorChildAgentActivity?, + lifecycleMetrics: OperatorLifecycleMetrics?, account: OperatorRunAccountSummary?, accounts: [OperatorRunAccountSummary] ) { @@ -622,6 +757,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { self.worktreePath = worktreePath self.suspectedStall = suspectedStall self.childAgentActivity = childAgentActivity + self.lifecycleMetrics = lifecycleMetrics self.account = account self.accounts = accounts } @@ -837,6 +973,76 @@ private func rawDisplayToken(_ value: String) -> String { value.trimmingCharacters(in: .whitespacesAndNewlines) } +private func formatOperatorActivityDuration(_ seconds: Int?) -> String? { + guard let seconds else { + return nil + } + + let value = max(0, seconds) + if value < 60 { + return "\(value)s" + } + + let hours = value / 3_600 + let minutes = (value % 3_600) / 60 + let remainderSeconds = value % 60 + if hours > 0 { + return minutes > 0 ? "\(hours)h \(minutes)m" : "\(hours)h" + } + if minutes > 0 { + return remainderSeconds > 0 ? "\(minutes)m \(remainderSeconds)s" : "\(minutes)m" + } + + return "\(remainderSeconds)s" +} + +private func formatOperatorCompactCount(_ value: Int) -> String { + let absoluteValue = abs(Double(value)) + let sign = value < 0 ? "-" : "" + + if absoluteValue >= 1_000_000_000 { + return "\(sign)\(formatOperatorCompactDecimal(absoluteValue / 1_000_000_000))B" + } + if absoluteValue >= 1_000_000 { + return "\(sign)\(formatOperatorCompactDecimal(absoluteValue / 1_000_000))M" + } + if absoluteValue >= 1_000 { + return "\(sign)\(formatOperatorCompactDecimal(absoluteValue / 1_000))k" + } + + return "\(value)" +} + +private func formatOperatorCompactDecimal(_ value: Double) -> String { + if value >= 100 { + return String(format: "%.0f", value) + } + if value >= 10 { + return String(format: "%.1f", value) + } + + return String(format: "%.2f", value) +} + +private func formatOperatorCompactBytes(_ value: Int) -> String { + let units = ["B", "KiB", "MiB", "GiB"] + var amount = Double(max(0, value)) + var unitIndex = 0 + while amount >= 1024, unitIndex < units.count - 1 { + amount /= 1024 + unitIndex += 1 + } + + if unitIndex == 0 { + return "\(Int(amount))\(units[unitIndex])" + } + if amount >= 100 { + return "\(Int(amount.rounded()))\(units[unitIndex])" + } + + return String(format: "%.1f%@", amount, units[unitIndex]) +} + private func date(fromUnixEpoch value: Int64?) -> Date? { guard let value else { return nil diff --git a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift index 923d505ef..f3549821d 100644 --- a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift +++ b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift @@ -124,6 +124,133 @@ final class AccountModelTests: XCTestCase { XCTAssertEqual(activity.wallSeconds(for: toolBucket, at: now), 5) } + func testOperatorRunCompactActivitySummaryShowsRunningLaneCosts() throws { + let payload = """ + { + "run_id": "xy-941-attempt-1", + "issue_identifier": "XY-941", + "status": "running", + "phase": "executing", + "process_alive": true, + "child_agent_activity": { + "current_bucket": "Model", + "current_elapsed_seconds": 10, + "current_started_unix_epoch": 100, + "wall_seconds": 80, + "event_count": 9, + "input_tokens_cumulative": 4270000, + "output_tokens_cumulative": 12000, + "tool_call_count": 11, + "largest_tool_output_bytes": 180000, + "buckets": [ + { + "name": "Model", + "wall_seconds": 70 + }, + { + "name": "Shell", + "wall_seconds": 10, + "tool_call_count": 11 + } + ] + } + } + """.data(using: .utf8)! + + let run = try JSONDecoder().decode(OperatorRunStatus.self, from: payload) + let summary = try XCTUnwrap( + run.compactActivitySummary(at: Date(timeIntervalSince1970: 130)) + ) + + XCTAssertTrue(summary.contains("Model 1m 30s")) + XCTAssertTrue(summary.contains("in 4.27M / out 12.0k")) + XCTAssertTrue(summary.contains("11 tools")) + XCTAssertTrue(summary.contains("176KiB output")) + } + + func testOperatorRunDecodesLifecycleMetricsByPhase() throws { + let payload = """ + { + "run_id": "xy-941-attempt-2", + "issue_identifier": "XY-941", + "lifecycle_metrics": { + "attempt_count": 2, + "run_count": 2, + "captured_attempt_count": 2, + "missing_attempt_count": 0, + "protocol_event_count": 48, + "child_event_count": 54, + "wall_seconds": 1740, + "tool_call_count": 18, + "input_tokens_current": 105000, + "input_tokens_peak": 128000, + "input_tokens_cumulative": 7120000, + "output_tokens_cumulative": 20500, + "largest_tool_output_bytes": 180000, + "largest_tool_output_tool": "view_image", + "buckets": [ + { + "name": "Model", + "wall_seconds": 1313, + "event_count": 23, + "tool_call_count": 0, + "input_tokens": 7120000, + "output_tokens": 20500, + "output_bytes": 0 + } + ], + "phases": [ + { + "phase": "development", + "label": "Development", + "attempt_count": 1, + "run_count": 1, + "captured_attempt_count": 1, + "missing_attempt_count": 0, + "protocol_event_count": 18, + "child_event_count": 24, + "wall_seconds": 910, + "tool_call_count": 7, + "input_tokens_cumulative": 2850000, + "output_tokens_cumulative": 8500, + "largest_tool_output_bytes": 34000, + "largest_tool_output_tool": "shell", + "buckets": [] + }, + { + "phase": "review", + "label": "Review", + "attempt_count": 1, + "run_count": 1, + "captured_attempt_count": 1, + "missing_attempt_count": 0, + "protocol_event_count": 30, + "child_event_count": 30, + "wall_seconds": 830, + "tool_call_count": 11, + "input_tokens_cumulative": 4270000, + "output_tokens_cumulative": 12000, + "largest_tool_output_bytes": 180000, + "largest_tool_output_tool": "view_image", + "buckets": [] + } + ] + } + } + """.data(using: .utf8)! + + let run = try JSONDecoder().decode(OperatorRunStatus.self, from: payload) + let lifecycle = try XCTUnwrap(run.lifecycleMetrics) + + XCTAssertEqual(lifecycle.attemptCount, 2) + XCTAssertEqual(lifecycle.inputTokensCurrent, 105_000) + XCTAssertEqual(lifecycle.inputTokensPeak, 128_000) + XCTAssertEqual(lifecycle.inputTokensCumulative, 7_120_000) + XCTAssertEqual(lifecycle.phases.map(\.label), ["Development", "Review"]) + XCTAssertEqual(lifecycle.phases.map(\.toolCallCount), [7, 11]) + XCTAssertEqual(lifecycle.buckets.first?.wallSeconds, 1313) + } + func testStoppedActiveRunUsesInactiveDurationAndAttentionTone() throws { let payload = """ { diff --git a/apps/decodex-app/Tests/DecodexAppTests/DecodexServerBridgeTests.swift b/apps/decodex-app/Tests/DecodexAppTests/DecodexServerBridgeTests.swift index 0a34c2a4b..b01a3334b 100644 --- a/apps/decodex-app/Tests/DecodexAppTests/DecodexServerBridgeTests.swift +++ b/apps/decodex-app/Tests/DecodexAppTests/DecodexServerBridgeTests.swift @@ -13,6 +13,16 @@ final class DecodexServerBridgeTests: XCTestCase { ) } + func testConfiguredServerURLRequiresAbsoluteHTTPURL() throws { + XCTAssertNil(try DecodexServerBridge.configuredServerURL(from: "")) + XCTAssertEqual( + try DecodexServerBridge.configuredServerURL(from: " http://127.0.0.1:57399 ")?.absoluteString, + "http://127.0.0.1:57399" + ) + XCTAssertThrowsError(try DecodexServerBridge.configuredServerURL(from: "127.0.0.1:57399")) + XCTAssertThrowsError(try DecodexServerBridge.configuredServerURL(from: "file:///tmp/mock")) + } + func testDashboardWebSocketHandshakeRequestEndsWithHeaderTerminator() { let request = DashboardWebSocketConnection.handshakeRequest( host: "127.0.0.1", diff --git a/apps/decodex/src/agent/app_server.rs b/apps/decodex/src/agent/app_server.rs index 7c24b7038..9b51731a7 100644 --- a/apps/decodex/src/agent/app_server.rs +++ b/apps/decodex/src/agent/app_server.rs @@ -1158,6 +1158,13 @@ impl<'a> RunRecorder<'a> { let child_activity = self.child_activity.record(event_type, payload); let protocol_activity = self.protocol_activity.record(event_type, payload, &child_activity); + self.state_store.record_run_activity_summary( + self.run_id, + self.attempt_number, + Some(&child_activity), + Some(&protocol_activity), + )?; + if let Some(marker_path) = self.activity_marker_path { let activity = state::ProtocolActivityMarker { run_id: self.run_id, diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html index 77c7b370b..a9ad9060c 100644 --- a/apps/decodex/src/orchestrator/operator_dashboard.html +++ b/apps/decodex/src/orchestrator/operator_dashboard.html @@ -1503,6 +1503,7 @@ } .child-activity { + --child-muted-readable: color-mix(in srgb, var(--muted-strong) 62%, var(--muted)); margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--line); @@ -2674,17 +2675,17 @@ font-family: var(--mono); font-size: var(--type-caption); line-height: 1.4; - color: var(--muted); + color: var(--child-muted-readable); } .child-activity-note { - color: var(--muted); + color: var(--child-muted-readable); } .child-activity-body { display: grid; - gap: 8px; - margin-top: 9px; + gap: 4px; + margin-top: 0; } .child-activity-head strong, @@ -2693,6 +2694,10 @@ font-weight: var(--weight-label); } + .child-activity-head.is-project strong { + color: var(--muted-strong); + } + .child-share-list { display: grid; gap: 6px; @@ -2707,7 +2712,7 @@ min-width: 0; font-family: var(--mono); font-size: var(--type-caption); - color: var(--muted); + color: var(--child-muted-readable); } .child-bucket.is-event-only, @@ -2749,6 +2754,7 @@ min-width: 0; text-align: right; overflow: visible; + font-variant-numeric: tabular-nums; } .child-bucket-signals { @@ -2768,7 +2774,121 @@ border-top: 1px solid var(--line); font-family: var(--mono); font-size: var(--type-caption); - color: var(--muted); + color: var(--child-muted-readable); + } + + .child-context-group { + display: grid; + gap: 5px; + width: 100%; + max-width: 100%; + padding-top: 9px; + border-top: 1px solid var(--line); + } + + .child-context-group .child-context { + padding-top: 0; + border-top: 0; + } + + .child-total-overview { + display: inline-grid; + grid-template-columns: + minmax(86px, max-content) + repeat(4, max-content max-content); + align-items: baseline; + gap: 3px 0.38em; + width: fit-content; + max-width: 100%; + overflow: hidden; + font-family: var(--mono); + font-size: var(--type-caption); + color: var(--child-muted-readable); + } + + .child-total-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--muted-strong); + } + + .child-total-segment { + display: contents; + } + + .child-total-segment.is-empty > * { + visibility: hidden; + } + + .child-total-primary { + color: var(--text); + font-weight: var(--weight-label); + font-variant-numeric: tabular-nums; + justify-self: end; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .child-total-primary[data-slot] { + padding-left: clamp(18px, 1.45vw, 26px); + } + + .child-total-secondary { + color: var(--child-muted-readable); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .child-phase-table { + display: inline-grid; + grid-template-columns: + max-content + max-content + max-content + max-content + max-content + max-content + max-content; + gap: 4px clamp(24px, 2vw, 34px); + align-items: baseline; + width: fit-content; + max-width: 100%; + overflow: hidden; + margin-top: 2px; + padding-top: 5px; + border-top: 1px solid color-mix(in srgb, var(--line) 72%, transparent); + font-family: var(--mono); + font-size: var(--type-caption); + color: var(--child-muted-readable); + } + + .child-phase-table-cell { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .child-phase-table-cell[data-align="right"] { + justify-self: end; + text-align: right; + } + + .child-phase-table-cell.is-header { + color: var(--child-muted-readable); + font-size: var(--type-meta); + } + + .child-phase-table-cell.is-value { + color: var(--text); + font-weight: var(--weight-label); + font-variant-numeric: tabular-nums; } .child-context-label { @@ -3056,7 +3176,7 @@ } details.is-animating > .grid, - details.is-animating > .attempt-list, + details.is-animating > .phase-list, details.is-animating > .panel-body { overflow: hidden; transform-origin: top center; @@ -3066,39 +3186,64 @@ transform var(--slow) var(--ease); } - .attempt-timeline { - margin-top: 10px; + .phase-timeline { + margin-top: 12px; } - .attempt-list { + .phase-list { display: grid; - gap: 1px; - margin-top: 10px; - border: 1px solid var(--line); - border-radius: var(--radius-sm); - background: var(--line); - overflow: hidden; + gap: 0; + margin-top: 8px; + border-top: 1px solid var(--line); } - .attempt-row { + .phase-row { display: grid; - grid-template-columns: 54px 110px minmax(150px, 0.8fr) 120px minmax(220px, 1fr); - gap: 12px; - align-items: center; + grid-template-columns: minmax(112px, 0.28fr) minmax(0, 1fr); + gap: 12px 16px; + align-items: start; min-width: 0; - padding: 8px 10px; - background: var(--surface-muted); - color: var(--muted-strong); - font-size: var(--type-meta); + padding: 8px 0; + border-bottom: 1px solid color-mix(in srgb, var(--line) 72%, transparent); } - .attempt-row span { + .phase-row:last-child { + border-bottom: 0; + } + + .phase-name, + .phase-facts, + .phase-facts span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .phase-name { + color: var(--text); + font-family: var(--sans); + font-size: var(--type-label); + font-weight: var(--weight-label); + line-height: 1.35; + } + + .phase-facts { + display: flex; + flex-wrap: wrap; + gap: 5px 14px; + align-items: baseline; + font-family: var(--mono); + font-size: var(--type-caption); + line-height: 1.45; + color: var(--muted); + } + + .phase-facts strong { + color: var(--muted-strong); + font-weight: var(--weight-label); + } + details.fold-panel { margin-top: 0; padding-top: 0; @@ -3343,12 +3488,9 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } - .attempt-row { - grid-template-columns: 48px 96px minmax(130px, 1fr); - } - - .attempt-row span:nth-child(n + 4) { - display: none; + .phase-row { + grid-template-columns: 1fr; + gap: 4px; } .row-head { @@ -5226,7 +5368,7 @@

Run History

} function detailContent(details) { - return details.querySelector(":scope > .panel-body, :scope > .grid, :scope > .attempt-list"); + return details.querySelector(":scope > .panel-body, :scope > .grid, :scope > .phase-list"); } function rememberDetailOpenState(details, isOpen) { @@ -6001,6 +6143,153 @@

Run History

]; } + function lifecycleNumber(value) { + const number = Number(value ?? 0); + + return Number.isFinite(number) ? Math.max(0, number) : 0; + } + + function historyLaneLifecycleMetrics(lane) { + const attempts = Array.isArray(lane?.attempts) && lane.attempts.length + ? lane.attempts + : lane?.latest_run + ? [lane.latest_run] + : []; + const provided = lane?.lifecycle_metrics || {}; + const summaries = attempts.map(childAgentActivity).filter(Boolean); + const captured = lifecycleNumber( + provided.captured_attempt_count ?? summaries.length, + ); + const attemptCount = lifecycleNumber( + provided.attempt_count ?? lane?.attempt_count ?? attempts.length, + ); + + return { + ...provided, + attempt_count: attemptCount, + captured_attempt_count: captured, + missing_attempt_count: lifecycleNumber( + provided.missing_attempt_count ?? Math.max(0, attemptCount - captured), + ), + protocol_event_count: lifecycleNumber( + provided.protocol_event_count ?? + attempts.reduce((total, run) => total + lifecycleNumber(run?.event_count), 0), + ), + child_event_count: lifecycleNumber( + provided.child_event_count ?? + summaries.reduce((total, summary) => total + lifecycleNumber(summary.event_count), 0), + ), + wall_seconds: lifecycleNumber( + provided.wall_seconds ?? + summaries.reduce((total, summary) => total + lifecycleNumber(summary.wall_seconds), 0), + ), + tool_call_count: lifecycleNumber( + provided.tool_call_count ?? + summaries.reduce((total, summary) => total + lifecycleNumber(summary.tool_call_count), 0), + ), + input_tokens_cumulative: lifecycleNumber( + provided.input_tokens_cumulative ?? + summaries.reduce( + (total, summary) => total + lifecycleNumber(summary.input_tokens_cumulative), + 0, + ), + ), + output_tokens_cumulative: lifecycleNumber( + provided.output_tokens_cumulative ?? + summaries.reduce( + (total, summary) => total + lifecycleNumber(summary.output_tokens_cumulative), + 0, + ), + ), + buckets: Array.isArray(provided.buckets) ? provided.buckets : [], + phases: Array.isArray(provided.phases) + ? provided.phases.map(normalizeLifecyclePhaseMetrics) + : [], + }; + } + + function normalizeLifecyclePhaseMetrics(phase) { + return { + ...phase, + phase: phase?.phase || "unknown", + label: phase?.label || displayToken(phase?.phase || "unknown"), + attempt_count: lifecycleNumber(phase?.attempt_count), + captured_attempt_count: lifecycleNumber(phase?.captured_attempt_count), + missing_attempt_count: lifecycleNumber(phase?.missing_attempt_count), + protocol_event_count: lifecycleNumber(phase?.protocol_event_count), + child_event_count: lifecycleNumber(phase?.child_event_count), + wall_seconds: lifecycleNumber(phase?.wall_seconds), + tool_call_count: lifecycleNumber(phase?.tool_call_count), + input_tokens_cumulative: lifecycleNumber(phase?.input_tokens_cumulative), + output_tokens_cumulative: lifecycleNumber(phase?.output_tokens_cumulative), + buckets: Array.isArray(phase?.buckets) ? phase.buckets : [], + }; + } + + function lifecycleBucket(metrics, bucketName) { + return (metrics?.buckets || []).find( + (candidate) => String(candidate?.name || "").toLowerCase() === bucketName.toLowerCase(), + ); + } + + function lifecycleBucketSeconds(metrics, bucketName) { + const bucket = lifecycleBucket(metrics, bucketName); + + return lifecycleNumber(bucket?.wall_seconds); + } + + function lifecycleWallSeconds(metrics) { + const buckets = Array.isArray(metrics?.buckets) ? metrics.buckets : []; + return Math.max( + 1, + lifecycleNumber(metrics?.wall_seconds), + buckets.reduce((total, bucket) => total + lifecycleNumber(bucket?.wall_seconds), 0), + ); + } + + function formatRuntimeShare(seconds, totalSeconds) { + const elapsed = lifecycleNumber(seconds); + const total = Math.max(1, lifecycleNumber(totalSeconds), elapsed); + if (elapsed <= 0) { + return { percent: "-", elapsed: "-", total: "-", ratio: "-", text: "-" }; + } + + const percent = Math.round((elapsed / total) * 100); + const elapsedText = formatDuration(elapsed); + const totalText = formatDuration(total); + const ratio = `${compactRuntimeDuration(elapsedText)}/${compactRuntimeDuration(totalText)}`; + return { + percent: `${percent}%`, + elapsed: elapsedText, + total: totalText, + ratio, + text: `${ratio}(${percent}%)`, + }; + } + + function compactRuntimeDuration(value) { + return String(value || "-").replaceAll(" ", ""); + } + + function historyLifecycleTokenSummary(metrics) { + const input = lifecycleNumber(metrics?.input_tokens_cumulative); + const output = lifecycleNumber(metrics?.output_tokens_cumulative); + + if (input === 0 && output === 0) { + return "not captured"; + } + + return `in ${formatCompactCount(input)} / out ${formatCompactCount(output)}`; + } + + function historyLifecycleCaptureSummary(metrics) { + const captured = lifecycleNumber(metrics?.captured_attempt_count); + const attempts = lifecycleNumber(metrics?.attempt_count); + const missing = lifecycleNumber(metrics?.missing_attempt_count); + + return missing > 0 ? `${captured}/${attempts} captured · ${missing} missing` : `${captured}/${attempts} captured`; + } + function activeRunTelemetryFacts(run) { const facts = []; const freshness = activeRunFreshness(run); @@ -6043,6 +6332,165 @@

Run History

return facts.map(([label, value]) => renderRunMetaFact(label, value)).join(""); } + function activeRunLifecycleMetrics(run, summary = childAgentActivity(run)) { + const provided = run?.lifecycle_metrics || {}; + const providedPhases = Array.isArray(provided.phases) + ? provided.phases.map(normalizeLifecyclePhaseMetrics) + : []; + const fallbackCaptured = summary ? 1 : 0; + const attemptCount = lifecycleNumber(provided.attempt_count ?? (summary ? 1 : 0)); + const capturedAttemptCount = lifecycleNumber( + provided.captured_attempt_count ?? fallbackCaptured, + ); + const buckets = Array.isArray(provided.buckets) && provided.buckets.length + ? provided.buckets + : childAgentBuckets(summary); + const metrics = { + ...provided, + attempt_count: attemptCount, + run_count: lifecycleNumber(provided.run_count ?? attemptCount), + captured_attempt_count: capturedAttemptCount, + missing_attempt_count: lifecycleNumber( + provided.missing_attempt_count ?? + Math.max(0, attemptCount - capturedAttemptCount), + ), + protocol_event_count: lifecycleNumber(provided.protocol_event_count ?? run?.event_count), + child_event_count: lifecycleNumber(provided.child_event_count ?? summary?.event_count), + wall_seconds: lifecycleNumber(provided.wall_seconds ?? summary?.wall_seconds), + tool_call_count: lifecycleNumber(provided.tool_call_count ?? summary?.tool_call_count), + input_tokens_current: provided.input_tokens_current ?? summary?.input_tokens_current ?? null, + input_tokens_peak: provided.input_tokens_peak ?? summary?.input_tokens_max ?? null, + input_tokens_cumulative: lifecycleNumber( + provided.input_tokens_cumulative ?? summary?.input_tokens_cumulative, + ), + output_tokens_cumulative: lifecycleNumber( + provided.output_tokens_cumulative ?? summary?.output_tokens_cumulative, + ), + largest_tool_output_bytes: + provided.largest_tool_output_bytes ?? summary?.largest_tool_output_bytes ?? null, + largest_tool_output_tool: + provided.largest_tool_output_tool ?? summary?.largest_tool_output_tool ?? null, + large_output_warnings: Array.isArray(provided.large_output_warnings) + ? provided.large_output_warnings + : childAgentLargeOutputWarnings(summary), + buckets, + phases: providedPhases, + }; + + if (!metrics.phases.length && summary) { + const phase = fallbackLifecyclePhaseForRun(run); + metrics.phases = [ + normalizeLifecyclePhaseMetrics({ + phase: phase.key, + label: phase.label, + attempt_count: attemptCount || 1, + run_count: 1, + captured_attempt_count: 1, + missing_attempt_count: 0, + protocol_event_count: lifecycleNumber(run?.event_count), + child_event_count: lifecycleNumber(summary.event_count), + wall_seconds: lifecycleNumber(summary.wall_seconds), + tool_call_count: lifecycleNumber(summary.tool_call_count), + input_tokens_current: summary.input_tokens_current ?? null, + input_tokens_peak: summary.input_tokens_max ?? null, + input_tokens_cumulative: lifecycleNumber(summary.input_tokens_cumulative), + output_tokens_cumulative: lifecycleNumber(summary.output_tokens_cumulative), + largest_tool_output_bytes: summary.largest_tool_output_bytes ?? null, + largest_tool_output_tool: summary.largest_tool_output_tool ?? null, + large_output_warnings: childAgentLargeOutputWarnings(summary), + buckets: childAgentBuckets(summary), + }), + ]; + } + + return metrics; + } + + function fallbackLifecyclePhaseForRun(run) { + const status = String(run?.status || "").toLowerCase(); + const operation = String(run?.current_operation || "").toLowerCase(); + const reviewPhase = String(run?.loop_status?.review?.phase || "").toLowerCase(); + + if (["cleanup_complete", "closeout", "closeout_pending", "landed"].includes(status)) { + return { key: "closeout", label: "Closeout" }; + } + if ( + ["manual_attention", "manual_attention_pending", "needs_attention", "terminal_failure"].includes(status) || + String(run?.phase || "").toLowerCase() === "needs_attention" + ) { + return { key: "manual_attention", label: "Manual attention" }; + } + if (reviewPhase === "repair" || status === "review_repair_pending") { + return { key: "review_repair", label: "Review repair" }; + } + if (reviewPhase || status === "review_handoff_pending" || operation === "review_writeback") { + return { key: "review", label: "Review" }; + } + + return { key: "development", label: "Development" }; + } + + function lifecycleMetricDurationFact(metrics) { + const modelSeconds = lifecycleBucketSeconds(metrics, "Model"); + const activitySeconds = modelSeconds > 0 ? modelSeconds : lifecycleNumber(metrics?.wall_seconds); + + if (activitySeconds <= 0) { + return null; + } + + return [modelSeconds > 0 ? "inference" : "activity", formatDuration(activitySeconds)]; + } + + function lifecycleMetricFacts(metrics, { includeAttempts = false } = {}) { + if (!metrics) { + return []; + } + + const facts = []; + const durationFact = lifecycleMetricDurationFact(metrics); + const tokenSummary = historyLifecycleTokenSummary(metrics); + const attempts = lifecycleNumber(metrics.attempt_count); + const captured = lifecycleNumber(metrics.captured_attempt_count); + const missing = lifecycleNumber(metrics.missing_attempt_count); + const childEvents = lifecycleNumber(metrics.child_event_count); + const protocolEvents = lifecycleNumber(metrics.protocol_event_count); + + if (includeAttempts && (attempts > 1 || missing > 0)) { + facts.push([ + "attempts", + missing > 0 ? `${captured}/${attempts} captured` : formatCompactCount(attempts), + ]); + } + if (durationFact) { + facts.push(durationFact); + } + if (tokenSummary !== "not captured") { + facts.push(["tokens", tokenSummary]); + } + if (lifecycleNumber(metrics.tool_call_count) > 0) { + facts.push(["tools", formatCompactCount(metrics.tool_call_count)]); + } + if (childEvents || protocolEvents) { + facts.push([ + "events", + childEvents && protocolEvents + ? `${formatCompactCount(childEvents)} child / ${formatCompactCount(protocolEvents)} protocol` + : formatCompactCount(childEvents || protocolEvents), + ]); + } + if (metrics.largest_tool_output_bytes != null) { + facts.push([ + "max output", + formatLargestOutputValue( + metrics.largest_tool_output_bytes, + metrics.largest_tool_output_tool, + ), + ]); + } + + return facts; + } + function codexAccount(run, snapshot = null) { const selected = run?.account || run?.codex_account || null; if (selected) { @@ -7753,18 +8201,34 @@

Run History

].join("; "); } - function historyLaneTimingFacts(lane) { - const outcome = historyLedgerOutcome(lane); + function historyLaneTimingFacts(lane) { + const outcome = historyLedgerOutcome(lane); + const metrics = historyLaneLifecycleMetrics(lane); + const modelSeconds = lifecycleBucketSeconds(metrics, "Model"); + const activitySeconds = modelSeconds > 0 + ? modelSeconds + : lifecycleNumber(metrics.wall_seconds); + const activityLabel = modelSeconds > 0 ? "Inference" : "Activity"; if (!historyLedgerHasRecords(outcome)) { - return historyRunTimingFacts(lane.latest_run); + const run = lane.latest_run; + + return [ + ["Updated", formatTimestampCompact(run.updated_at)], + ["Attempts", String(metrics.attempt_count || lane.attempt_count || 1)], + [activityLabel, formatDuration(activitySeconds)], + ["Tokens", historyLifecycleTokenSummary(metrics)], + ["Events", formatCompactCount(metrics.protocol_event_count)], + ]; } return [ ["Finished", formatTimestampCompact(outcome.lifecycle_finished_at || outcome.final_event_at)], ["Elapsed", formatDuration(outcome.lifecycle_elapsed_seconds)], - ["Attempts", String(lane.attempt_count ?? lane.attempts?.length ?? 1)], - ["Records", String(outcome.record_count ?? 0)], + ["Attempts", String(metrics.attempt_count || lane.attempt_count || 1)], + [activityLabel, formatDuration(activitySeconds)], + ["Tokens", historyLifecycleTokenSummary(metrics)], + ["Events", formatCompactCount(metrics.protocol_event_count || outcome.record_count || 0)], ]; } @@ -7807,43 +8271,29 @@

Run History

); } - function childAgentContextFacts(summary) { - if (!summary) { - return []; - } + function childAgentContextFacts(summary) { + if (!summary) { + return []; + } - const latestInput = formatCompactCount(summary.input_tokens_current); - const peakInput = formatCompactCount(summary.input_tokens_max); - const inputWindowsMatch = childAgentInputWindowsMatch(summary); - const facts = [ - ["current window", latestInput, "Current context window from the latest child-agent event."], - [ - "cumulative input", - formatCompactCount(summary.input_tokens_cumulative), - "Total input tokens processed across child-agent events.", - ], - ["tool calls", String(summary.tool_call_count ?? 0)], - ]; + const latestInput = formatCompactCount(summary.input_tokens_current); + const peakInput = formatCompactCount(summary.input_tokens_max); + const inputWindowsMatch = childAgentInputWindowsMatch(summary); + const facts = [ + ["current window", latestInput, "Current context window from the latest child-agent event."], + ]; - if (!inputWindowsMatch) { - facts.splice(1, 0, [ - "peak window", - peakInput, - "Largest observed context window for this lane.", - ]); - } + if (!inputWindowsMatch) { + facts.push([ + "peak window", + peakInput, + "Largest observed context window for this lane.", + ]); + } - if (summary.largest_tool_output_bytes != null) { - facts.push([ - "largest output", - formatCompactBytes(summary.largest_tool_output_bytes), - "Largest tool output; debug details show attribution.", - ]); + return facts; } - return facts; - } - function childAgentLargeOutputWarnings(summary) { return Array.isArray(summary?.large_output_warnings) ? summary.large_output_warnings.filter(Boolean) @@ -7872,7 +8322,7 @@

Run History

signals.push(["events", formatCompactCount(events)]); } if (toolCalls > 0) { - signals.push(["tool calls", formatCompactCount(toolCalls)]); + signals.push(["tools", formatCompactCount(toolCalls)]); } if (inputTokens > 0) { signals.push(["input", `${formatCompactCount(inputTokens)} tok`]); @@ -7897,6 +8347,10 @@

Run History

: fallback; } + function childBucketDisplayName(bucket) { + return childBucketIsPrimaryShareBucket(bucket) ? "Inference" : displayToken(bucket?.name); + } + function renderChildBucketSignalList(signals) { return signals .map( @@ -7965,8 +8419,22 @@

Run History

return `child-bucket:${bucket?.name || "unknown"}`; } + function childBucketNormalizedName(bucket) { + return String(bucket?.name || "").toLowerCase(); + } + + function childBucketIsPrimaryShareBucket(bucket) { + return childBucketNormalizedName(bucket) === "model"; + } + + function childBucketIsLifecycleTotalBucket(bucket) { + const name = childBucketNormalizedName(bucket); + + return name === "protocol" || name === "tracker"; + } + function childDiagnosticBucketRank(bucket) { - const name = String(bucket?.name || "").toLowerCase(); + const name = childBucketNormalizedName(bucket); if (name.includes("protocol")) { return 0; } @@ -8025,6 +8493,246 @@

Run History

}); } + function renderChildContextFacts(facts) { + return facts + .map(([label, value, title]) => { + const titleAttribute = title ? ` title="${escapeHtml(title)}"` : ""; + + return `${escapeHtml(detailLabel(label))} ${escapeHtml(value)}`; + }) + .join(""); + } + + function runProjectSummary(run) { + return displayToken(run?.project_display_name || run?.project_id || "project"); + } + + function lifecycleMetricSegment(value, label = "") { + if (value == null || value === "") { + return null; + } + + return { + value: String(value), + label: label ? String(label) : "", + }; + } + + function appendLifecycleMetricSegment(segments, value, label = "") { + const segment = lifecycleMetricSegment(value, label); + if (segment) { + segments.push(segment); + } + } + + function formatLargestOutputValue(bytes, tool = "") { + if (bytes == null) { + return "-"; + } + + const output = formatCompactBytes(bytes); + const source = tool ? String(tool).trim() : ""; + return source ? `${output}(${source})` : output; + } + + function renderLifecycleMetricSegment(segment, slotIndex) { + if (!segment) { + return ` + + `; + } + + const label = segment.label + ? `${escapeHtml(segment.label)}` + : ``; + + return `${escapeHtml(segment.value)}${label}`; + } + + function renderLifecycleOverviewRows(rows) { + const visibleRows = rows.filter((row) => row.segments.length); + if (!visibleRows.length) { + return ""; + } + + const cells = visibleRows + .flatMap((row) => { + const paddedSegments = [ + ...row.segments.slice(0, 4), + ...Array.from({ length: Math.max(0, 4 - row.segments.length) }, () => null), + ]; + + return [ + `${escapeHtml(row.label)}`, + ...paddedSegments.map((segment, slotIndex) => + renderLifecycleMetricSegment(segment, slotIndex), + ), + ]; + }) + .join(""); + + return ` +
+ ${cells} +
+ `; + } + + function renderChildLifecycleOverview(lifecycle, contextFacts) { + const rows = []; + + const contextSegments = []; + if (lifecycleNumber(lifecycle?.input_tokens_cumulative) > 0) { + appendLifecycleMetricSegment( + contextSegments, + formatCompactCount(lifecycle.input_tokens_cumulative), + "input", + ); + } + if (lifecycleNumber(lifecycle?.output_tokens_cumulative) > 0) { + appendLifecycleMetricSegment( + contextSegments, + formatCompactCount(lifecycle.output_tokens_cumulative), + "output", + ); + } + for (const [label, value] of contextFacts) { + const detail = detailLabel(label); + appendLifecycleMetricSegment( + contextSegments, + value, + detail, + ); + } + if (contextSegments.length) { + rows.push({ label: "Context", segments: contextSegments }); + } + + const toolCalls = lifecycleNumber(lifecycle?.tool_call_count); + const largestOutput = lifecycle?.largest_tool_output_bytes; + const toolSegments = []; + if (toolCalls > 0) { + appendLifecycleMetricSegment(toolSegments, formatCompactCount(toolCalls), "tools"); + } + if (largestOutput != null) { + appendLifecycleMetricSegment( + toolSegments, + formatLargestOutputValue(largestOutput, lifecycle?.largest_tool_output_tool), + "max output", + ); + } + if (toolSegments.length) { + rows.push({ label: "Tools", segments: toolSegments }); + } + + const tracker = lifecycleBucket(lifecycle, "Tracker"); + if (tracker) { + const trackerSegments = []; + if (lifecycleNumber(tracker.event_count) > 0) { + appendLifecycleMetricSegment(trackerSegments, formatCompactCount(tracker.event_count), "events"); + } + if (lifecycleNumber(tracker.tool_call_count) > 0) { + appendLifecycleMetricSegment(trackerSegments, formatCompactCount(tracker.tool_call_count), "tools"); + } + if (lifecycleNumber(tracker.output_bytes) > 0) { + appendLifecycleMetricSegment(trackerSegments, formatCompactBytes(tracker.output_bytes), "output bytes"); + } + if (trackerSegments.length) { + rows.push({ label: "Tracker", segments: trackerSegments }); + } + } + + const protocolEvents = lifecycleNumber(lifecycle?.protocol_event_count); + const childEvents = lifecycleNumber(lifecycle?.child_event_count); + if (protocolEvents || childEvents) { + const protocolSegments = []; + if (protocolEvents) { + appendLifecycleMetricSegment(protocolSegments, formatCompactCount(protocolEvents), "events"); + } + if (childEvents) { + appendLifecycleMetricSegment(protocolSegments, formatCompactCount(childEvents), "child events"); + } + rows.push({ label: "Protocol", segments: protocolSegments }); + } + + return renderLifecycleOverviewRows(rows); + } + + function renderChildLifecyclePhaseTable(phases) { + const rows = (phases || []) + .map((phase) => { + const modelSeconds = lifecycleBucketSeconds(phase, "Model"); + const phaseWall = lifecycleWallSeconds(phase); + const runtime = formatRuntimeShare(modelSeconds, phaseWall); + const inputTokens = lifecycleNumber(phase.input_tokens_cumulative); + const outputTokens = lifecycleNumber(phase.output_tokens_cumulative); + const toolCalls = lifecycleNumber(phase.tool_call_count); + const largestOutput = phase?.largest_tool_output_bytes != null + ? formatLargestOutputValue( + phase.largest_tool_output_bytes, + phase.largest_tool_output_tool, + ) + : "-"; + + return [ + phase.label || displayToken(phase.phase), + lifecycleNumber(phase.attempt_count) > 0 + ? formatCompactCount(phase.attempt_count) + : "-", + runtime.text, + inputTokens > 0 ? formatCompactCount(inputTokens) : "-", + outputTokens > 0 ? formatCompactCount(outputTokens) : "-", + toolCalls > 0 ? formatCompactCount(toolCalls) : "-", + largestOutput, + ]; + }); + + if (!rows.length) { + return ""; + } + + const header = ["Stage", "attempts", "inference", "input", "output", "tools", "max output"]; + const alignRight = new Set([1, 2, 3, 4, 5, 6]); + const cells = [ + ...header.map( + (label, index) => + `${escapeHtml(label)}`, + ), + ...rows.flatMap((row) => + row.map( + (value, index) => + renderChildLifecyclePhaseCell(value, index, alignRight.has(index)), + ), + ), + ].join(""); + + return `
${cells}
`; + } + + function renderChildLifecyclePhaseCell(value, index, alignRight) { + const align = alignRight ? "right" : "left"; + return `${escapeHtml(String(value))}`; + } + + function childAgentContextRows(run, summary, lifecycle = activeRunLifecycleMetrics(run, summary)) { + const rows = []; + const contextFacts = childAgentContextFacts(summary); + const overview = renderChildLifecycleOverview(lifecycle, contextFacts); + const phaseTable = renderChildLifecyclePhaseTable(lifecycle.phases || []); + + if (overview) { + rows.push(overview); + } + if (phaseTable) { + rows.push(phaseTable); + } + + return rows; + } + function renderChildAgentBreakdown(run) { const summary = childAgentActivity(run); @@ -8032,17 +8740,27 @@

Run History

return ""; } - const buckets = childAgentBuckets(summary); + const lifecycle = activeRunLifecycleMetrics(run, summary); + const buckets = (lifecycle.buckets || []).length ? lifecycle.buckets : childAgentBuckets(summary); const totalWall = Math.max( 1, - Number(summary.wall_seconds || 0), + Number(lifecycle.wall_seconds || 0), buckets.reduce((total, bucket) => total + Number(bucket.wall_seconds || 0), 0), ); const current = childAgentCurrentSummary(summary) || "none"; - const contextFacts = childAgentContextFacts(summary); - const shareBuckets = buckets.filter((bucket) => childBucketHasMeaningfulWallShare(bucket, totalWall)); + const contextRows = childAgentContextRows(run, summary, lifecycle); + const shareBuckets = buckets.filter( + (bucket) => + childBucketIsPrimaryShareBucket(bucket) && + childBucketHasMeaningfulWallShare(bucket, totalWall), + ); const diagnosticBuckets = buckets - .filter((bucket) => !childBucketHasMeaningfulWallShare(bucket, totalWall)) + .filter( + (bucket) => + !childBucketIsPrimaryShareBucket(bucket) && + !childBucketIsLifecycleTotalBucket(bucket) && + !childBucketHasMeaningfulWallShare(bucket, totalWall), + ) .sort( (left, right) => childDiagnosticBucketRank(left) - childDiagnosticBucketRank(right) || @@ -8051,6 +8769,10 @@

Run History

return `
+
+ Project + ${escapeHtml(runProjectSummary(run))} +
Activity ${escapeHtml(current)} @@ -8064,12 +8786,12 @@

Run History

.map((bucket) => { const width = childBucketWidth(bucket, totalWall); - return ` -
- ${escapeHtml(displayToken(bucket.name))} - - ${escapeHtml(childBucketShareLabel(bucket, totalWall))} -
+ return ` +
+ ${escapeHtml(childBucketDisplayName(bucket))} + + ${escapeHtml(childBucketShareLabel(bucket, totalWall))} +
`; }) .join("")} @@ -8105,19 +8827,10 @@

Run History

: "" } ${ - contextFacts.length - ? `
- Context - - ${contextFacts - .map(([label, value, title]) => { - const titleAttribute = title ? ` title="${escapeHtml(title)}"` : ""; - - return `${escapeHtml(detailLabel(label))} ${escapeHtml(value)}`; - }) - .join("")} - -
` + contextRows.length + ? `
+ ${contextRows.join("")} +
` : "" }
@@ -8148,9 +8861,8 @@

Run History

const peakSummary = childAgentInputWindowsMatch(summary) ? "" : `, ${peakInput} peak window`; - const largestTool = summary.largest_tool_output_tool ? ` by ${summary.largest_tool_output_tool}` : ""; - return `input ${latestInput} current window${peakSummary}, cumulative input ${formatCompactCount(summary.input_tokens_cumulative)}, largest output ${formatCompactBytes(summary.largest_tool_output_bytes)}${largestTool}`; + return `input ${latestInput} current window${peakSummary}, cumulative input ${formatCompactCount(summary.input_tokens_cumulative)}, max output ${formatLargestOutputValue(summary.largest_tool_output_bytes, summary.largest_tool_output_tool)}`; } function childAgentLargeOutputSummary(summary) { @@ -8166,10 +8878,10 @@

Run History

return displayToken(run.phase || run.status); } - function recentRunSummary(run, lane = null) { - if ((lane?.attempt_count ?? 1) > 1) { - return `Latest attempt ${displayToken(run.status || run.phase)}; earlier attempts are in the timeline.`; - } + function recentRunSummary(run, lane = null) { + if ((lane?.attempt_count ?? 1) > 1) { + return `Latest run ${displayToken(run.status || run.phase)}; lifecycle cost is grouped by phase.`; + } if (isSuccessfulTerminalRun(run)) { return "Finished; no active lane."; } @@ -8265,6 +8977,40 @@

Run History

`; } + function historyLifecycleFacts(lane) { + const metrics = historyLaneLifecycleMetrics(lane); + const facts = [ + ["Lifecycle tokens", historyLifecycleTokenSummary(metrics)], + ["Lifecycle activity", formatDuration(lifecycleNumber(metrics.wall_seconds))], + ["Tool calls", formatCompactCount(metrics.tool_call_count)], + ["Captured attempts", historyLifecycleCaptureSummary(metrics)], + ]; + + if (metrics.largest_tool_output_bytes != null) { + const tool = metrics.largest_tool_output_tool || "tool"; + facts.push([ + "Largest output", + formatLargestOutputValue(metrics.largest_tool_output_bytes, displayToken(tool)), + ]); + } + + return facts; + } + + function renderHistoryLifecycleFacts(lane) { + const facts = historyLifecycleFacts(lane); + + if (!facts.length) { + return ""; + } + + return ` +
+ ${facts.map(([label, value]) => field(label, value)).join("")} +
+ `; + } + function activeRunSummary(run) { if (runTelemetryMissingNeedsAttention(run)) { if (runHasChildAgentActivity(run)) { @@ -9936,26 +10682,29 @@

${escapeHtml(issueTitle)}

); } - function renderAttemptTimeline(lane) { - const attempts = lane.attempts ?? []; + function renderPhaseBreakdown(lane) { + const phases = historyLaneLifecycleMetrics(lane).phases || []; - if (attempts.length <= 1) { + if (!phases.length) { return ""; } return ` -
- ${escapeHtml(pluralize(attempts.length, "attempt"))} -
- ${attempts +
+ ${escapeHtml(pluralize(phases.length, "phase"))} +
+ ${phases .map( - (attempt) => ` -
- #${escapeHtml(attempt.attempt_number ?? "none")} - ${escapeHtml(displayToken(attempt.status || attempt.phase || "unknown"))} - ${escapeHtml(displayToken(attempt.current_operation || attempt.phase || "idle"))} - ${escapeHtml(formatTimestampCompact(attempt.updated_at))} - ${escapeHtml(attempt.run_id || "none")} + (phase) => ` +
+ ${escapeHtml(phase.label)} + + ${escapeHtml(String(phase.attempt_count))} ${escapeHtml(phase.attempt_count === 1 ? "attempt" : "attempts")} + ${escapeHtml(historyLifecycleTokenSummary(phase))} + ${escapeHtml(formatDuration(phase.wall_seconds))} + ${escapeHtml(formatCompactCount(phase.tool_call_count))} tools + ${escapeHtml(formatCompactCount(phase.protocol_event_count))} events +
`, ) @@ -10023,10 +10772,11 @@

${escapeHtml(title)}

${escapeHtml(summary)}

${statusBits.join("")}
${renderHistoryTimingStrip(lane)} + ${renderHistoryLifecycleFacts(lane)} ${renderHistoryLedgerFacts(lane)} - ${renderAttemptTimeline(lane)} + ${renderPhaseBreakdown(lane)}
- Latest Attempt Details + Latest Run Details
${field("Run", run.run_id)} ${field("Issue id", run.issue_id)} @@ -10034,6 +10784,7 @@

${escapeHtml(title)}

${field("Codex thread", runThreadSummary(run))} ${field(COPY.protocolEvent, protocolEventSummary(run))} ${field("Protocol activity", protocolActivityDebugSummary(run))} + ${field("Lifecycle totals", historyLifecycleTokenSummary(historyLaneLifecycleMetrics(lane)))} ${field("Branch", run.branch_name || "none")} ${field("Worktree", run.worktree_path || "none")} ${field("Model", runModelSummary(run))} diff --git a/apps/decodex/src/orchestrator/status.rs b/apps/decodex/src/orchestrator/status.rs index 257496d07..38803f12c 100644 --- a/apps/decodex/src/orchestrator/status.rs +++ b/apps/decodex/src/orchestrator/status.rs @@ -53,6 +53,12 @@ enum RunIssueMetadataHydration { ActiveRowsOnly, } +enum TrackerObserverOutcome { + Ok, + Unavailable, + RateLimited(TrackerConnectorBackoff), +} + #[derive(Clone, Copy)] struct LiveOperatorStatusSnapshotOptions { hydrate_history_ledger: bool, @@ -60,12 +66,6 @@ struct LiveOperatorStatusSnapshotOptions { account_activity_mode: AccountActivityMode, } -enum TrackerObserverOutcome { - Ok, - Unavailable, - RateLimited(TrackerConnectorBackoff), -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct PostReviewReadbackDegradation<'a> { reason: &'a str, @@ -273,6 +273,12 @@ struct WorktreeOwnership { audit_required: bool, } +struct OperatorLifecycleMetricPhase { + key: &'static str, + label: &'static str, + rank: u8, +} + pub(crate) fn ensure_project_has_no_merged_worktree_cleanup_debt( project: &ServiceConfig, ) -> crate::prelude::Result<()> { @@ -466,6 +472,8 @@ fn operator_active_run_statuses( } } + hydrate_active_run_lifecycle_metrics(&mut active_runs, recent_runs); + Ok(active_runs) } @@ -5443,9 +5451,11 @@ fn operator_run_status( &protocol_summary, now_unix_epoch, ); - let child_agent_activity = operator_run_child_agent_activity(marker.as_ref(), now_unix_epoch); + let child_agent_activity = + operator_run_child_agent_activity(marker.as_ref(), run.child_agent_activity(), now_unix_epoch); let protocol_activity = operator_run_protocol_activity( marker.as_ref(), + run.protocol_activity(), &app_server_state, child_agent_activity.as_ref(), timing.protocol_idle_for_seconds, @@ -5532,14 +5542,15 @@ fn operator_run_status( effective_cwd: app_server_state.effective_cwd, effective_approval_policy: app_server_state.effective_approval_policy, effective_approvals_reviewer: app_server_state.effective_approvals_reviewer, - effective_sandbox_mode: app_server_state.effective_sandbox_mode, - child_agent_activity, - protocol_activity, - account, - accounts, - branch_name, - worktree_path, - }) + effective_sandbox_mode: app_server_state.effective_sandbox_mode, + child_agent_activity, + protocol_activity, + lifecycle_metrics: OperatorLaneLifecycleMetrics::default(), + account, + accounts, + branch_name, + worktree_path, + }) } fn operator_run_lifecycle_projection( @@ -6464,9 +6475,13 @@ fn process_liveness_reason_is_identity_mismatch(reason: Option<&str>) -> bool { fn operator_run_child_agent_activity( marker: Option<&RunActivityMarker>, + stored_summary: Option<&ChildAgentActivitySummary>, now_unix_epoch: i64, ) -> Option { - let mut summary = marker.and_then(RunActivityMarker::child_agent_activity).cloned()?; + let mut summary = marker + .and_then(RunActivityMarker::child_agent_activity) + .or(stored_summary) + .cloned()?; summary.current_elapsed_seconds = summary.current_started_unix_epoch.and_then(|started_at| { @@ -6493,12 +6508,17 @@ fn operator_run_child_agent_activity( fn operator_run_protocol_activity( marker: Option<&RunActivityMarker>, + stored_summary: Option<&ProtocolActivitySummary>, app_server_state: &OperatorRunAppServerState, child_agent_activity: Option<&ChildAgentActivitySummary>, protocol_idle_for_seconds: Option, is_running: bool, ) -> Option { - let mut summary = marker.and_then(RunActivityMarker::protocol_activity).cloned().unwrap_or_default(); + let mut summary = marker + .and_then(RunActivityMarker::protocol_activity) + .or(stored_summary) + .cloned() + .unwrap_or_default(); if is_running && summary.waiting_reason.is_none() && app_server_state.interactive_requested { summary.waiting_reason = Some(String::from("approval_or_user_input")); @@ -7216,10 +7236,16 @@ fn operator_history_lanes( lane.attempts.push(run.clone()); + lane.lifecycle_metrics = operator_lane_lifecycle_metrics(&lane.attempts); + continue; } lane_indexes.insert(group_key, lanes.len()); + + let attempts = vec![run.clone()]; + let lifecycle_metrics = operator_lane_lifecycle_metrics(&attempts); + lanes.push(OperatorHistoryLaneStatus { project_id: run.project_id.clone(), issue_id: run.issue_id.clone(), @@ -7232,8 +7258,9 @@ fn operator_history_lanes( issue_key: operator_run_issue_key(run), attempt_count: 1, ledger_outcome: not_loaded_history_ledger_outcome(), + lifecycle_metrics, latest_run: run.clone(), - attempts: vec![run.clone()], + attempts, }); } @@ -7258,6 +7285,241 @@ fn hydrate_history_lane_from_run(lane: &mut OperatorHistoryLaneStatus, run: &Ope } } +fn hydrate_active_run_lifecycle_metrics( + active_runs: &mut [OperatorRunStatus], + recent_runs: &[OperatorRunStatus], +) { + for active_run in active_runs { + let group_key = operator_run_group_key(active_run); + let active_run_id = active_run.run_id.clone(); + let active_snapshot = active_run.clone(); + let mut attempts = Vec::new(); + let mut captured_active_snapshot = false; + + for run in recent_runs + .iter() + .filter(|run| operator_run_group_key(run) == group_key) + { + if run.run_id == active_run_id { + captured_active_snapshot = true; + + attempts.push(active_snapshot.clone()); + } else { + attempts.push(run.clone()); + } + } + + if !captured_active_snapshot { + attempts.push(active_snapshot); + } + + active_run.lifecycle_metrics = operator_lane_lifecycle_metrics(&attempts); + } +} + +fn operator_lane_lifecycle_metrics( + attempts: &[OperatorRunStatus], +) -> OperatorLaneLifecycleMetrics { + let mut metrics = operator_lane_lifecycle_totals(attempts.iter()); + + metrics.phases = operator_lane_lifecycle_phase_metrics(attempts); + + metrics +} + +fn operator_lane_lifecycle_totals<'a>( + runs: impl IntoIterator, +) -> OperatorLaneLifecycleMetrics { + let mut bucket_totals = HashMap::::new(); + let mut warning_set = HashSet::::new(); + let mut run_ids = HashSet::::new(); + let mut metrics = OperatorLaneLifecycleMetrics::default(); + + for run in runs { + metrics.attempt_count += 1; + + run_ids.insert(run.run_id.clone()); + + metrics.protocol_event_count = metrics + .protocol_event_count + .saturating_add(run.event_count.max(0)); + + let Some(summary) = run.child_agent_activity.as_ref() else { + continue; + }; + + metrics.captured_attempt_count += 1; + metrics.child_event_count = metrics + .child_event_count + .saturating_add(summary.event_count.max(0)); + metrics.wall_seconds = metrics.wall_seconds.saturating_add(summary.wall_seconds.max(0)); + metrics.tool_call_count = metrics + .tool_call_count + .saturating_add(summary.tool_call_count.max(0)); + metrics.input_tokens_current = + max_optional_i64(metrics.input_tokens_current, summary.input_tokens_current); + metrics.input_tokens_peak = max_optional_i64(metrics.input_tokens_peak, summary.input_tokens_max); + metrics.input_tokens_cumulative = metrics + .input_tokens_cumulative + .saturating_add(summary.input_tokens_cumulative.max(0)); + metrics.output_tokens_cumulative = metrics + .output_tokens_cumulative + .saturating_add(summary.output_tokens_cumulative.max(0)); + + if summary + .largest_tool_output_bytes + .is_some_and(|bytes| metrics.largest_tool_output_bytes.is_none_or(|current| bytes > current)) + { + metrics.largest_tool_output_bytes = summary.largest_tool_output_bytes; + metrics.largest_tool_output_tool = summary.largest_tool_output_tool.clone(); + } + + for warning in &summary.large_output_warnings { + if !warning.trim().is_empty() { + warning_set.insert(warning.clone()); + } + } + for bucket in &summary.buckets { + let total = bucket_totals + .entry(bucket.name.clone()) + .or_insert_with(|| ChildAgentActivityBucket { + name: bucket.name.clone(), + ..ChildAgentActivityBucket::default() + }); + + total.wall_seconds = total.wall_seconds.saturating_add(bucket.wall_seconds.max(0)); + total.event_count = total.event_count.saturating_add(bucket.event_count.max(0)); + total.tool_call_count = total + .tool_call_count + .saturating_add(bucket.tool_call_count.max(0)); + total.input_tokens = total.input_tokens.saturating_add(bucket.input_tokens.max(0)); + total.output_tokens = total.output_tokens.saturating_add(bucket.output_tokens.max(0)); + total.output_bytes = total.output_bytes.saturating_add(bucket.output_bytes.max(0)); + } + } + + metrics.missing_attempt_count = metrics + .attempt_count + .saturating_sub(metrics.captured_attempt_count); + metrics.run_count = run_ids.len(); + metrics.large_output_warnings = warning_set.into_iter().collect(); + + metrics.large_output_warnings.sort(); + + metrics.buckets = bucket_totals.into_values().collect(); + + metrics.buckets.sort_by(|left, right| { + right + .wall_seconds + .cmp(&left.wall_seconds) + .then_with(|| right.event_count.cmp(&left.event_count)) + .then_with(|| left.name.cmp(&right.name)) + }); + + metrics +} + +fn operator_lane_lifecycle_phase_metrics( + attempts: &[OperatorRunStatus], +) -> Vec { + let mut groups = HashMap::)>::new(); + + for run in attempts { + let phase = operator_run_lifecycle_metric_phase(run); + let entry = groups.entry(phase.key.to_owned()).or_insert_with(|| { + (phase.label.to_owned(), phase.rank, Vec::new()) + }); + + entry.2.push(run); + } + + let mut phases = groups + .into_iter() + .map(|(phase, (label, rank, runs))| { + let totals = operator_lane_lifecycle_totals(runs); + + ( + rank, + OperatorLaneLifecyclePhaseMetrics { + phase, + label, + attempt_count: totals.attempt_count, + run_count: totals.run_count, + captured_attempt_count: totals.captured_attempt_count, + missing_attempt_count: totals.missing_attempt_count, + protocol_event_count: totals.protocol_event_count, + child_event_count: totals.child_event_count, + wall_seconds: totals.wall_seconds, + tool_call_count: totals.tool_call_count, + input_tokens_current: totals.input_tokens_current, + input_tokens_peak: totals.input_tokens_peak, + input_tokens_cumulative: totals.input_tokens_cumulative, + output_tokens_cumulative: totals.output_tokens_cumulative, + largest_tool_output_bytes: totals.largest_tool_output_bytes, + largest_tool_output_tool: totals.largest_tool_output_tool, + large_output_warnings: totals.large_output_warnings, + buckets: totals.buckets, + }, + ) + }) + .collect::>(); + + phases.sort_by(|(left_rank, left), (right_rank, right)| { + left_rank + .cmp(right_rank) + .then_with(|| left.phase.cmp(&right.phase)) + }); + + phases.into_iter().map(|(_rank, phase)| phase).collect() +} + +fn operator_run_lifecycle_metric_phase(run: &OperatorRunStatus) -> OperatorLifecycleMetricPhase { + if matches!( + run.status.as_str(), + "cleanup_complete" | "closeout" | "closeout_pending" | "landed" + ) { + return operator_lifecycle_metric_phase("closeout", "Closeout", 30); + } + if matches!( + run.status.as_str(), + "manual_attention" | "manual_attention_pending" | "needs_attention" | "terminal_failure" + ) || run.phase == "needs_attention" + { + return operator_lifecycle_metric_phase("manual_attention", "Manual attention", 40); + } + + if let Some(review_phase) = run + .loop_status + .as_ref() + .and_then(|status| status.review.as_ref()) + .map(|review| review.phase.as_str()) + { + return match review_phase { + "repair" => operator_lifecycle_metric_phase("review_repair", "Review repair", 20), + _ => operator_lifecycle_metric_phase("review", "Review", 10), + }; + } + + if run.status == "review_repair_pending" { + return operator_lifecycle_metric_phase("review_repair", "Review repair", 20); + } + if run.status == "review_handoff_pending" + || run.current_operation == RUN_OPERATION_REVIEW_WRITEBACK + { + return operator_lifecycle_metric_phase("review", "Review", 10); + } + + operator_lifecycle_metric_phase("development", "Development", 0) +} + +fn operator_lifecycle_metric_phase( + key: &'static str, + label: &'static str, + rank: u8, +) -> OperatorLifecycleMetricPhase { + OperatorLifecycleMetricPhase { key, label, rank } +} + fn operator_run_group_key(run: &OperatorRunStatus) -> String { let issue_id = run.issue_id.trim(); @@ -7810,6 +8072,11 @@ fn append_rendered_history_lane(output: &mut String, lane: &OperatorHistoryLaneS append_rendered_history_ledger_outcome(output, &lane.ledger_outcome); + output.push_str(&format!( + " lifecycle_metrics: {}\n", + render_lane_lifecycle_metrics(&lane.lifecycle_metrics) + )); + if history_ledger_outcome_has_records(&lane.ledger_outcome) { output.push_str(&format!( " local_attempts: {}\n latest_run_id: {}\n", @@ -7818,24 +8085,46 @@ fn append_rendered_history_lane(output: &mut String, lane: &OperatorHistoryLaneS } else { append_rendered_run(output, &lane.latest_run); } - if lane.attempts.len() <= 1 { + if lane.lifecycle_metrics.phases.is_empty() { return; } - output.push_str(" attempt_timeline:\n"); + output.push_str(" phase_breakdown:\n"); - for attempt in &lane.attempts { + for phase in &lane.lifecycle_metrics.phases { output.push_str(&format!( - " - run_id: {} attempt: {} status: {} phase: {} updated_at: {}\n", - attempt.run_id, - attempt.attempt_number, - attempt.status, - attempt.phase, - attempt.updated_at + " - phase: {} label: {} attempts: {} captured: {}/{} protocol_events: {} child_events: {} wall: {} tool_calls: {} input_tokens: {} output_tokens: {}\n", + phase.phase, + phase.label, + phase.attempt_count, + phase.captured_attempt_count, + phase.attempt_count, + phase.protocol_event_count, + phase.child_event_count, + format_seconds_compact(phase.wall_seconds), + phase.tool_call_count, + phase.input_tokens_cumulative, + phase.output_tokens_cumulative, )); } } +fn render_lane_lifecycle_metrics(metrics: &OperatorLaneLifecycleMetrics) -> String { + format!( + "attempts={}; captured={}/{}; missing={}; protocol_events={}; child_events={}; wall={}; tool_calls={}; input_tokens={}; output_tokens={}", + metrics.attempt_count, + metrics.captured_attempt_count, + metrics.attempt_count, + metrics.missing_attempt_count, + metrics.protocol_event_count, + metrics.child_event_count, + format_seconds_compact(metrics.wall_seconds), + metrics.tool_call_count, + metrics.input_tokens_cumulative, + metrics.output_tokens_cumulative, + ) +} + fn append_rendered_history_ledger_outcome( output: &mut String, outcome: &OperatorHistoryLedgerOutcome, diff --git a/apps/decodex/src/orchestrator/tests.rs b/apps/decodex/src/orchestrator/tests.rs index 6dd8f9a84..23ce167ea 100644 --- a/apps/decodex/src/orchestrator/tests.rs +++ b/apps/decodex/src/orchestrator/tests.rs @@ -44,7 +44,8 @@ use crate::state::{ self, ChildAgentActivitySummary, CodexAccountActivitySummary, CodexAccountMarker, EffectiveRuntimeMarker, ProjectRegistration, ProtocolActivityMarker, ProtocolActivitySummary, RUN_ACTIVITY_MARKER_FILE, RUN_OPERATION_AGENT_RUN, RUN_OPERATION_GIT_CREDENTIALS, - RUN_OPERATION_RECONCILIATION, RUN_OPERATION_REPO_GATE, StateStore, WorktreeMapping, + RUN_OPERATION_RECONCILIATION, RUN_OPERATION_REPO_GATE, ReviewPolicyCheckpointInput, + StateStore, WorktreeMapping, }; use crate::test_support::TestEnvVarGuard; #[rustfmt::skip] diff --git a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs index a20a1015c..2a4145723 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs @@ -312,108 +312,245 @@ fn operator_dashboard_patches_active_run_cards_without_replacing_the_list() { fn operator_dashboard_child_bucket_rows_split_time_bars_from_event_diagnostics() { let response = dashboard_response(); - assert!(response.contains("childBucketIsSubsecond")); - assert!(response.contains("childBucketIsEventOnly")); - assert!(response.contains("childBucketEventSignals")); - assert!(response.contains("childBucketEventSummary")); - assert!(response.contains("childBucketDiagnosticSignals")); - assert!(response.contains("childBucketDiagnosticSummary")); - assert!(response.contains("renderChildBucketDiagnosticSignals")); - assert!(response.contains("childBucketHasMeaningfulWallShare")); - assert!(response.contains("childAgentLargeOutputWarnings")); - assert!(response.contains("childAgentLargeOutputSummary")); - assert!(response.contains("childBucketShareLabel")); - assert!(response.contains("childBucketWidth")); - assert!(response.contains("function renderMetricText(text)")); - assert!(response.contains("function setMetricText(node, text)")); - assert!(response.contains("function setPanelMeta(node, text, tone = \"\")")); - assert!(response.contains("function pluralLabel(count, singular, plural = `${singular}s`)")); - assert!(response.contains("return `${count} ${pluralLabel(count, singular, plural)}`;")); - assert!(response.contains("pluralLabel(notices.length, \"alert\")")); - assert!(response.contains("pluralLabel(notices.length, \"warning\")")); - assert!(response.contains("child-bucket is-share")); - assert!(response.contains("child-bucket is-diagnostic")); - assert!(response.contains("child-bucket is-event-only")); - assert!(response.contains("child-bucket-signals")); - assert!(response.contains("child-bucket-signal")); - assert!(response.contains("data-duration=\"wall-share\"")); - assert!(response.contains("data-duration=\"event-diagnostics\"")); - assert!(response.contains("data-duration=\"diagnostic\"")); - assert!(response.contains("function childDiagnosticBucketRank(bucket)")); - assert!(response.contains("summary.current_detail")); - assert!(response.contains("detailLabel(displayToken(summary.current_detail || summary.current_bucket))")); - assert!(response.contains("return `${label} · ${formatDuration(summary.current_elapsed_seconds)}`;")); - assert!(response.contains("Activity")); - assert!(response.contains(".child-activity-head {\n\t\t\t\tdisplay: grid;\n\t\t\t\tgrid-template-columns: 96px minmax(0, 1fr);\n\t\t\t\talign-items: baseline;\n\t\t\t\tgap: 10px;")); - assert!(!response.contains("titleCaseLabel(\"agent activity\")")); - assert!(!response.contains("agent activity")); - assert!(!response.contains("Agent Activity")); - assert!(response.contains("const current = childAgentCurrentSummary(summary) || \"none\";")); - assert!(!response.contains("Agent Now")); - assert!(!response.contains("No active child bucket")); - assert!(response.contains("[\"current window\", latestInput")); - assert!(response.contains("\"peak window\",")); - assert!(response.contains("\"cumulative input\",")); - assert!(response.contains("[\"tool calls\", String(summary.tool_call_count ?? 0)]")); - assert!(response.contains("\"largest output\",")); - assert!(response.contains("tool calls")); - assert!(response.contains("output bytes")); - assert!(response.contains("Largest tool output; debug details show attribution.")); - assert!(response.contains("field(\"Large outputs\", childAgentLargeOutputSummary(childAgentActivity(run)))")); - assert!(!response.contains("events only")); - assert!(!response.contains("child-warning")); - assert!(!response.contains("${warnings.length ? `
")); - assert!(!response.contains("warnings.join(\" · \")")); - assert!(!response.contains("summary.largest_tool_output_tool || \"tool\"")); - assert!(!response.contains("child-bucket is-subsecond")); - assert!(!response.contains("data-duration=\"events-only\"")); - assert!(!response.contains("child-bucket.is-event-only .child-bucket-bar::before")); - assert!(response.contains("--child-bucket-value-column: clamp(190px, 18vw, 230px);")); - assert!(response.contains("grid-template-columns: 96px minmax(64px, 1fr) var(--child-bucket-value-column);")); - assert!(response.contains("width: var(--child-bucket-value-column);")); - assert!(response.contains("runningLaneMetaText")); - assert!(response.contains("const parts = [`${derived.liveRuns ?? 0} running`];")); - assert!(response.contains("attentionCount === 1")); - assert!(response.contains("nodes.activeRunsMeta,")); - assert!(response.contains("runningLaneMetaText(derived),")); - assert!(!response.contains("Snapshot pending")); - assert!(!response.contains("COPY.waitingSnapshot")); - assert!(!response.contains("const parts = [`${derived.liveRuns} live`];")); - assert!(!response.contains("parts.push(`${derived.runningAttentionCount} stalled`)")); - assert!(response.contains("runStaleWithoutKnownProcessNeedsAttention")); - assert!(response.contains("runExecutionLivenessSummary")); - assert!(response.contains("runQueueLeaseSummary")); - assert!(response.contains("return displayToken(run.execution_liveness || \"liveness_unknown\");")); - assert!(!response.contains("return \"Process alive\";")); - assert!(!response.contains("lease not held")); - assert!(response.contains("field(\"Attempt status\", run.attempt_status || run.status)")); - assert!(response.contains("field(\"Queue lease\", runQueueLeaseSummary(run))")); - assert!(response.contains("field(\"Execution liveness\", runExecutionLivenessSummary(run))")); - assert!(response.contains("live_no_queue_lease")); - assert!(response.contains("return `${leaseState}; ${displayToken(run.execution_liveness || \"liveness_unknown\")}`;")); - assert!(!response.contains("Queue ownership")); - assert!(response.contains("attention.worktree_path")); - assert!(response.contains("candidate.attention?.attention_error_class")); - assert!(response.contains("facts.push([\"Cause\", displayToken(attention.attention_error_class)]);")); - assert!(response.contains("queued attention")); - assert!(response.contains("worktree.ownership_reason")); - assert!(response.contains("const hygiene = worktree.hygiene;")); - assert!(response.contains("hygiene.classification === \"merged_dirty_worktree\"")); - assert!(response.contains("post-land cleanup blocked")); - assert!(response.contains("post-land cleanup")); - assert!(response.contains("post-review cleanup blocked")); - assert!(response.contains("hygiene.reason ||")); - assert!(response.contains("function renderWorktreeHygieneFields(worktree)")); - assert!(response.contains("field(\"Cleanup state\", displayToken(hygiene.classification || \"cleanup_pending\"))")); - assert!(response.contains("field(\"Default branch\", hygiene.default_branch || \"unknown\")")); - assert!(response.contains("field(\"Uncommitted changes\", hygiene.dirty ? \"yes\" : \"no\")")); - assert!(response.contains("local cleanup")); - assert!(response.contains( - "Owned by Intake Queue attention; recover there before cleanup." - )); - assert!(response.contains( - "No lane owns this worktree; inspect before cleanup." - )); + assert_child_bucket_contract(&response); + assert_child_activity_header_contract(&response); + assert_child_lifecycle_contract(&response); + assert_running_lane_meta_contract(&response); + assert_liveness_and_cleanup_contract(&response); +} + +fn assert_contains_all(response: &str, snippets: &[&str]) { + for snippet in snippets { + assert!( + response.contains(snippet), + "dashboard response should contain {snippet:?}" + ); + } +} + +fn assert_excludes_all(response: &str, snippets: &[&str]) { + for snippet in snippets { + assert!( + !response.contains(snippet), + "dashboard response should not contain {snippet:?}" + ); + } +} + +fn assert_child_bucket_contract(response: &str) { + assert_contains_all( + response, + &[ + "childBucketIsSubsecond", + "childBucketIsEventOnly", + "childBucketEventSignals", + "childBucketEventSummary", + "childBucketDiagnosticSignals", + "childBucketDiagnosticSummary", + "renderChildBucketDiagnosticSignals", + "childBucketHasMeaningfulWallShare", + "childAgentLargeOutputWarnings", + "childAgentLargeOutputSummary", + "childBucketShareLabel", + "childBucketWidth", + "function childBucketIsPrimaryShareBucket(bucket)", + "function childBucketIsLifecycleTotalBucket(bucket)", + "childBucketIsPrimaryShareBucket(bucket) &&", + "!childBucketIsLifecycleTotalBucket(bucket) &&", + "child-bucket is-share", + "child-bucket is-diagnostic", + "child-bucket is-event-only", + "child-bucket-signals", + "child-bucket-signal", + "data-duration=\"wall-share\"", + "data-duration=\"event-diagnostics\"", + "data-duration=\"diagnostic\"", + "function childDiagnosticBucketRank(bucket)", + "--child-bucket-value-column: clamp(190px, 18vw, 230px);", + "grid-template-columns: 96px minmax(64px, 1fr) var(--child-bucket-value-column);", + "width: var(--child-bucket-value-column);", + ], + ); + assert_excludes_all( + response, + &[ + "events only", + "child-warning", + "${warnings.length ? `
", + "warnings.join(\" · \")", + "summary.largest_tool_output_tool || \"tool\"", + "child-bucket is-subsecond", + "data-duration=\"events-only\"", + "child-bucket.is-event-only .child-bucket-bar::before", + ], + ); +} + +fn assert_child_activity_header_contract(response: &str) { + assert_contains_all( + response, + &[ + "function renderMetricText(text)", + "function setMetricText(node, text)", + "function setPanelMeta(node, text, tone = \"\")", + "function pluralLabel(count, singular, plural = `${singular}s`)", + "return `${count} ${pluralLabel(count, singular, plural)}`;", + "pluralLabel(notices.length, \"alert\")", + "pluralLabel(notices.length, \"warning\")", + "summary.current_detail", + "detailLabel(displayToken(summary.current_detail || summary.current_bucket))", + "return `${label} · ${formatDuration(summary.current_elapsed_seconds)}`;", + "function runProjectSummary(run)", + "
", + "Project", + "${escapeHtml(runProjectSummary(run))}", + "Activity", + ".child-activity-head {\n\t\t\t\tdisplay: grid;\n\t\t\t\tgrid-template-columns: 96px minmax(0, 1fr);\n\t\t\t\talign-items: baseline;\n\t\t\t\tgap: 10px;", + "const current = childAgentCurrentSummary(summary) || \"none\";", + "[\"current window\", latestInput", + "\"peak window\",", + ], + ); + assert_excludes_all( + response, + &[ + "titleCaseLabel(\"agent activity\")", + "agent activity", + "Agent Activity", + "Agent Now", + "No active child bucket", + ], + ); +} + +fn assert_child_lifecycle_contract(response: &str) { + assert_contains_all( + response, + &[ + "function renderChildLifecycleOverview(lifecycle, contextFacts)", + "function renderChildLifecyclePhaseTable(phases)", + "
", + "", + "repeat(4, max-content max-content);", + ".child-total-segment {\n\t\t\t\tdisplay: contents;", + "
", + ".child-phase-table {\n\t\t\t\tdisplay: inline-grid;\n\t\t\t\tgrid-template-columns:\n\t\t\t\t\tmax-content", + "gap: 4px clamp(24px, 2vw, 34px);", + "overflow: hidden;", + "text-overflow: ellipsis;", + "const header = [\"Stage\", \"attempts\", \"inference\", \"input\", \"output\", \"tools\", \"max output\"];", + "const alignRight = new Set([1, 2, 3, 4, 5, 6]);", + "width: fit-content;\n\t\t\t\tmax-width: 100%;", + "appendLifecycleMetricSegment(\n\t\t\t\t\t\t\ttoolSegments,\n\t\t\t\t\t\t\tformatLargestOutputValue(largestOutput, lifecycle?.largest_tool_output_tool),\n\t\t\t\t\t\t\t\"max output\",", + "\"tools\"", + "output bytes", + "field(\"Large outputs\", childAgentLargeOutputSummary(childAgentActivity(run)))", + ], + ); + assert_excludes_all( + response, + &[ + "--child-total-segment-width", + "--child-total-column-template", + "child-total-control", + "child-total-separator", + ".child-phase-table-cell:nth-child(7n)", + "input / output", + "tools / largest output", + "rows.push(renderChildContextRow(\"Total\", totalFacts, \"is-total\"));", + "\"cumulative input\",", + "[\"tool calls\", String(summary.tool_call_count ?? 0)]", + "[\"largest output\",", + "\"model time\"", + "\"input tokens\"", + "\"output tokens\"", + ], + ); +} + +fn assert_running_lane_meta_contract(response: &str) { + assert_contains_all( + response, + &[ + "runningLaneMetaText", + "const parts = [`${derived.liveRuns ?? 0} running`];", + "attentionCount === 1", + "nodes.activeRunsMeta,", + "runningLaneMetaText(derived),", + ], + ); + assert_excludes_all( + response, + &[ + "Snapshot pending", + "COPY.waitingSnapshot", + "const parts = [`${derived.liveRuns} live`];", + "parts.push(`${derived.runningAttentionCount} stalled`)", + ], + ); +} + +fn assert_liveness_and_cleanup_contract(response: &str) { + assert_contains_all( + response, + &[ + "runStaleWithoutKnownProcessNeedsAttention", + "runExecutionLivenessSummary", + "runQueueLeaseSummary", + "return displayToken(run.execution_liveness || \"liveness_unknown\");", + "field(\"Attempt status\", run.attempt_status || run.status)", + "field(\"Queue lease\", runQueueLeaseSummary(run))", + "field(\"Execution liveness\", runExecutionLivenessSummary(run))", + "live_no_queue_lease", + "return `${leaseState}; ${displayToken(run.execution_liveness || \"liveness_unknown\")}`;", + "attention.worktree_path", + "candidate.attention?.attention_error_class", + "facts.push([\"Cause\", displayToken(attention.attention_error_class)]);", + "queued attention", + "worktree.ownership_reason", + "const hygiene = worktree.hygiene;", + "hygiene.classification === \"merged_dirty_worktree\"", + "post-land cleanup blocked", + "post-land cleanup", + "post-review cleanup blocked", + "hygiene.reason ||", + "function renderWorktreeHygieneFields(worktree)", + "field(\"Cleanup state\", displayToken(hygiene.classification || \"cleanup_pending\"))", + "field(\"Default branch\", hygiene.default_branch || \"unknown\")", + "field(\"Uncommitted changes\", hygiene.dirty ? \"yes\" : \"no\")", + "local cleanup", + "Owned by Intake Queue attention; recover there before cleanup.", + "No lane owns this worktree; inspect before cleanup.", + ], + ); + assert_excludes_all( + response, + &["return \"Process alive\";", "lease not held", "Queue ownership"], + ); +} + +#[test] +fn operator_dashboard_history_lifecycle_metrics_are_grouped_by_phase() { + let response = dashboard_response(); + + assert!(response.contains("function historyLaneLifecycleMetrics(lane)")); + assert!(response.contains("function normalizeLifecyclePhaseMetrics(phase)")); + assert!(response.contains("function renderHistoryLifecycleFacts(lane)")); + assert!(response.contains("function renderPhaseBreakdown(lane)")); + assert!(response.contains("Lifecycle tokens")); + assert!(response.contains("Captured attempts")); + assert!(response.contains("${renderHistoryLifecycleFacts(lane)}")); + assert!(response.contains("${renderPhaseBreakdown(lane)}")); + assert!(response.contains("phase-timeline")); + assert!(response.contains("phase-list")); + assert!(response.contains("phase-row")); + assert!(response.contains("phase-name")); + assert!(response.contains("phase-facts")); + assert!(response.contains("history-phases:${lane.issue_key}")); + assert!(!response.contains(".phase-row span:nth-child(n + 4)")); + assert!(!response.contains("function renderAttemptTimeline(lane)")); + assert!(!response.contains("history-attempts:${lane.issue_key}")); + assert!(!response.contains("attempt-timeline")); } #[test] @@ -1612,6 +1749,19 @@ fn operator_dashboard_active_freshness_prefers_live_activity_source() { assert!(response.contains("facts.push([\"lane idle\", formatDuration(run.idle_for_seconds)]);")); assert!(response.contains("facts.push([\"agent idle\", formatDuration(run.protocol_idle_for_seconds)]);")); assert!(response.contains("facts.push([\"focus\", detailLabel(focus)]);")); + assert!(response.contains("function activeRunLifecycleMetrics(run, summary = childAgentActivity(run))")); + assert!(response.contains("function lifecycleMetricFacts(metrics, { includeAttempts = false } = {})")); + assert!(response.contains("facts.push([\"tokens\", tokenSummary]);")); + assert!(response.contains("facts.push([\"tools\", formatCompactCount(metrics.tool_call_count)]);")); + assert!(response.contains("\"max output\",")); + assert!(response.contains("function childAgentContextRows(run, summary, lifecycle = activeRunLifecycleMetrics(run, summary))")); + assert!(response.contains("renderChildLifecycleOverview(lifecycle, contextFacts)")); + assert!(response.contains("renderChildLifecyclePhaseTable(lifecycle.phases || [])")); + assert!(!response.contains("rows.push(renderChildContextRow(\"Total\", totalFacts, \"is-total\"));")); + assert!(response.contains("
")); + assert!(response.contains(".child-phase-table {\n\t\t\t\tdisplay: inline-grid;\n\t\t\t\tgrid-template-columns:\n\t\t\t\t\tmax-content")); + assert!(!response.contains("function childAgentUsageFacts(summary)")); + assert!(!response.contains("Usage")); assert!(response.contains("renderRunMetaFact(label, value)")); assert!(!response.contains("sourceLabel: \"Live Activity\"")); assert!(!response.contains("facts.push([\"Lane Idle\", formatDuration(run.idle_for_seconds)]);")); @@ -1625,6 +1775,8 @@ fn operator_dashboard_active_freshness_prefers_live_activity_source() { assert!(response.contains("function activeRunTelemetryFacts(run)")); assert!(response.contains("function renderRunTelemetryMetaItems(run)")); assert!(response.contains("function renderRunMetaFact(label, value, valueClass = \"\", title = \"\")")); + assert!(!response.contains("renderActiveRunActivityStrip(run)")); + assert!(!response.contains("run-activity-strip")); assert!(!response.contains("function renderActiveTelemetryLine(run)")); assert!(!response.contains("activity-line")); assert!( diff --git a/apps/decodex/src/orchestrator/tests/operator/status/history.rs b/apps/decodex/src/orchestrator/tests/operator/status/history.rs index bbca4200a..c00e37210 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/history.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/history.rs @@ -69,6 +69,73 @@ fn operator_status_history_limit_applies_after_active_runs_are_split_out() { ); } +fn history_lane_child_activity( + wall_seconds: i64, + event_count: i64, + tool_call_count: i64, + input_tokens: i64, + output_tokens: i64, +) -> ChildAgentActivitySummary { + ChildAgentActivitySummary { + buckets: vec![state::ChildAgentActivityBucket { + name: String::from("Model"), + wall_seconds, + event_count, + tool_call_count, + input_tokens, + output_tokens, + output_bytes: 0, + }], + wall_seconds, + event_count, + tool_call_count, + input_tokens_cumulative: input_tokens, + output_tokens_cumulative: output_tokens, + ..ChildAgentActivitySummary::default() + } +} + +fn seed_grouped_history_lane_lifecycle_metrics(state_store: &StateStore, issue_id: &str) { + let first_activity = history_lane_child_activity(10, 2, 1, 100, 30); + let second_activity = history_lane_child_activity(20, 3, 4, 200, 40); + + state_store + .record_run_activity_summary( + "xy-323-attempt-1-1777361523", + 1, + Some(&first_activity), + None, + ) + .expect("first activity summary should record"); + state_store + .record_run_activity_summary( + "xy-323-attempt-2-1777361550", + 2, + Some(&second_activity), + None, + ) + .expect("second activity summary should record"); + state_store + .append_event("xy-323-attempt-2-1777361550", 1, "turn/started", "{}") + .expect("second protocol event should record"); + state_store + .append_event("xy-323-attempt-2-1777361550", 2, "turn/completed", "{}") + .expect("third protocol event should record"); + state_store + .upsert_review_policy_checkpoint(ReviewPolicyCheckpointInput { + project_id: TEST_SERVICE_ID, + issue_id, + run_id: "xy-323-attempt-2-1777361550", + attempt_number: 2, + phase: "handoff", + status: "clean", + head_sha: "2222222222222222222222222222222222222222", + nonclean_rounds: 0, + details_json: "{}", + }) + .expect("second attempt review checkpoint should record"); +} + #[test] fn operator_status_history_lanes_group_attempts_by_issue() { let (_temp_dir, config, _workflow) = temp_project_layout(); @@ -116,6 +183,8 @@ fn operator_status_history_lanes_group_attempts_by_issue() { ) .expect("second issue worktree should record"); + seed_grouped_history_lane_lifecycle_metrics(&state_store, &first_issue.id); + let snapshot = orchestrator::build_operator_status_snapshot(&config, &state_store, 10) .expect("snapshot should build"); let rendered = orchestrator::render_operator_status(&snapshot); @@ -129,10 +198,57 @@ fn operator_status_history_lanes_group_attempts_by_issue() { assert_eq!(snapshot.history_lanes.len(), 2); assert_eq!(grouped_lane.attempt_count, 2); assert_eq!(grouped_lane.latest_run.run_id, "xy-323-attempt-2-1777361550"); + assert_eq!(grouped_lane.lifecycle_metrics.attempt_count, 2); + assert_eq!(grouped_lane.lifecycle_metrics.captured_attempt_count, 2); + assert_eq!(grouped_lane.lifecycle_metrics.missing_attempt_count, 0); + assert_eq!(grouped_lane.lifecycle_metrics.protocol_event_count, 2); + assert_eq!(grouped_lane.lifecycle_metrics.child_event_count, 5); + assert_eq!(grouped_lane.lifecycle_metrics.wall_seconds, 30); + assert_eq!(grouped_lane.lifecycle_metrics.tool_call_count, 5); + assert_eq!(grouped_lane.lifecycle_metrics.input_tokens_cumulative, 300); + assert_eq!(grouped_lane.lifecycle_metrics.output_tokens_cumulative, 70); + assert_eq!(grouped_lane.lifecycle_metrics.buckets[0].name, "Model"); + assert_eq!(grouped_lane.lifecycle_metrics.buckets[0].wall_seconds, 30); + assert_eq!(grouped_lane.lifecycle_metrics.phases.len(), 2); + assert_eq!(grouped_lane.lifecycle_metrics.phases[0].phase, "development"); + assert_eq!(grouped_lane.lifecycle_metrics.phases[0].label, "Development"); + assert_eq!(grouped_lane.lifecycle_metrics.phases[0].attempt_count, 1); + assert_eq!(grouped_lane.lifecycle_metrics.phases[0].captured_attempt_count, 1); + assert_eq!(grouped_lane.lifecycle_metrics.phases[0].protocol_event_count, 0); + assert_eq!(grouped_lane.lifecycle_metrics.phases[0].child_event_count, 2); + assert_eq!(grouped_lane.lifecycle_metrics.phases[0].wall_seconds, 10); + assert_eq!(grouped_lane.lifecycle_metrics.phases[0].tool_call_count, 1); + assert_eq!( + grouped_lane.lifecycle_metrics.phases[0].input_tokens_cumulative, + 100 + ); + assert_eq!( + grouped_lane.lifecycle_metrics.phases[0].output_tokens_cumulative, + 30 + ); + assert_eq!(grouped_lane.lifecycle_metrics.phases[1].phase, "review"); + assert_eq!(grouped_lane.lifecycle_metrics.phases[1].label, "Review"); + assert_eq!(grouped_lane.lifecycle_metrics.phases[1].attempt_count, 1); + assert_eq!(grouped_lane.lifecycle_metrics.phases[1].captured_attempt_count, 1); + assert_eq!(grouped_lane.lifecycle_metrics.phases[1].protocol_event_count, 2); + assert_eq!(grouped_lane.lifecycle_metrics.phases[1].child_event_count, 3); + assert_eq!(grouped_lane.lifecycle_metrics.phases[1].wall_seconds, 20); + assert_eq!(grouped_lane.lifecycle_metrics.phases[1].tool_call_count, 4); + assert_eq!( + grouped_lane.lifecycle_metrics.phases[1].input_tokens_cumulative, + 200 + ); + assert_eq!( + grouped_lane.lifecycle_metrics.phases[1].output_tokens_cumulative, + 40 + ); assert!(rendered.contains("Run ledger shown: 2 issue lanes from 3 history attempts")); assert!(rendered.contains("issue: XY-323")); assert!(rendered.contains("attempts: 2")); - assert!(rendered.contains("attempt_timeline")); + assert!(rendered.contains("lifecycle_metrics: attempts=2; captured=2/2; missing=0; protocol_events=2")); + assert!(rendered.contains("phase_breakdown")); + assert!(rendered.contains("phase: development label: Development attempts: 1")); + assert!(rendered.contains("phase: review label: Review attempts: 1")); } #[test] @@ -312,8 +428,9 @@ fn live_operator_history_lanes_prefer_linear_ledger_outcome() { assert!(rendered.contains("commit_sha: 2222222222222222222222222222222222222222")); assert!(rendered.contains("closeout_status: Done")); assert!(rendered.contains("lifecycle_elapsed_seconds: 600")); - assert!(rendered.contains("attempt_timeline")); - assert!(rendered.contains("status: failed")); + assert!(rendered.contains("local_attempts: 2")); + assert!(rendered.contains("phase_breakdown")); + assert!(rendered.contains("phase: development label: Development attempts: 2")); assert!(!rendered.contains("pr_url: none")); assert_eq!( snapshot_json["history_lanes"][0]["latest_run"]["status"], diff --git a/apps/decodex/src/orchestrator/tests/operator/status/http.rs b/apps/decodex/src/orchestrator/tests/operator/status/http.rs index 2b7e8a108..323681788 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/http.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/http.rs @@ -94,13 +94,16 @@ fn operator_state_endpoint_serves_dashboard_html_from_root_and_dashboard_route() assert!(response.contains("Child agent")); assert!(response.contains("Activity")); assert!(!response.contains("Agent Now")); - assert!(response.contains("current window")); - assert!(response.contains("peak window")); - assert!(!response.contains("same as current")); - assert!(response.contains("cumulative input")); - assert!(response.contains("Current context window from the latest child-agent event.")); - assert!(response.contains("Total input tokens processed across child-agent events.")); - assert!(response.contains("child_agent_activity")); + assert!(response.contains("current window")); + assert!(response.contains("peak window")); + assert!(!response.contains("same as current")); + assert!(response.contains("Context lifecycle metrics")); + assert!(response.contains("rows.push({ label: \"Context\", segments: contextSegments });")); + assert!(response.contains("renderChildLifecyclePhaseTable(lifecycle.phases || [])")); + assert!(response.contains("facts.push([\"tokens\", tokenSummary]);")); + assert!(!response.contains("\"cumulative input\",")); + assert!(response.contains("Current context window from the latest child-agent event.")); + assert!(response.contains("child_agent_activity")); assert!(response.contains("renderChildAgentBreakdown")); assert!(response.contains("Debug Details")); assert!(response.contains("already running")); diff --git a/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs b/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs index 3cfaa18eb..e8eea289d 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs @@ -1995,6 +1995,11 @@ fn operator_status_snapshot_rolls_current_child_bucket_elapsed_time_into_bucket( assert_eq!(run.wait_reason.as_deref(), Some("tool_execution")); assert_eq!(protocol_activity.waiting_reason.as_deref(), Some("tool_execution")); + assert_eq!(run.lifecycle_metrics.attempt_count, 1); + assert_eq!(run.lifecycle_metrics.captured_attempt_count, 1); + assert_eq!(run.lifecycle_metrics.tool_call_count, 1); + assert_eq!(run.lifecycle_metrics.phases.len(), 1); + assert_eq!(run.lifecycle_metrics.phases[0].phase, "review"); assert!(activity.current_elapsed_seconds.is_some_and(|elapsed| elapsed >= 90)); assert!( tracker_bucket.wall_seconds >= 90, diff --git a/apps/decodex/src/orchestrator/tests/operator/status/text.rs b/apps/decodex/src/orchestrator/tests/operator/status/text.rs index 30f75d65c..6a3a23db7 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/text.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/text.rs @@ -1,5 +1,3 @@ -use state::ReviewPolicyCheckpointInput; - use crate::execution_program::{ ExecutionLinearIssueMapping, ExecutionProgram, ExecutionProgramDependency, ExecutionProgramNode, ExecutionProgramNodeStage, ExecutionQueueIntent, diff --git a/apps/decodex/src/orchestrator/tests/operator/status_support.rs b/apps/decodex/src/orchestrator/tests/operator/status_support.rs index ece2e6921..d9bbec835 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status_support.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status_support.rs @@ -1,9 +1,12 @@ use std::{panic, slice}; -use orchestrator::{OperatorPostReviewLaneStatus, OperatorQueuedIssueStatus, OperatorWorktreeStatus}; +use orchestrator::{ + AgentPrivateEvidenceRef, OperatorLaneLifecycleMetrics, OperatorPostReviewLaneStatus, + OperatorQueuedIssueStatus, OperatorRunControlCapability, OperatorRunStatus, + OperatorWorktreeProvenanceStatus, OperatorWorktreeStatus, +}; use serde_json::Value; -use orchestrator::AgentPrivateEvidenceRef; -use orchestrator::OperatorRunControlCapability; +use state::{ChildAgentActivityBucket, ProtocolActivityEventSummary}; fn successful_linear_execution_history_comments(issue: &TrackerIssue) -> Vec { vec![ @@ -264,11 +267,72 @@ fn operator_status_text_control_capability() -> OperatorRunControlCapability { } } -fn operator_status_text_active_run() -> orchestrator::OperatorRunStatus { +fn operator_status_text_child_agent_activity() -> ChildAgentActivitySummary { + ChildAgentActivitySummary { + buckets: vec![ + ChildAgentActivityBucket { + name: String::from("Model"), + wall_seconds: 693, + event_count: 12, + tool_call_count: 0, + input_tokens: 4_270_000, + output_tokens: 12_000, + output_bytes: 0, + }, + ChildAgentActivityBucket { + name: String::from("Browser/Image"), + wall_seconds: 41, + event_count: 6, + tool_call_count: 3, + input_tokens: 0, + output_tokens: 0, + output_bytes: 180_000, + }, + ], + current_bucket: Some(String::from("Model")), + current_detail: Some(String::from("waiting after tool output")), + current_started_unix_epoch: None, + current_elapsed_seconds: Some(652), + wall_seconds: 734, + event_count: 18, + tool_call_count: 3, + input_tokens_current: Some(105_000), + input_tokens_max: Some(105_000), + input_tokens_cumulative: 4_270_000, + output_tokens_cumulative: 12_000, + largest_tool_output_bytes: Some(180_000), + largest_tool_output_tool: Some(String::from("view_image")), + large_output_warnings: vec![String::from( + "view_image repeated 3 large outputs; largest 180000 bytes", + )], + } +} + +fn operator_status_text_protocol_activity() -> ProtocolActivitySummary { + ProtocolActivitySummary { + turn_status: Some(String::from("completed")), + waiting_reason: Some(String::from("model_execution")), + rate_limit_status: Some(String::from("none")), + recent_events: vec![ + ProtocolActivityEventSummary { + event_type: String::from("item/tool/call"), + category: String::from("item"), + detail: Some(String::from("view_image")), + }, + ProtocolActivityEventSummary { + event_type: String::from("turn/completed"), + category: String::from("turn"), + detail: Some(String::from("completed")), + }, + ], + } +} + +fn operator_status_text_active_run() -> OperatorRunStatus { let account = operator_status_text_codex_account(); let backup_account = operator_status_text_backup_codex_account(); - orchestrator::OperatorRunStatus { + OperatorRunStatus { project_id: String::from("pubfi"), project_display_name: String::from("hack-ink/pubfi-mono-v2"), run_id: String::from("run-1"), @@ -323,73 +387,21 @@ fn operator_status_text_active_run() -> orchestrator::OperatorRunStatus { effective_approval_policy: Some(String::from("never")), effective_approvals_reviewer: Some(String::from("human")), effective_sandbox_mode: Some(String::from("workspaceWrite")), - child_agent_activity: Some(ChildAgentActivitySummary { - buckets: vec![ - state::ChildAgentActivityBucket { - name: String::from("Model"), - wall_seconds: 693, - event_count: 12, - tool_call_count: 0, - input_tokens: 4_270_000, - output_tokens: 12_000, - output_bytes: 0, - }, - state::ChildAgentActivityBucket { - name: String::from("Browser/Image"), - wall_seconds: 41, - event_count: 6, - tool_call_count: 3, - input_tokens: 0, - output_tokens: 0, - output_bytes: 180_000, - }, - ], - current_bucket: Some(String::from("Model")), - current_detail: Some(String::from("waiting after tool output")), - current_started_unix_epoch: None, - current_elapsed_seconds: Some(652), - wall_seconds: 734, - event_count: 18, - tool_call_count: 3, - input_tokens_current: Some(105_000), - input_tokens_max: Some(105_000), - input_tokens_cumulative: 4_270_000, - output_tokens_cumulative: 12_000, - largest_tool_output_bytes: Some(180_000), - largest_tool_output_tool: Some(String::from("view_image")), - large_output_warnings: vec![String::from( - "view_image repeated 3 large outputs; largest 180000 bytes", - )], - }), - protocol_activity: Some(ProtocolActivitySummary { - turn_status: Some(String::from("completed")), - waiting_reason: Some(String::from("model_execution")), - rate_limit_status: Some(String::from("none")), - recent_events: vec![ - state::ProtocolActivityEventSummary { - event_type: String::from("item/tool/call"), - category: String::from("item"), - detail: Some(String::from("view_image")), - }, - state::ProtocolActivityEventSummary { - event_type: String::from("turn/completed"), - category: String::from("turn"), - detail: Some(String::from("completed")), - }, - ], - }), - account: Some(account.clone()), - accounts: vec![account, backup_account], - branch_name: Some(String::from("x/pubfi-pub-101")), - worktree_path: Some(String::from(".worktrees/PUB-101")), - } + child_agent_activity: Some(operator_status_text_child_agent_activity()), + protocol_activity: Some(operator_status_text_protocol_activity()), + lifecycle_metrics: OperatorLaneLifecycleMetrics::default(), + account: Some(account.clone()), + accounts: vec![account, backup_account], + branch_name: Some(String::from("x/pubfi-pub-101")), + worktree_path: Some(String::from(".worktrees/PUB-101")), + } } fn operator_status_text_queued_candidates() -> Vec { vec![ - orchestrator::OperatorQueuedIssueStatus { - project_id: String::from(TEST_SERVICE_ID), - issue_id: String::from("issue-1"), + OperatorQueuedIssueStatus { + project_id: String::from(TEST_SERVICE_ID), + issue_id: String::from("issue-1"), issue_identifier: String::from("PUB-101"), title: String::from("Running lane still has a backlog claim"), author: Some(String::from("Yvette")), @@ -401,9 +413,9 @@ fn operator_status_text_queued_candidates() -> Vec { attention: None, blocker_identifiers: vec![], }, - orchestrator::OperatorQueuedIssueStatus { - project_id: String::from(TEST_SERVICE_ID), - issue_id: String::from("issue-2"), + OperatorQueuedIssueStatus { + project_id: String::from(TEST_SERVICE_ID), + issue_id: String::from("issue-2"), issue_identifier: String::from("PUB-102"), title: String::from("Implement backlog surface"), author: Some(String::from("Yvette")), @@ -415,9 +427,9 @@ fn operator_status_text_queued_candidates() -> Vec { attention: None, blocker_identifiers: vec![], }, - orchestrator::OperatorQueuedIssueStatus { - project_id: String::from(TEST_SERVICE_ID), - issue_id: String::from("issue-5"), + OperatorQueuedIssueStatus { + project_id: String::from(TEST_SERVICE_ID), + issue_id: String::from("issue-5"), issue_identifier: String::from("PUB-105"), title: String::from("Remove stale queue label"), author: Some(String::from("Yvette")), @@ -434,9 +446,9 @@ fn operator_status_text_queued_candidates() -> Vec { fn operator_status_text_worktrees() -> Vec { vec![ - orchestrator::OperatorWorktreeStatus { - project_id: String::from(TEST_SERVICE_ID), - issue_id: String::from("issue-4"), + OperatorWorktreeStatus { + project_id: String::from(TEST_SERVICE_ID), + issue_id: String::from("issue-4"), issue_identifier: Some(String::from("PUB-104")), issue_state: None, branch_name: String::from("x/pubfi-pub-104"), @@ -449,9 +461,9 @@ fn operator_status_text_worktrees() -> Vec { recovery_next_action: None, hygiene: None, }, - orchestrator::OperatorWorktreeStatus { - project_id: String::from(TEST_SERVICE_ID), - issue_id: String::from("issue-1"), + OperatorWorktreeStatus { + project_id: String::from(TEST_SERVICE_ID), + issue_id: String::from("issue-1"), issue_identifier: Some(String::from("PUB-101")), issue_state: Some(String::from("In Progress")), branch_name: String::from("x/pubfi-pub-101"), @@ -462,9 +474,9 @@ fn operator_status_text_worktrees() -> Vec { recovery_next_action: None, hygiene: None, }, - orchestrator::OperatorWorktreeStatus { - project_id: String::from(TEST_SERVICE_ID), - issue_id: String::from("issue-3"), + OperatorWorktreeStatus { + project_id: String::from(TEST_SERVICE_ID), + issue_id: String::from("issue-3"), issue_identifier: Some(String::from("PUB-103")), issue_state: Some(String::from("In Review")), branch_name: String::from("x/pubfi-pub-103"), @@ -480,8 +492,8 @@ fn operator_status_text_worktrees() -> Vec { ] } -fn test_worktree_provenance(source: &str) -> orchestrator::OperatorWorktreeProvenanceStatus { - orchestrator::OperatorWorktreeProvenanceStatus { +fn test_worktree_provenance(source: &str) -> OperatorWorktreeProvenanceStatus { + OperatorWorktreeProvenanceStatus { source: source.to_owned(), created_at_unix: Some(1), updated_at_unix: Some(2), @@ -490,7 +502,7 @@ fn test_worktree_provenance(source: &str) -> orchestrator::OperatorWorktreeProve } fn operator_status_text_post_review_lanes() -> Vec { - vec![orchestrator::OperatorPostReviewLaneStatus { + vec![OperatorPostReviewLaneStatus { project_id: String::from(TEST_SERVICE_ID), issue_id: String::from("issue-3"), issue_identifier: String::from("PUB-103"), diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs index bce5a4788..009aa43ba 100644 --- a/apps/decodex/src/orchestrator/types.rs +++ b/apps/decodex/src/orchestrator/types.rs @@ -1449,10 +1449,54 @@ struct OperatorHistoryLaneStatus { issue_key: String, attempt_count: usize, ledger_outcome: OperatorHistoryLedgerOutcome, + lifecycle_metrics: OperatorLaneLifecycleMetrics, latest_run: OperatorRunStatus, attempts: Vec, } +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +struct OperatorLaneLifecycleMetrics { + attempt_count: usize, + run_count: usize, + captured_attempt_count: usize, + missing_attempt_count: usize, + protocol_event_count: i64, + child_event_count: i64, + wall_seconds: i64, + tool_call_count: i64, + input_tokens_current: Option, + input_tokens_peak: Option, + input_tokens_cumulative: i64, + output_tokens_cumulative: i64, + largest_tool_output_bytes: Option, + largest_tool_output_tool: Option, + large_output_warnings: Vec, + buckets: Vec, + phases: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +struct OperatorLaneLifecyclePhaseMetrics { + phase: String, + label: String, + attempt_count: usize, + run_count: usize, + captured_attempt_count: usize, + missing_attempt_count: usize, + protocol_event_count: i64, + child_event_count: i64, + wall_seconds: i64, + tool_call_count: i64, + input_tokens_current: Option, + input_tokens_peak: Option, + input_tokens_cumulative: i64, + output_tokens_cumulative: i64, + largest_tool_output_bytes: Option, + largest_tool_output_tool: Option, + large_output_warnings: Vec, + buckets: Vec, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] struct OperatorHistoryLedgerOutcome { ledger_status: String, @@ -1521,12 +1565,14 @@ struct OperatorRunStatus { effective_cwd: Option, effective_approval_policy: Option, effective_approvals_reviewer: Option, - effective_sandbox_mode: Option, - child_agent_activity: Option, - protocol_activity: Option, - account: Option, - accounts: Vec, - branch_name: Option, + effective_sandbox_mode: Option, + child_agent_activity: Option, + protocol_activity: Option, + #[serde(default)] + lifecycle_metrics: OperatorLaneLifecycleMetrics, + account: Option, + accounts: Vec, + branch_name: Option, worktree_path: Option, } diff --git a/apps/decodex/src/state.rs b/apps/decodex/src/state.rs index d85478fca..8dd5251b5 100644 --- a/apps/decodex/src/state.rs +++ b/apps/decodex/src/state.rs @@ -16,7 +16,7 @@ use std::{ }; use rusqlite::{Connection, OptionalExtension, Transaction, params}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Value; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; diff --git a/apps/decodex/src/state/internal.rs b/apps/decodex/src/state/internal.rs index be1b272bb..2d2387c05 100644 --- a/apps/decodex/src/state/internal.rs +++ b/apps/decodex/src/state/internal.rs @@ -14,7 +14,7 @@ use libc::{ }; #[cfg(target_os = "macos")] use process::Command; -use rusqlite::{self, Row}; +use rusqlite::{self, Row, types::Type}; use crate::tracker; @@ -127,6 +127,7 @@ struct StateData { control_channels: HashMap, events: HashMap>, event_summaries: HashMap, + run_activity_summaries: HashMap, worktrees: HashMap, linear_execution_events: HashMap, private_execution_events: Vec, @@ -153,6 +154,7 @@ impl StateData { self.control_channels = loaded.control_channels; self.events = loaded.events; self.event_summaries = loaded.event_summaries; + self.run_activity_summaries = loaded.run_activity_summaries; self.worktrees = loaded.worktrees; self.linear_execution_events = loaded.linear_execution_events; self.private_execution_events = loaded.private_execution_events; @@ -172,6 +174,7 @@ impl StateData { self.leases = loaded.leases; self.run_attempts = loaded.run_attempts; self.control_channels = loaded.control_channels; + self.run_activity_summaries = loaded.run_activity_summaries; self.worktrees = loaded.worktrees; } @@ -207,6 +210,7 @@ impl StateData { } let event_summary = self.protocol_event_summary(&attempt.run_id); + let run_activity_summary = self.run_activity_summaries.get(&attempt.run_id); let control_channel = self .control_channels .get(&attempt.run_id) @@ -234,6 +238,10 @@ impl StateData { last_event_at: event_summary.last_event_at, last_event_at_unix: event_summary.last_event_at_unix, control_channel, + child_agent_activity: run_activity_summary + .and_then(|summary| summary.child_agent_activity.clone()), + protocol_activity: run_activity_summary + .and_then(|summary| summary.protocol_activity.clone()), }) } @@ -350,6 +358,14 @@ CREATE TABLE IF NOT EXISTS protocol_event_summaries ( compacted_at TEXT NOT NULL, compacted_at_unix INTEGER NOT NULL ); +CREATE TABLE IF NOT EXISTS run_activity_summaries ( + run_id TEXT PRIMARY KEY NOT NULL, + attempt_number INTEGER NOT NULL, + child_agent_activity_json TEXT, + protocol_activity_json TEXT, + updated_at TEXT NOT NULL, + updated_at_unix INTEGER NOT NULL +); CREATE TABLE IF NOT EXISTS worktrees ( issue_id TEXT PRIMARY KEY NOT NULL, project_id TEXT NOT NULL, @@ -787,6 +803,7 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; self.load_run_attempts(&mut state)?; self.load_run_control_channels(&mut state)?; self.load_protocol_event_summaries(&mut state)?; + self.load_run_activity_summaries(&mut state)?; self.load_worktrees(&mut state)?; self.load_linear_execution_events(&mut state)?; self.load_private_execution_events(&mut state)?; @@ -807,6 +824,7 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; self.load_leases(&mut state)?; self.load_run_attempts_for_project(&mut state, project_id)?; + self.load_run_activity_summaries_for_loaded_runs(&mut state)?; self.load_worktrees(&mut state)?; self.load_run_control_channels_for_project(&mut state, project_id)?; @@ -838,6 +856,7 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; persist_run_attempts(&transaction, state)?; persist_run_control_channels(&transaction, state)?; persist_protocol_events(&transaction, state)?; + persist_run_activity_summaries(&transaction, state)?; persist_worktrees(&transaction, state)?; persist_linear_execution_events(&transaction, state)?; persist_private_execution_events(&transaction, state)?; @@ -993,6 +1012,36 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; Ok(()) } + fn upsert_run_activity_summary(&self, summary: &RunActivitySummaryRecord) -> Result<()> { + let child_agent_activity_json = summary + .child_agent_activity + .as_ref() + .map(serde_json::to_string) + .transpose()?; + let protocol_activity_json = summary + .protocol_activity + .as_ref() + .map(serde_json::to_string) + .transpose()?; + + self.connection.execute( + "INSERT OR REPLACE INTO run_activity_summaries ( + run_id, attempt_number, child_agent_activity_json, protocol_activity_json, + updated_at, updated_at_unix + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + &summary.run_id, + summary.attempt_number, + child_agent_activity_json.as_deref(), + protocol_activity_json.as_deref(), + &summary.updated_at, + summary.updated_at_unix, + ], + )?; + + Ok(()) + } + fn upsert_lease_and_remember_run_project(&mut self, lease: &IssueLease) -> Result<()> { let transaction = self.connection.transaction()?; @@ -1772,6 +1821,54 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; Ok(()) } + fn load_run_activity_summaries(&self, state: &mut StateData) -> Result<()> { + let mut statement = self.connection.prepare( + "SELECT run_id, attempt_number, child_agent_activity_json, protocol_activity_json, + updated_at, updated_at_unix FROM run_activity_summaries ORDER BY run_id", + )?; + let rows = statement.query_map([], run_activity_summary_record_from_row)?; + + for row in rows { + let summary = row?; + + state.run_activity_summaries.insert(summary.run_id.clone(), summary); + } + + Ok(()) + } + + fn load_run_activity_summaries_for_loaded_runs(&self, state: &mut StateData) -> Result<()> { + let run_ids = state.run_attempts.keys().cloned().collect::>(); + + for run_id in run_ids { + self.load_run_activity_summary_for_run(state, &run_id)?; + } + + Ok(()) + } + + fn load_run_activity_summary_for_run( + &self, + state: &mut StateData, + run_id: &str, + ) -> Result<()> { + state.run_activity_summaries.remove(run_id); + + let mut statement = self.connection.prepare( + "SELECT run_id, attempt_number, child_agent_activity_json, protocol_activity_json, + updated_at, updated_at_unix FROM run_activity_summaries WHERE run_id = ?1", + )?; + let summary = statement + .query_row(params![run_id], run_activity_summary_record_from_row) + .optional()?; + + if let Some(summary) = summary { + state.run_activity_summaries.insert(run_id.to_owned(), summary); + } + + Ok(()) + } + fn load_compacted_protocol_event_summaries(&self, state: &mut StateData) -> Result<()> { let mut statement = self.connection.prepare( "SELECT run_id, event_count, last_sequence_number, last_event_type, last_event_at, \ @@ -2704,6 +2801,16 @@ impl ProtocolEventSummaryRecord { } } +#[derive(Clone, Debug)] +struct RunActivitySummaryRecord { + run_id: String, + attempt_number: i64, + child_agent_activity: Option, + protocol_activity: Option, + updated_at: String, + updated_at_unix: i64, +} + #[derive(Clone, Debug)] struct LinearExecutionEventRuntimeRecord { record: LinearExecutionEventRecord, @@ -3900,6 +4007,41 @@ fn persist_protocol_events( Ok(()) } +fn persist_run_activity_summaries( + transaction: &Transaction<'_>, + state: &StateData, +) -> Result<()> { + for summary in state.run_activity_summaries.values() { + let child_agent_activity_json = summary + .child_agent_activity + .as_ref() + .map(serde_json::to_string) + .transpose()?; + let protocol_activity_json = summary + .protocol_activity + .as_ref() + .map(serde_json::to_string) + .transpose()?; + + transaction.execute( + "INSERT OR REPLACE INTO run_activity_summaries ( + run_id, attempt_number, child_agent_activity_json, protocol_activity_json, + updated_at, updated_at_unix + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + &summary.run_id, + summary.attempt_number, + child_agent_activity_json.as_deref(), + protocol_activity_json.as_deref(), + &summary.updated_at, + summary.updated_at_unix, + ], + )?; + } + + Ok(()) +} + fn persist_worktrees(transaction: &Transaction<'_>, state: &StateData) -> Result<()> { for mapping in state.worktrees.values() { transaction.execute( @@ -5003,6 +5145,41 @@ fn run_attempt_record_from_row( }) } +fn run_activity_summary_record_from_row( + row: &Row<'_>, +) -> std::result::Result { + Ok(RunActivitySummaryRecord { + run_id: row.get(0)?, + attempt_number: row.get(1)?, + child_agent_activity: optional_json_from_row(row, 2)?, + protocol_activity: optional_json_from_row(row, 3)?, + updated_at: row.get(4)?, + updated_at_unix: row.get(5)?, + }) +} + +fn optional_json_from_row( + row: &Row<'_>, + index: usize, +) -> std::result::Result, rusqlite::Error> +where + T: DeserializeOwned, +{ + let value: Option = row.get(index)?; + + value + .map(|value| { + serde_json::from_str(&value).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + index, + Type::Text, + Box::new(error), + ) + }) + }) + .transpose() +} + fn worktree_mapping_record_from_row( row: &Row<'_>, ) -> std::result::Result { diff --git a/apps/decodex/src/state/models.rs b/apps/decodex/src/state/models.rs index 832b68bbb..2bbf16c17 100644 --- a/apps/decodex/src/state/models.rs +++ b/apps/decodex/src/state/models.rs @@ -319,6 +319,8 @@ pub struct ProjectRunStatus { last_event_at: Option, last_event_at_unix: Option, control_channel: Option, + child_agent_activity: Option, + protocol_activity: Option, } impl ProjectRunStatus { /// Stable run identifier. @@ -391,6 +393,14 @@ impl ProjectRunStatus { self.control_channel.as_ref() } + pub(crate) fn child_agent_activity(&self) -> Option<&ChildAgentActivitySummary> { + self.child_agent_activity.as_ref() + } + + pub(crate) fn protocol_activity(&self) -> Option<&ProtocolActivitySummary> { + self.protocol_activity.as_ref() + } + /// Unix timestamp of the latest recorded protocol event, when one exists. pub(crate) fn last_event_at_unix(&self) -> Option { self.last_event_at_unix diff --git a/apps/decodex/src/state/store.rs b/apps/decodex/src/state/store.rs index d91883f95..239a27ecc 100644 --- a/apps/decodex/src/state/store.rs +++ b/apps/decodex/src/state/store.rs @@ -1576,6 +1576,29 @@ impl StateStore { Ok(()) } + pub(crate) fn record_run_activity_summary( + &self, + run_id: &str, + attempt_number: i64, + child_agent_activity: Option<&ChildAgentActivitySummary>, + protocol_activity: Option<&ProtocolActivitySummary>, + ) -> Result<()> { + let now = timestamp_parts(); + let summary = RunActivitySummaryRecord { + run_id: run_id.to_owned(), + attempt_number, + child_agent_activity: child_agent_activity.cloned(), + protocol_activity: protocol_activity.cloned(), + updated_at: now.text, + updated_at_unix: now.unix, + }; + let mut state = self.lock_without_refresh()?; + + state.run_activity_summaries.insert(run_id.to_owned(), summary.clone()); + + self.upsert_run_activity_summary_locked(&summary) + } + /// Persist a locally known Linear execution event in the runtime store. pub(crate) fn record_linear_execution_event( &self, @@ -2771,6 +2794,17 @@ impl StateStore { sqlite.upsert_run_control_channel(channel) } + fn upsert_run_activity_summary_locked(&self, summary: &RunActivitySummaryRecord) -> Result<()> { + let Some(sqlite) = self.sqlite.as_ref() else { + return Ok(()); + }; + let sqlite = sqlite + .lock() + .map_err(|_| eyre::eyre!("StateStore SQLite mutex is poisoned."))?; + + sqlite.upsert_run_activity_summary(summary) + } + fn upsert_lease_and_remember_run_project_locked(&self, lease: &IssueLease) -> Result<()> { let Some(sqlite) = self.sqlite.as_ref() else { return Ok(()); diff --git a/apps/decodex/src/state/tests.rs b/apps/decodex/src/state/tests.rs index 9ac79e575..18ca28590 100644 --- a/apps/decodex/src/state/tests.rs +++ b/apps/decodex/src/state/tests.rs @@ -22,8 +22,8 @@ use crate::{ DecisionContract, DecisionContractStatus, DecisionPromotion, DecisionPromotionActorKind, }, state::{ - self, ChildAgentActivitySummary, CodexAccountActivitySummary, CodexAccountMarker, - ConnectorBackoffInput, DispatchSlotLimit, EffectiveRuntimeMarker, + self, ChildAgentActivityBucket, ChildAgentActivitySummary, CodexAccountActivitySummary, + CodexAccountMarker, ConnectorBackoffInput, DispatchSlotLimit, EffectiveRuntimeMarker, LoopGuardrailCheckpointInput, PreacquiredLeaseGuards, ProjectRegistration, ProtocolActivityMarker, ProtocolActivitySummary, RUN_ACTIVITY_MARKER_FILE, RUN_CONTROL_ACTION_COMPLETED, RUN_CONTROL_ACTION_FAILED, RUN_CONTROL_ACTION_FALLBACK, @@ -1645,6 +1645,68 @@ fn records_run_attempts_and_events() { ); } +#[test] +fn records_run_activity_summary_for_recent_project_runs() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let state_path = temp_dir.path().join("runtime.sqlite3"); + let child_activity = ChildAgentActivitySummary { + buckets: vec![ChildAgentActivityBucket { + name: String::from("Model"), + wall_seconds: 12, + event_count: 3, + tool_call_count: 0, + input_tokens: 1_200, + output_tokens: 240, + output_bytes: 0, + }], + current_bucket: Some(String::from("Model")), + current_detail: Some(String::from("gpt-5")), + current_started_unix_epoch: None, + current_elapsed_seconds: Some(12), + wall_seconds: 12, + event_count: 3, + tool_call_count: 2, + input_tokens_current: Some(1_200), + input_tokens_max: Some(1_200), + input_tokens_cumulative: 1_200, + output_tokens_cumulative: 240, + largest_tool_output_bytes: Some(4_096), + largest_tool_output_tool: Some(String::from("shell")), + large_output_warnings: vec![String::from("shell output was truncated")], + }; + let protocol_activity = ProtocolActivitySummary { + turn_status: Some(String::from("completed")), + ..ProtocolActivitySummary::default() + }; + + { + let store = StateStore::open(&state_path).expect("persistent state store should open"); + + store + .record_run_attempt("run-1", "PUB-101", 1, "succeeded") + .expect("run attempt should be recorded"); + store + .upsert_worktree("pubfi", "PUB-101", "x/pubfi-pub-101", "/tmp/worktrees/pub-101") + .expect("project ownership should record"); + store + .record_run_activity_summary( + "run-1", + 1, + Some(&child_activity), + Some(&protocol_activity), + ) + .expect("activity summary should persist"); + } + + let reopened = StateStore::open(&state_path).expect("persistent state store should reopen"); + let runs = reopened.list_recent_runs("pubfi", 10).expect("recent project runs should load"); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].run_id(), "run-1"); + assert_eq!(runs[0].child_agent_activity(), Some(&child_activity)); + assert_eq!(runs[0].protocol_activity(), Some(&protocol_activity)); +} + #[test] fn lists_issue_attempts_and_protocol_event_presence() { let store = StateStore::open_in_memory().expect("in-memory state store should open"); diff --git a/dev/operator-dashboard-mock.mjs b/dev/operator-dashboard-mock.mjs index f592c7173..80a62db10 100755 --- a/dev/operator-dashboard-mock.mjs +++ b/dev/operator-dashboard-mock.mjs @@ -13,7 +13,7 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".." function parseArgs(argv) { const options = { authDir: null, - dashboardHtml: path.join(repoRoot, "src/orchestrator/operator_dashboard.html"), + dashboardHtml: path.join(repoRoot, "apps/decodex/src/orchestrator/operator_dashboard.html"), listenAddress: DEFAULT_LISTEN_ADDRESS, }; @@ -57,7 +57,10 @@ function requiredValue(argv, index, flag) { function printHelp() { console.log(`Usage: node dev/operator-dashboard-mock.mjs [options] -Serves the real operator dashboard HTML with mock WebSocket snapshot and activity events. +Serves the real operator dashboard HTML, /api/accounts, and mock dashboard WebSocket +snapshot/activity events from one local base URL. Use the same mock base URL for the +browser dashboard and Decodex App previews; do not start a second mock server for the +App. The dashboard authority is ws://HOST:PORT/dashboard/control. Options: --listen-address HOST:PORT Bind address (default ${DEFAULT_LISTEN_ADDRESS}) @@ -300,6 +303,188 @@ function childAgentActivity() { }; } +function lifecycleMetrics({ + attemptCount, + capturedAttemptCount = attemptCount, + protocolEventCount, + childEventCount, + wallSeconds, + toolCallCount, + inputTokens, + outputTokens, + buckets = [], +}) { + return { + attempt_count: attemptCount, + run_count: attemptCount, + captured_attempt_count: capturedAttemptCount, + missing_attempt_count: Math.max(0, attemptCount - capturedAttemptCount), + protocol_event_count: protocolEventCount, + child_event_count: childEventCount, + wall_seconds: wallSeconds, + tool_call_count: toolCallCount, + input_tokens_cumulative: inputTokens, + output_tokens_cumulative: outputTokens, + largest_tool_output_bytes: 180_000, + largest_tool_output_tool: "view_image", + buckets, + }; +} + +function lifecyclePhaseMetrics({ phase, label, ...metrics }) { + return { + phase, + label, + ...lifecycleMetrics(metrics), + }; +} + +function activeRunLifecycleMetrics(childActivity, { attemptCount = 1, phase = "development", label = "Development" } = {}) { + if (!childActivity) { + return lifecycleMetrics({ + attemptCount: 0, + capturedAttemptCount: 0, + protocolEventCount: 0, + childEventCount: 0, + wallSeconds: 0, + toolCallCount: 0, + inputTokens: 0, + outputTokens: 0, + buckets: [], + }); + } + const phaseMetrics = lifecyclePhaseMetrics({ + phase, + label, + attemptCount, + protocolEventCount: childActivity.event_count, + childEventCount: childActivity.event_count, + wallSeconds: childActivity.wall_seconds, + toolCallCount: childActivity.tool_call_count, + inputTokens: childActivity.input_tokens_cumulative, + outputTokens: childActivity.output_tokens_cumulative, + buckets: childActivity.buckets, + }); + const total = { + ...phaseMetrics, + phase: undefined, + label: undefined, + phases: [phaseMetrics], + }; + + total.input_tokens_current = childActivity.input_tokens_current; + total.input_tokens_peak = childActivity.input_tokens_max; + total.large_output_warnings = childActivity.large_output_warnings || []; + total.largest_tool_output_bytes = childActivity.largest_tool_output_bytes; + total.largest_tool_output_tool = childActivity.largest_tool_output_tool; + phaseMetrics.input_tokens_current = childActivity.input_tokens_current; + phaseMetrics.input_tokens_peak = childActivity.input_tokens_max; + phaseMetrics.large_output_warnings = childActivity.large_output_warnings || []; + phaseMetrics.largest_tool_output_bytes = childActivity.largest_tool_output_bytes; + phaseMetrics.largest_tool_output_tool = childActivity.largest_tool_output_tool; + + delete total.phase; + delete total.label; + + return total; +} + +function activeReviewLifecycleMetrics(currentActivity) { + const developmentPhase = lifecyclePhaseMetrics({ + phase: "development", + label: "Development", + attemptCount: 1, + protocolEventCount: 18, + childEventCount: 24, + wallSeconds: 910, + toolCallCount: 7, + inputTokens: 2_850_000, + outputTokens: 8_500, + buckets: [ + { + name: "Model", + wall_seconds: 620, + event_count: 11, + tool_call_count: 0, + input_tokens: 2_850_000, + output_tokens: 8_500, + output_bytes: 0, + }, + { + name: "Shell", + wall_seconds: 220, + event_count: 9, + tool_call_count: 5, + input_tokens: 0, + output_tokens: 0, + output_bytes: 34_000, + }, + { + name: "Tracker", + wall_seconds: 0, + event_count: 4, + tool_call_count: 2, + input_tokens: 0, + output_tokens: 0, + output_bytes: 3_200, + }, + ], + }); + developmentPhase.largest_tool_output_bytes = 34_000; + developmentPhase.largest_tool_output_tool = "shell"; + developmentPhase.large_output_warnings = []; + const reviewPhase = activeRunLifecycleMetrics(currentActivity, { + attemptCount: 1, + phase: "review", + label: "Review", + }).phases[0]; + const phases = [developmentPhase, reviewPhase]; + const bucketTotals = new Map(); + + for (const phase of phases) { + for (const bucket of phase.buckets || []) { + const total = + bucketTotals.get(bucket.name) || + { + name: bucket.name, + wall_seconds: 0, + event_count: 0, + tool_call_count: 0, + input_tokens: 0, + output_tokens: 0, + output_bytes: 0, + }; + total.wall_seconds += bucket.wall_seconds || 0; + total.event_count += bucket.event_count || 0; + total.tool_call_count += bucket.tool_call_count || 0; + total.input_tokens += bucket.input_tokens || 0; + total.output_tokens += bucket.output_tokens || 0; + total.output_bytes += bucket.output_bytes || 0; + bucketTotals.set(bucket.name, total); + } + } + + const total = lifecycleMetrics({ + attemptCount: 2, + protocolEventCount: phases.reduce((count, phase) => count + phase.protocol_event_count, 0), + childEventCount: phases.reduce((count, phase) => count + phase.child_event_count, 0), + wallSeconds: phases.reduce((count, phase) => count + phase.wall_seconds, 0), + toolCallCount: phases.reduce((count, phase) => count + phase.tool_call_count, 0), + inputTokens: phases.reduce((count, phase) => count + phase.input_tokens_cumulative, 0), + outputTokens: phases.reduce((count, phase) => count + phase.output_tokens_cumulative, 0), + buckets: Array.from(bucketTotals.values()).sort((left, right) => right.wall_seconds - left.wall_seconds), + }); + + total.input_tokens_current = currentActivity.input_tokens_current; + total.input_tokens_peak = Math.max(currentActivity.input_tokens_max, 128_000); + total.large_output_warnings = currentActivity.large_output_warnings || []; + total.largest_tool_output_bytes = currentActivity.largest_tool_output_bytes; + total.largest_tool_output_tool = currentActivity.largest_tool_output_tool; + total.phases = phases; + + return total; +} + function activeRun({ accounts, accountIndex = 0, @@ -308,17 +493,26 @@ function activeRun({ issue = "XY-445", operation = "agent_run", status = "running", - title = "Account pool dashboard polish", - processAlive = true, - activeLease = true, - childActivity = childAgentActivity(), -}) { + title = "Account pool dashboard polish", + processAlive = true, + activeLease = true, + childActivity = childAgentActivity(), + lifecycleMetrics = null, + }) { const selectedAccount = assignedAccount || accounts[accountIndex] || accounts.find((item) => item.status === "selected") || accounts[0] || null; + const lifecyclePhase = + status === "review_handoff_pending" || operation === "review_writeback" + ? { phase: "review", label: "Review" } + : status === "closeout_pending" || operation === "closeout" + ? { phase: "closeout", label: "Closeout" } + : status === "needs_attention" + ? { phase: "manual_attention", label: "Manual attention" } + : { phase: "development", label: "Development" }; return { project_id: "decodex-preview", @@ -362,12 +556,19 @@ function activeRun({ effective_approvals_reviewer: null, effective_sandbox_mode: "danger-full-access", protocol_event: `turn/completed @ ${ago(processAlive ? 16 : 185)}`, - codex_account: selectedAccount, - codex_accounts: accounts, - child_agent_activity: childActivity, - branch_name: `xy/${issue.toLowerCase()}-mock`, - worktree_path: `/Users/x/code/y/hack-ink/decodex/.worktrees/${issue}`, - }; + codex_account: selectedAccount, + codex_accounts: accounts, + child_agent_activity: childActivity, + lifecycle_metrics: + lifecycleMetrics || + activeRunLifecycleMetrics(childActivity, { + attemptCount: attempt, + phase: lifecyclePhase.phase, + label: lifecyclePhase.label, + }), + branch_name: `xy/${issue.toLowerCase()}-mock`, + worktree_path: `/Users/x/code/y/hack-ink/decodex/.worktrees/${issue}`, + }; } function queuedCandidates() { @@ -538,13 +739,176 @@ function historyLane(accounts) { run.process_alive = false; run.thread_status = "completed"; + const developmentPhase = lifecyclePhaseMetrics({ + phase: "development", + label: "Development", + attemptCount: 2, + protocolEventCount: 42, + childEventCount: 78, + wallSeconds: 2_940, + toolCallCount: 31, + inputTokens: 6_800_000, + outputTokens: 21_000, + buckets: [ + { + name: "Model", + wall_seconds: 2_320, + event_count: 38, + tool_call_count: 0, + input_tokens: 6_800_000, + output_tokens: 21_000, + output_bytes: 0, + }, + { + name: "Shell", + wall_seconds: 410, + event_count: 28, + tool_call_count: 24, + input_tokens: 0, + output_tokens: 0, + output_bytes: 58_000, + }, + { + name: "Tracker", + wall_seconds: 0, + event_count: 12, + tool_call_count: 7, + input_tokens: 0, + output_tokens: 0, + output_bytes: 8_200, + }, + ], + }); + const reviewPhase = lifecyclePhaseMetrics({ + phase: "review", + label: "Review", + attemptCount: 1, + protocolEventCount: 16, + childEventCount: 24, + wallSeconds: 1_080, + toolCallCount: 9, + inputTokens: 2_100_000, + outputTokens: 8_400, + buckets: [ + { + name: "Model", + wall_seconds: 820, + event_count: 14, + tool_call_count: 0, + input_tokens: 2_100_000, + output_tokens: 8_400, + output_bytes: 0, + }, + { + name: "GitHub", + wall_seconds: 130, + event_count: 5, + tool_call_count: 4, + input_tokens: 0, + output_tokens: 0, + output_bytes: 11_000, + }, + { + name: "Shell", + wall_seconds: 130, + event_count: 5, + tool_call_count: 5, + input_tokens: 0, + output_tokens: 0, + output_bytes: 16_000, + }, + ], + }); + const closeoutPhase = lifecyclePhaseMetrics({ + phase: "closeout", + label: "Closeout", + attemptCount: 1, + protocolEventCount: 8, + childEventCount: 12, + wallSeconds: 480, + toolCallCount: 5, + inputTokens: 520_000, + outputTokens: 2_100, + buckets: [ + { + name: "Model", + wall_seconds: 300, + event_count: 6, + tool_call_count: 0, + input_tokens: 520_000, + output_tokens: 2_100, + output_bytes: 0, + }, + { + name: "Shell", + wall_seconds: 180, + event_count: 6, + tool_call_count: 5, + input_tokens: 0, + output_tokens: 0, + output_bytes: 9_000, + }, + ], + }); + const phases = [developmentPhase, reviewPhase, closeoutPhase]; + const lifecycle = lifecycleMetrics({ + attemptCount: 4, + capturedAttemptCount: 4, + protocolEventCount: phases.reduce((total, phase) => total + phase.protocol_event_count, 0), + childEventCount: phases.reduce((total, phase) => total + phase.child_event_count, 0), + wallSeconds: phases.reduce((total, phase) => total + phase.wall_seconds, 0), + toolCallCount: phases.reduce((total, phase) => total + phase.tool_call_count, 0), + inputTokens: phases.reduce((total, phase) => total + phase.input_tokens_cumulative, 0), + outputTokens: phases.reduce((total, phase) => total + phase.output_tokens_cumulative, 0), + buckets: [ + { + name: "Model", + wall_seconds: 3_440, + event_count: 58, + tool_call_count: 0, + input_tokens: 9_420_000, + output_tokens: 31_500, + output_bytes: 0, + }, + { + name: "Shell", + wall_seconds: 720, + event_count: 39, + tool_call_count: 34, + input_tokens: 0, + output_tokens: 0, + output_bytes: 83_000, + }, + { + name: "GitHub", + wall_seconds: 130, + event_count: 5, + tool_call_count: 4, + input_tokens: 0, + output_tokens: 0, + output_bytes: 11_000, + }, + { + name: "Tracker", + wall_seconds: 0, + event_count: 12, + tool_call_count: 7, + input_tokens: 0, + output_tokens: 0, + output_bytes: 8_200, + }, + ], + }); + lifecycle.phases = phases; + return { project_id: "decodex-preview", issue_id: "issue-xy-430", issue_identifier: "XY-430", title: "Completed dashboard lane", issue_key: "XY-430", - attempt_count: 2, + attempt_count: 4, + lifecycle_metrics: lifecycle, ledger_outcome: { ledger_status: "present", final_outcome: "succeeded", @@ -565,6 +929,8 @@ function historyLane(accounts) { attempts: [ { ...run, run_id: "xy-430-attempt-1-mock", attempt_number: 1, status: "failed" }, { ...run, run_id: "xy-430-attempt-2-mock", attempt_number: 2, status: "succeeded" }, + { ...run, run_id: "xy-430-review-1-mock", attempt_number: 3, status: "succeeded" }, + { ...run, run_id: "xy-430-closeout-1-mock", attempt_number: 4, status: "succeeded" }, ], }; } @@ -631,8 +997,15 @@ function usageEstimate(accounts) { function buildSnapshot(accounts, fixedAccountSelector) { const controlledAccounts = accountsWithSelection(accounts, fixedAccountSelector); + const primaryActivity = childAgentActivity(); const activeRuns = [ - activeRun({ accounts: controlledAccounts, accountIndex: 0 }), + activeRun({ + accounts: controlledAccounts, + accountIndex: 0, + attempt: 2, + childActivity: primaryActivity, + lifecycleMetrics: activeReviewLifecycleMetrics(primaryActivity), + }), activeRun({ accounts: controlledAccounts, accountIndex: Math.min(2, controlledAccounts.length - 1), @@ -1152,7 +1525,12 @@ async function main() { }); server.listen(port, host, () => { - console.log(`operator dashboard mock: http://${host}:${port}/dashboard`); + const baseUrl = `http://${host}:${port}`; + const webSocketUrl = `ws://${host}:${port}/dashboard/control`; + console.log(`operator dashboard mock: ${baseUrl}/dashboard`); + console.log(`operator dashboard websocket: ${webSocketUrl}`); + console.log(`Decodex App mock base: DECODEX_APP_SERVER_URL=${baseUrl}`); + console.log("preview invariant: browser dashboard and Decodex App must use this same mock server"); console.log( options.authDir ? `accounts: ${options.authDir} (${staticAccounts.length} loaded)` diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index 5a9163061..80f0bcfe3 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -127,8 +127,12 @@ Use `--dev` only for isolated local development: - Do not use `--dev` for operator automation, queue intake, retained-lane recovery, project registration refresh, or service scheduling. It is hidden from CLI help and intentionally rejects `--config`; `serve` has no interval override argument. -- For browser-only dashboard UI work, use `dev/operator-dashboard-mock.mjs` instead - of `--dev`. +- For browser dashboard and Decodex App preview UI work, use one + `dev/operator-dashboard-mock.mjs` listener instead of `--dev`. The same mock base + URL must serve the browser dashboard, `/api/accounts`, and the Decodex App + dashboard WebSocket connection; do not start a separate App mock server. When + `DECODEX_APP_SERVER_URL` is set, Decodex App treats that URL as authoritative and + does not fall back to the default `127.0.0.1:8192` runtime. Project registration is not service intake. The `Projects` dashboard section may show multiple enabled projects with visible work at once, and its filter can reveal the full @@ -238,12 +242,13 @@ state is, by itself, evidence that the Linear tracker or GitHub connector failed; confirm the central project registry and service queue label before treating it as a connector problem. -The browser dashboard reads the complete published state from the local -`GET /dashboard/control` WebSocket. That socket is the dashboard authority for -published snapshots, active-lane activity updates, and local dashboard control -acknowledgements. `GET /api/operator-snapshot` is the Decodex App read API over the -same runtime database, not a browser-dashboard polling authority and not a sign that -the dev listener owns scheduling. +The browser dashboard and Decodex App read the complete published operator state from +the local `GET /dashboard/control` WebSocket. That socket is the dashboard/App +authority for published snapshots, active-lane activity updates, and local dashboard +control acknowledgements. The App still uses the same base URL's `/api/accounts` +HTTP surface for account-pool rows and actions. `GET /api/operator-snapshot` is a +status/cache read path over the same runtime database, not a browser-dashboard or App +polling authority and not a sign that the dev listener owns scheduling. `decodex status` uses that same local `GET /api/operator-snapshot` as a fast read path when the default listener is reachable, the published snapshot is recent, includes the