diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index 38dd39ae..90b2bcc4 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -57,6 +57,12 @@ enum PanelPalette { : Color(red: 0.62, green: 0.4, blue: 0.12) } + static func usageCyan(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.35, green: 0.78, blue: 0.86) + : Color(red: 0.1, green: 0.53, blue: 0.62) + } + static func fastModeAccent(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark ? Color(red: 0.98, green: 0.84, blue: 0.48) @@ -80,6 +86,18 @@ enum PanelPalette { ? Color.white.opacity(0.12) : Color.white.opacity(0.22) } + + static func glassStroke(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color.white.opacity(0.1) + : Color.white.opacity(0.5) + } + + static func glassInnerShadow(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color.black.opacity(0.18) + : Color.black.opacity(0.055) + } } enum PanelMotion { @@ -468,7 +486,6 @@ struct AccountPanelView: View { AccountRowView( account: account, runs: runs, - runCount: operatorRunCount(for: account), emailsHidden: emailsHidden, showsDivider: index < store.accounts.count - 1, isLogoutArmed: armedLogoutAccountID == account.id, @@ -575,14 +592,6 @@ struct AccountPanelView: View { store.operatorSnapshot?.activeRuns(for: account) ?? [] } - private func operatorRunCount(for account: CodexAccount) -> Int? { - guard let count = store.operatorSnapshot?.runningCount(for: account), count > 0 else { - return nil - } - - return count - } - private func accountRowHeight(for account: CodexAccount) -> CGFloat { let base: CGFloat if account.hasUsageWindowSummary { @@ -645,7 +654,6 @@ struct AccountPanelView: View { struct AccountRowView: View { let account: CodexAccount let runs: [OperatorRunStatus] - let runCount: Int? let emailsHidden: Bool let showsDivider: Bool let isLogoutArmed: Bool @@ -692,22 +700,6 @@ struct AccountRowView: View { .fixedSize(horizontal: true, vertical: false) } - if let runCount { - Text("·") - .font(PanelFont.accountDetail) - .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.62)) - .fixedSize(horizontal: true, vertical: false) - - Text(runCount == 1 ? "1 running" : "\(runCount) running") - .font(PanelFont.accountDetail) - .foregroundStyle( - runCount > 0 - ? PanelPalette.routeAccent(colorScheme) - : PanelPalette.secondaryText(colorScheme).opacity(0.84) - ) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - } } .frame(maxWidth: .infinity, alignment: .leading) @@ -813,19 +805,31 @@ struct AccountRunSummaryView: View { var body: some View { HStack(spacing: 5) { - AccountRunChipView(run: runs[0]) + ForEach(visibleRuns) { run in + AccountRunChipView(run: run) + } - if runs.count > 1 { - AccountRunOverflowView(runs: runs) + if hiddenRuns.isEmpty == false { + AccountRunOverflowView(runs: runs, hiddenRunCount: hiddenRuns.count) } } .frame(maxWidth: .infinity, alignment: .leading) } + + private var visibleRuns: [OperatorRunStatus] { + Array(runs.prefix(3)) + } + + private var hiddenRuns: [OperatorRunStatus] { + Array(runs.dropFirst(3)) + } } struct AccountRunChipView: View { let run: OperatorRunStatus @Environment(\.colorScheme) private var colorScheme + @State private var isHovered = false + @State private var showsPopover = false var body: some View { HStack(spacing: 5) { @@ -842,8 +846,24 @@ struct AccountRunChipView: View { } .frame(height: 21) .padding(.horizontal, 8) + .frame(maxWidth: 88, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 10.5, style: .continuous) + .fill(isHovered ? tint.opacity(colorScheme == .dark ? 0.09 : 0.07) : Color.clear) + } .modernGlassSurface(cornerRadius: 10.5, depth: .control) - .fixedSize(horizontal: true, vertical: false) + .contentShape(RoundedRectangle(cornerRadius: 10.5, style: .continuous)) + .onHover { hovering in + withAnimation(PanelMotion.hover) { + isHovered = hovering + } + showsPopover = hovering + } + .popover(isPresented: $showsPopover, arrowEdge: .trailing) { + OperatorLanePopoverView(run: run) + .frame(width: 360) + .padding(8) + } } private var symbol: String { @@ -871,12 +891,13 @@ struct AccountRunChipView: View { struct AccountRunOverflowView: View { let runs: [OperatorRunStatus] + let hiddenRunCount: Int @Environment(\.colorScheme) private var colorScheme @State private var isHovered = false @State private var showsPopover = false var body: some View { - Text("+\(max(0, runs.count - 1))") + Text("+\(hiddenRunCount)") .font(PanelFont.metricLabel) .foregroundStyle(PanelPalette.secondaryText(colorScheme)) .frame(height: 21) @@ -895,103 +916,48 @@ struct AccountRunOverflowView: View { showsPopover = hovering } .popover(isPresented: $showsPopover, arrowEdge: .trailing) { - AccountRunOverflowPopoverView(runs: runs) - .frame(width: 240) - .padding(10) + OperatorLaneDetailsListView( + title: "\(runs.count) running lane\(runs.count == 1 ? "" : "s")", + runs: runs + ) + .frame(width: 372) + .padding(8) } } } -struct AccountRunOverflowPopoverView: View { +struct OperatorLaneDetailsListView: View { + let title: String let runs: [OperatorRunStatus] @Environment(\.colorScheme) private var colorScheme var body: some View { - VStack(alignment: .leading, spacing: 7) { + VStack(alignment: .leading, spacing: 8) { HStack(spacing: 7) { Image(systemName: "arrow.triangle.branch") .font(PanelFont.summaryIcon) .foregroundStyle(PanelPalette.routeAccent(colorScheme).opacity(0.86)) .frame(width: 12) - Text("\(runs.count) running") + Text(title) .font(PanelFont.lanePopoverTitle) .foregroundStyle(PanelPalette.primaryText(colorScheme)) .lineLimit(1) } - VStack(spacing: 0) { - ForEach(runs) { run in - AccountRunOverflowRowView(run: run) + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach(runs) { run in + OperatorLanePopoverView(run: run) + } } } + .frame(maxHeight: 430) + .scrollIndicators(.hidden) } .padding(10) .modernGlassSurface(cornerRadius: 12, depth: .section) - .accessibilityLabel("\(runs.count) account lanes") - } -} - -struct AccountRunOverflowRowView: View { - let run: OperatorRunStatus - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - HStack(spacing: 7) { - Image(systemName: symbol) - .font(PanelFont.summaryIcon) - .foregroundStyle(tint.opacity(colorScheme == .dark ? 0.9 : 0.76)) - .frame(width: 12) - - Text(run.compactTitle) - .font(PanelFont.laneTitle) - .foregroundStyle(PanelPalette.primaryText(colorScheme)) - .lineLimit(1) - .truncationMode(.middle) - - Spacer(minLength: 6) - - Text(statusLabel) - .font(PanelFont.tertiary) - .foregroundStyle(PanelPalette.secondaryText(colorScheme)) - .monospacedDigit() - .lineLimit(1) - } - .frame(height: 25) - .padding(.horizontal, 2) - } - - private var symbol: String { - if run.hasAttentionTone { - return "exclamationmark.triangle.fill" - } - if run.isWaiting { - return "clock" - } - - return "play.fill" - } - - private var tint: Color { - if run.hasAttentionTone { - return PanelPalette.warning(colorScheme) - } - if run.isWaiting { - return PanelPalette.secondaryText(colorScheme) - } - - return PanelPalette.routeAccent(colorScheme) - } - - private var statusLabel: String { - if run.hasAttentionTone { - return "attention" - } - if run.isWaiting { - return "waiting" - } - - return humanizedPanelToken(run.phase ?? run.status ?? "running") + .accessibilityLabel(title) } } @@ -1401,6 +1367,10 @@ struct AccountUsageMeterView: View { } } + private var resetDisplay: UsageResetDisplay { + UsageResetDisplay.make(resetAtUnixEpoch: resetAtUnixEpoch) + } + private var trackColor: Color { PanelPalette.progressTrack(colorScheme) } @@ -1409,10 +1379,6 @@ struct AccountUsageMeterView: View { PanelPalette.progressEdge(colorScheme) } - private var resetDisplay: UsageResetDisplay { - UsageResetDisplay.make(resetAtUnixEpoch: resetAtUnixEpoch) - } - private var fillStyle: LinearGradient { LinearGradient( colors: [ @@ -1435,7 +1401,86 @@ struct AccountUsageMeterView: View { endPoint: .bottom ) } +} + +private struct UsageGlassTrackView: View { + let progress: CGFloat + let tint: Color + let markers: [CGFloat] + let alertMarker: CGFloat? + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + GeometryReader { proxy in + let trackWidth = proxy.size.width + let boundedProgress = max(0, min(1, progress)) + let fillWidth = max(boundedProgress > 0 ? 5 : 0, trackWidth * boundedProgress) + + ZStack(alignment: .leading) { + Capsule() + .fill(trackFill) + .overlay { + Capsule() + .strokeBorder(PanelPalette.progressEdge(colorScheme), lineWidth: 0.4) + } + + Capsule() + .fill(fillFill) + .frame(width: fillWidth) + .shadow(color: tint.opacity(colorScheme == .dark ? 0.18 : 0.12), radius: 2, x: 0, y: 0) + .animation(PanelMotion.state, value: progress) + + ForEach(markers, id: \.self) { marker in + Rectangle() + .fill(markerColor) + .frame(width: 1.2) + .padding(.vertical, 0.8) + .offset(x: markerOffset(marker, in: trackWidth)) + } + + if let alertMarker { + Rectangle() + .fill(PanelPalette.destructive(colorScheme)) + .frame(width: 2) + .padding(.vertical, 0.2) + .offset(x: markerOffset(alertMarker, in: trackWidth)) + } + } + } + .accessibilityHidden(true) + } + + private var trackFill: LinearGradient { + LinearGradient( + colors: [ + PanelPalette.progressTrack(colorScheme).opacity(0.92), + PanelPalette.progressTrack(colorScheme).opacity(colorScheme == .dark ? 0.72 : 0.82), + ], + startPoint: .top, + endPoint: .bottom + ) + } + + private var fillFill: LinearGradient { + LinearGradient( + colors: [ + tint.opacity(colorScheme == .dark ? 0.9 : 0.78), + tint.opacity(colorScheme == .dark ? 0.72 : 0.64), + ], + startPoint: .leading, + endPoint: .trailing + ) + } + + private var markerColor: Color { + colorScheme == .dark + ? Color.white.opacity(0.48) + : Color.white.opacity(0.76) + } + private func markerOffset(_ marker: CGFloat, in width: CGFloat) -> CGFloat { + max(0, min(width - 1.2, width * max(0, min(1, marker)))) + } } private struct UsageResetDisplay { @@ -1537,27 +1582,19 @@ struct OperatorStatusStripView: View { let snapshot: OperatorSnapshotResponse let updatedAt: Date? @Environment(\.colorScheme) private var colorScheme - @State private var showsAllLanes = false var body: some View { VStack(alignment: .leading, spacing: 5) { - if metrics.isEmpty == false { - HStack(spacing: 0) { - ForEach(Array(metrics.enumerated()), id: \.element.id) { index, metric in - if index > 0 { - flowDivider - } - - OperatorFlowMetricView(metric: metric) + HStack(spacing: 0) { + ForEach(Array(metrics.enumerated()), id: \.element.id) { index, metric in + if index > 0 { + flowDivider } - } - .frame(height: 32) - } - if snapshot.activeRuns.isEmpty == false { - OperatorLaneListView(runs: snapshot.activeRuns, showsAllLanes: $showsAllLanes) - .padding(.top, metrics.isEmpty ? 0 : 1) + OperatorFlowMetricView(metric: metric) + } } + .frame(height: 32) if let warning = snapshot.warningSummary { HStack(spacing: 5) { @@ -1624,9 +1661,7 @@ struct OperatorStatusStripView: View { unitPlural: "PRs", tint: PanelPalette.landingAccent(colorScheme) ), - ].filter { metric in - metric.value > 0 - } + ] } private var refreshMeta: String { @@ -1643,163 +1678,6 @@ struct OperatorStatusStripView: View { } } -struct OperatorLaneListView: View { - let runs: [OperatorRunStatus] - @Binding var showsAllLanes: Bool - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - VStack(spacing: 0) { - ForEach(visibleRuns) { run in - OperatorLaneRowView(run: run) - } - - if hasOverflow { - Button { - withAnimation(PanelMotion.state) { - showsAllLanes.toggle() - } - } label: { - HStack(spacing: 5) { - Text(toggleLabel) - .font(PanelFont.tertiary) - .foregroundStyle(PanelPalette.secondaryText(colorScheme)) - .lineLimit(1) - Spacer(minLength: 4) - Image(systemName: showsAllLanes ? "chevron.up" : "chevron.down") - .font(PanelFont.summaryIcon) - .foregroundStyle(PanelPalette.secondaryText(colorScheme)) - .frame(width: 12) - } - .frame(height: 20) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help(showsAllLanes ? "Collapse running lanes" : "Show all running lanes") - } - } - .overlay(alignment: .top) { - Rectangle() - .fill(PanelPalette.separator(colorScheme).opacity(colorScheme == .dark ? 0.58 : 0.78)) - .frame(height: 0.5) - .allowsHitTesting(false) - } - .overlay(alignment: .bottom) { - Rectangle() - .fill(PanelPalette.separator(colorScheme).opacity(colorScheme == .dark ? 0.36 : 0.5)) - .frame(height: 0.5) - .allowsHitTesting(false) - } - .accessibilityLabel("\(runs.count) running lanes") - } - - private var visibleRuns: [OperatorRunStatus] { - showsAllLanes ? runs : Array(runs.prefix(3)) - } - - private var hasOverflow: Bool { - runs.count > 3 - } - - private var toggleLabel: String { - if showsAllLanes { - return "Show 3 lanes" - } - - return "\(runs.count - 3) more lane\(runs.count - 3 == 1 ? "" : "s")" - } -} - -struct OperatorLaneRowView: View { - let run: OperatorRunStatus - @Environment(\.colorScheme) private var colorScheme - @State private var isHovered = false - @State private var showsPopover = false - - var body: some View { - HStack(spacing: 7) { - Image(systemName: symbol) - .font(PanelFont.summaryIcon) - .foregroundStyle(tint.opacity(colorScheme == .dark ? 0.96 : 0.82)) - .frame(width: 12) - - Text(run.compactTitle) - .font(PanelFont.laneTitle) - .foregroundStyle(PanelPalette.primaryText(colorScheme)) - .lineLimit(1) - .truncationMode(.middle) - .frame(maxWidth: .infinity, alignment: .leading) - - Text(statusLabel) - .font(PanelFont.laneStatus) - .foregroundStyle(tint.opacity(colorScheme == .dark ? 0.9 : 0.78)) - .monospacedDigit() - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - } - .frame(height: 25) - .padding(.horizontal, 2) - .background { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(isHovered ? tint.opacity(colorScheme == .dark ? 0.08 : 0.07) : Color.clear) - } - .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) - .onHover { hovering in - withAnimation(PanelMotion.hover) { - isHovered = hovering - } - showsPopover = hovering - } - .popover(isPresented: $showsPopover, arrowEdge: .trailing) { - OperatorLanePopoverView(run: run) - .frame(width: 360) - .padding(8) - } - } - - private var symbol: String { - if run.hasAttentionTone { - return "exclamationmark.triangle.fill" - } - if run.isWaiting { - return "clock" - } - if run.processAlive == false { - return "checkmark.circle" - } - - return "play.fill" - } - - private var statusLabel: String { - if run.hasAttentionTone { - return "attention" - } - if run.isWaiting { - return "waiting" - } - if run.processAlive == false { - return "done" - } - - return humanizedPanelToken(run.phase ?? run.status ?? "running") - } - - private var tint: Color { - if run.hasAttentionTone { - return PanelPalette.warning(colorScheme) - } - if run.isWaiting { - return PanelPalette.secondaryText(colorScheme) - } - if run.processAlive == false { - return PanelPalette.landingAccent(colorScheme) - } - - return PanelPalette.routeAccent(colorScheme) - } -} - struct OperatorLanePopoverView: View { let run: OperatorRunStatus @Environment(\.colorScheme) private var colorScheme @@ -1808,6 +1686,10 @@ struct OperatorLanePopoverView: View { VStack(alignment: .leading, spacing: 7) { header + if let projectReadout { + OperatorLaneReadoutRow(title: "Project", items: [projectReadout]) + } + if let modelBucket { OperatorLaneProgressReadoutRow( title: "Model", @@ -1869,6 +1751,14 @@ struct OperatorLanePopoverView: View { ], trailing: run.compactTitle) } + private var projectReadout: OperatorLaneReadoutItem? { + guard let projectName = panelTrimmed(run.projectDisplayName) ?? panelTrimmed(run.projectID) else { + return nil + } + + return OperatorLaneReadoutItem(label: nil, value: projectName, tone: .primary) + } + private var modelBucket: OperatorChildAgentBucket? { orderedBuckets.first { bucket in bucket.name.caseInsensitiveCompare("Model") == .orderedSame @@ -2078,15 +1968,13 @@ struct OperatorLaneProgressReadoutRow: View { .lineLimit(1) .frame(width: 62, alignment: .leading) - ZStack(alignment: .leading) { - Capsule() - .fill(PanelPalette.separator(colorScheme).opacity(colorScheme == .dark ? 0.42 : 0.56)) - Capsule() - .fill(PanelPalette.routeAccent(colorScheme).opacity(colorScheme == .dark ? 0.74 : 0.62)) - .frame(maxWidth: .infinity) - .scaleEffect(x: barShare, y: 1, anchor: .leading) - } - .frame(height: 5) + UsageGlassTrackView( + progress: barShare, + tint: PanelPalette.usageCyan(colorScheme), + markers: [0.25, 0.5, 0.75], + alertMarker: nil + ) + .frame(height: 5.5) .frame(maxWidth: .infinity) Text("\(percent)% · \(elapsed) / \(total)") @@ -2222,6 +2110,20 @@ struct OperatorFlowMetric: Identifiable { let unitPlural: String let tint: Color + init( + title: String, + value: Int, + unitSingular: String, + unitPlural: String, + tint: Color + ) { + self.title = title + self.value = value + self.unitSingular = unitSingular + self.unitPlural = unitPlural + self.tint = tint + } + var id: String { title } @@ -2244,12 +2146,19 @@ struct OperatorFlowMetricView: View { Text("\(metric.value) \(metric.unit)") .font(PanelFont.metricValue) - .foregroundStyle(metric.tint) + .foregroundStyle(valueTint) .monospacedDigit() .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 5) + .help(metric.title) + } + + private var valueTint: Color { + metric.value > 0 + ? metric.tint + : PanelPalette.secondaryText(colorScheme).opacity(colorScheme == .dark ? 0.64 : 0.72) } } @@ -2784,11 +2693,33 @@ struct ModernGlassSurfaceModifier: ViewModifier { configuredGlass, in: shape ) + .overlay { + shape + .strokeBorder(surfaceStroke, lineWidth: strokeWidth) + .allowsHitTesting(false) + } + .shadow( + color: surfaceShadow, + radius: shadowRadius, + x: 0, + y: shadowY + ) } else { content .background { shape.fill(materialStyle) } + .overlay { + shape + .strokeBorder(surfaceStroke, lineWidth: strokeWidth) + .allowsHitTesting(false) + } + .shadow( + color: surfaceShadow, + radius: shadowRadius, + x: 0, + y: shadowY + ) } } @@ -2836,6 +2767,61 @@ struct ModernGlassSurfaceModifier: ViewModifier { } } + private var surfaceStroke: Color { + switch depth { + case .panel: + return PanelPalette.glassStroke(colorScheme) + case .section: + return PanelPalette.glassStroke(colorScheme).opacity(colorScheme == .dark ? 0.82 : 0.72) + case .row: + return PanelPalette.glassStroke(colorScheme).opacity(colorScheme == .dark ? 0.62 : 0.58) + case .control: + return PanelPalette.glassStroke(colorScheme).opacity(colorScheme == .dark ? 0.55 : 0.5) + } + } + + private var strokeWidth: CGFloat { + depth == .panel ? 0.8 : 0.55 + } + + private var surfaceShadow: Color { + switch depth { + case .panel: + return PanelPalette.glassInnerShadow(colorScheme) + case .section: + return PanelPalette.glassInnerShadow(colorScheme).opacity(0.72) + case .row: + return PanelPalette.glassInnerShadow(colorScheme).opacity(0.5) + case .control: + return PanelPalette.glassInnerShadow(colorScheme).opacity(0.34) + } + } + + private var shadowRadius: CGFloat { + switch depth { + case .panel: + return 18 + case .section: + return 9 + case .row: + return 5 + case .control: + return 3 + } + } + + private var shadowY: CGFloat { + switch depth { + case .panel: + return 10 + case .section: + return 5 + case .row: + return 2 + case .control: + return 1 + } + } } private extension CodexAccount { diff --git a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift index 0d47952d..cc1b055f 100644 --- a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift +++ b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift @@ -58,7 +58,7 @@ struct OperatorSnapshotResponse: Decodable, Sendable { } var warningSummary: String? { - let labels = warnings.compactMap(Self.warningLabel).filter { $0.isEmpty == false } + let labels = warnings.compactMap(warningLabel).filter { $0.isEmpty == false } guard let first = labels.first else { return nil } @@ -122,14 +122,47 @@ struct OperatorSnapshotResponse: Decodable, Sendable { && projects.allSatisfy { $0.connectorState == "api_only" } } - private static func warningLabel(_ value: String) -> String? { + private var snapshotBuildFailureProjectIDs: [String] { + let apiOnlyBaseline = warnings.contains("automation_disabled") + + return projects.compactMap { project in + guard project.enabled, let projectID = project.projectID, projectID.isEmpty == false else { + return nil + } + if project.connectorState == "config_error" { + return projectID + } + if apiOnlyBaseline && project.warningCount > 1 { + return projectID + } + if project.connectorState == "degraded" && project.warningCount > 0 { + return projectID + } + + return nil + } + } + + private func snapshotBuildFailureLabel() -> String { + let projectIDs = snapshotBuildFailureProjectIDs + guard let first = projectIDs.first else { + return "Snapshot build failed" + } + if projectIDs.count == 1 { + return "Snapshot build failed: \(first)" + } + + return "Snapshot build failed: \(first) +\(projectIDs.count - 1)" + } + + private func warningLabel(_ value: String) -> String? { switch value { case "automation_disabled": return nil case "control_plane_tick_context_failed": return "Control-plane context unavailable" case "operator_snapshot_build_failed": - return "Snapshot build failed" + return snapshotBuildFailureLabel() case "control_plane_tick_failed": return "Control-plane tick failed" case "tracker_rate_limited": @@ -170,7 +203,9 @@ struct OperatorSnapshotResponse: Decodable, Sendable { struct OperatorProjectStatus: Decodable, Sendable { let projectID: String? + let enabled: Bool let connectorState: String? + let warningCount: Int let activeRunCount: Int let queuedCandidateCount: Int let postReviewLaneCount: Int @@ -182,7 +217,9 @@ struct OperatorProjectStatus: Decodable, Sendable { func withActiveRunCount(_ count: Int) -> OperatorProjectStatus { OperatorProjectStatus( projectID: projectID, + enabled: enabled, connectorState: connectorState, + warningCount: warningCount, activeRunCount: count, queuedCandidateCount: queuedCandidateCount, postReviewLaneCount: postReviewLaneCount, @@ -195,7 +232,9 @@ struct OperatorProjectStatus: Decodable, Sendable { enum CodingKeys: String, CodingKey { case projectID = "project_id" + case enabled case connectorState = "connector_state" + case warningCount = "warning_count" case activeRunCount = "active_run_count" case queuedCandidateCount = "queued_candidate_count" case postReviewLaneCount = "post_review_lane_count" @@ -209,7 +248,9 @@ struct OperatorProjectStatus: Decodable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) projectID = try container.decodeIfPresent(String.self, forKey: .projectID) + enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true connectorState = try container.decodeIfPresent(String.self, forKey: .connectorState) + warningCount = try container.decodeIfPresent(Int.self, forKey: .warningCount) ?? 0 activeRunCount = try container.decodeIfPresent(Int.self, forKey: .activeRunCount) ?? 0 queuedCandidateCount = try container.decodeIfPresent(Int.self, forKey: .queuedCandidateCount) ?? 0 postReviewLaneCount = try container.decodeIfPresent(Int.self, forKey: .postReviewLaneCount) ?? 0 @@ -221,7 +262,9 @@ struct OperatorProjectStatus: Decodable, Sendable { private init( projectID: String?, + enabled: Bool, connectorState: String?, + warningCount: Int, activeRunCount: Int, queuedCandidateCount: Int, postReviewLaneCount: Int, @@ -231,7 +274,9 @@ struct OperatorProjectStatus: Decodable, Sendable { cleanupPendingCount: Int ) { self.projectID = projectID + self.enabled = enabled self.connectorState = connectorState + self.warningCount = warningCount self.activeRunCount = activeRunCount self.queuedCandidateCount = queuedCandidateCount self.postReviewLaneCount = postReviewLaneCount @@ -268,6 +313,7 @@ struct OperatorPostReviewLaneStatus: Decodable, Sendable { struct OperatorRunStatus: Decodable, Identifiable, Sendable { let projectID: String? + let projectDisplayName: String? let runID: String let issueID: String? let issueIdentifier: String? @@ -356,6 +402,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { func mergingActivity(_ activity: OperatorRunStatus) -> OperatorRunStatus { OperatorRunStatus( projectID: activity.projectID ?? projectID, + projectDisplayName: activity.projectDisplayName ?? projectDisplayName, runID: activity.runID, issueID: activity.issueID ?? issueID, issueIdentifier: activity.issueIdentifier ?? issueIdentifier, @@ -403,6 +450,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { enum CodingKeys: String, CodingKey { case projectID = "project_id" + case projectDisplayName = "project_display_name" case runID = "run_id" case issueID = "issue_id" case issueIdentifier = "issue_identifier" @@ -435,6 +483,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) projectID = try container.decodeIfPresent(String.self, forKey: .projectID) + projectDisplayName = try container.decodeIfPresent(String.self, forKey: .projectDisplayName) runID = try container.decodeIfPresent(String.self, forKey: .runID) ?? UUID().uuidString issueID = try container.decodeIfPresent(String.self, forKey: .issueID) issueIdentifier = try container.decodeIfPresent(String.self, forKey: .issueIdentifier) @@ -468,6 +517,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { private init( projectID: String?, + projectDisplayName: String?, runID: String, issueID: String?, issueIdentifier: String?, @@ -496,6 +546,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { accounts: [OperatorRunAccountSummary] ) { self.projectID = projectID + self.projectDisplayName = projectDisplayName self.runID = runID self.issueID = issueID self.issueIdentifier = issueIdentifier diff --git a/apps/decodex/src/agent/app_server.rs b/apps/decodex/src/agent/app_server.rs index 293d4b96..30187fd0 100644 --- a/apps/decodex/src/agent/app_server.rs +++ b/apps/decodex/src/agent/app_server.rs @@ -3061,15 +3061,22 @@ fn wait_for_turn_completion( Some(target_turn_id), ), )?, - JsonRpcMessage::Response(_) | JsonRpcMessage::Error(_) => { - eyre::bail!( - "Received an unexpected JSON-RPC response while waiting for turn completion." - ); - }, + JsonRpcMessage::Response(_) => ignore_orphan_turn_json_rpc_response(), + JsonRpcMessage::Error(_) => reject_unexpected_turn_json_rpc_error()?, } } } +fn ignore_orphan_turn_json_rpc_response() { + tracing::debug!( + "Recorded and ignored orphan app-server JSON-RPC response while waiting for turn completion." + ); +} + +fn reject_unexpected_turn_json_rpc_error() -> crate::prelude::Result<()> { + eyre::bail!("Received an unexpected JSON-RPC error while waiting for turn completion."); +} + fn turn_wait_timeout_error( target_thread_id: &str, target_turn_id: &str, diff --git a/apps/decodex/src/agent/app_server/protocol.rs b/apps/decodex/src/agent/app_server/protocol.rs index 82ac4079..28d34124 100644 --- a/apps/decodex/src/agent/app_server/protocol.rs +++ b/apps/decodex/src/agent/app_server/protocol.rs @@ -4,23 +4,23 @@ use std::{ time::Duration, }; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, de::Error}; use serde_json::Value; -use crate::{ - agent::{ - app_server::REQUEST_TIMEOUT, - json_rpc::{AppServerProcessEnv, JsonRpcConnection, JsonRpcRequest, WireMessage}, - tracker_tool_bridge::{DynamicToolCallResponse, DynamicToolHandler, DynamicToolSpec}, - }, - prelude::Result, +use crate::agent::{ + app_server::REQUEST_TIMEOUT, + json_rpc::{AppServerProcessEnv, JsonRpcConnection, JsonRpcRequest, WireMessage}, + tracker_tool_bridge::{DynamicToolCallResponse, DynamicToolHandler, DynamicToolSpec}, }; pub(super) struct AppServerClient { pub(super) connection: JsonRpcConnection, } impl AppServerClient { - pub(super) fn spawn(listen: &str, process_env: &AppServerProcessEnv) -> Result { + pub(super) fn spawn( + listen: &str, + process_env: &AppServerProcessEnv, + ) -> crate::prelude::Result { Ok(Self { connection: JsonRpcConnection::spawn_app_server(listen, process_env)? }) } @@ -28,7 +28,7 @@ impl AppServerClient { pub(super) fn initialize( &mut self, enable_experimental_api: bool, - ) -> Result { + ) -> crate::prelude::Result { self.initialize_with_handler(enable_experimental_api, |_connection, _message, request| { color_eyre::eyre::bail!( "Unexpected inbound JSON-RPC request `{}` while waiting for `initialize`.", @@ -41,9 +41,13 @@ impl AppServerClient { &mut self, enable_experimental_api: bool, handler: H, - ) -> Result + ) -> crate::prelude::Result where - H: FnMut(&mut JsonRpcConnection, &WireMessage, &JsonRpcRequest) -> Result<()>, + H: FnMut( + &mut JsonRpcConnection, + &WireMessage, + &JsonRpcRequest, + ) -> crate::prelude::Result<()>, { self.connection.request_with_handler( "initialize", @@ -62,7 +66,7 @@ impl AppServerClient { ) } - pub(super) fn mark_initialized(&mut self) -> Result<()> { + pub(super) fn mark_initialized(&mut self) -> crate::prelude::Result<()> { self.connection.notify::("initialized", None) } @@ -70,9 +74,13 @@ impl AppServerClient { &mut self, params: LoginAccountParams, handler: H, - ) -> Result + ) -> crate::prelude::Result where - H: FnMut(&mut JsonRpcConnection, &WireMessage, &JsonRpcRequest) -> Result<()>, + H: FnMut( + &mut JsonRpcConnection, + &WireMessage, + &JsonRpcRequest, + ) -> crate::prelude::Result<()>, { self.connection.request_with_handler( "account/login/start", @@ -86,7 +94,7 @@ impl AppServerClient { pub(super) fn start_thread( &mut self, params: ThreadStartRequest, - ) -> Result { + ) -> crate::prelude::Result { self.start_thread_with_handler(params, |_connection, _message, request| { color_eyre::eyre::bail!( "Unexpected inbound JSON-RPC request `{}` while waiting for `thread/start`.", @@ -99,9 +107,13 @@ impl AppServerClient { &mut self, params: ThreadStartRequest, handler: H, - ) -> Result + ) -> crate::prelude::Result where - H: FnMut(&mut JsonRpcConnection, &WireMessage, &JsonRpcRequest) -> Result<()>, + H: FnMut( + &mut JsonRpcConnection, + &WireMessage, + &JsonRpcRequest, + ) -> crate::prelude::Result<()>, { self.connection.request_with_handler("thread/start", ¶ms, REQUEST_TIMEOUT, handler) } @@ -110,7 +122,7 @@ impl AppServerClient { pub(super) fn resume_thread( &mut self, params: ThreadResumeRequest, - ) -> Result { + ) -> crate::prelude::Result { self.resume_thread_with_handler(params, |_connection, _message, request| { color_eyre::eyre::bail!( "Unexpected inbound JSON-RPC request `{}` while waiting for `thread/resume`.", @@ -123,9 +135,13 @@ impl AppServerClient { &mut self, params: ThreadResumeRequest, handler: H, - ) -> Result + ) -> crate::prelude::Result where - H: FnMut(&mut JsonRpcConnection, &WireMessage, &JsonRpcRequest) -> Result<()>, + H: FnMut( + &mut JsonRpcConnection, + &WireMessage, + &JsonRpcRequest, + ) -> crate::prelude::Result<()>, { self.connection.request_with_handler("thread/resume", ¶ms, REQUEST_TIMEOUT, handler) } @@ -133,12 +149,15 @@ impl AppServerClient { pub(super) fn archive_thread( &mut self, params: ThreadArchiveRequest, - ) -> Result { + ) -> crate::prelude::Result { self.connection.request("thread/archive", ¶ms, REQUEST_TIMEOUT) } #[allow(dead_code)] - pub(super) fn start_turn(&mut self, params: TurnStartRequest) -> Result { + pub(super) fn start_turn( + &mut self, + params: TurnStartRequest, + ) -> crate::prelude::Result { self.start_turn_with_handler(params, |_connection, _message, request| { color_eyre::eyre::bail!( "Unexpected inbound JSON-RPC request `{}` while waiting for `turn/start`.", @@ -151,9 +170,13 @@ impl AppServerClient { &mut self, params: TurnStartRequest, handler: H, - ) -> Result + ) -> crate::prelude::Result where - H: FnMut(&mut JsonRpcConnection, &WireMessage, &JsonRpcRequest) -> Result<()>, + H: FnMut( + &mut JsonRpcConnection, + &WireMessage, + &JsonRpcRequest, + ) -> crate::prelude::Result<()>, { self.connection.request_with_handler("turn/start", ¶ms, REQUEST_TIMEOUT, handler) } @@ -161,21 +184,27 @@ impl AppServerClient { pub(super) fn command_exec( &mut self, params: &CommandExecParams, - ) -> Result { + ) -> crate::prelude::Result { self.connection.request("command/exec", params, params.request_timeout()) } - pub(super) fn read_config(&mut self, params: &ConfigReadParams) -> Result { + pub(super) fn read_config( + &mut self, + params: &ConfigReadParams, + ) -> crate::prelude::Result { self.connection.request("config/read", params, REQUEST_TIMEOUT) } - pub(super) fn list_models(&mut self, params: &ModelListParams) -> Result { + pub(super) fn list_models( + &mut self, + params: &ModelListParams, + ) -> crate::prelude::Result { self.connection.request("model/list", params, REQUEST_TIMEOUT) } pub(super) fn read_model_provider_capabilities( &mut self, - ) -> Result { + ) -> crate::prelude::Result { self.connection.request( "modelProvider/capabilities/read", &ModelProviderCapabilitiesReadParams {}, @@ -183,11 +212,17 @@ impl AppServerClient { ) } - pub(super) fn list_skills(&mut self, params: &SkillsListParams) -> Result { + pub(super) fn list_skills( + &mut self, + params: &SkillsListParams, + ) -> crate::prelude::Result { self.connection.request("skills/list", params, REQUEST_TIMEOUT) } - pub(super) fn list_plugins(&mut self, params: &PluginListParams) -> Result { + pub(super) fn list_plugins( + &mut self, + params: &PluginListParams, + ) -> crate::prelude::Result { self.connection.request("plugin/list", params, REQUEST_TIMEOUT) } @@ -195,16 +230,19 @@ impl AppServerClient { &mut self, params: &ListMcpServerStatusParams, timeout: Duration, - ) -> Result { + ) -> crate::prelude::Result { self.connection.request("mcpServerStatus/list", params, timeout) } - pub(super) fn recv(&mut self, timeout: Option) -> Result { + pub(super) fn recv( + &mut self, + timeout: Option, + ) -> crate::prelude::Result { self.connection.recv(timeout) } #[allow(dead_code)] - pub(super) fn respond(&mut self, id: &Value, result: &R) -> Result<()> + pub(super) fn respond(&mut self, id: &Value, result: &R) -> crate::prelude::Result<()> where R: Serialize, { @@ -212,7 +250,12 @@ impl AppServerClient { } #[allow(dead_code)] - pub(super) fn respond_error(&mut self, id: &Value, code: i64, message: &str) -> Result<()> { + pub(super) fn respond_error( + &mut self, + id: &Value, + code: i64, + message: &str, + ) -> crate::prelude::Result<()> { self.connection.respond_error(id, code, message) } @@ -596,15 +639,36 @@ pub(super) struct TurnStatusPayload { pub(super) error: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug)] pub(super) struct TurnError { pub(super) message: String, - #[serde(rename = "codexErrorInfo")] pub(super) codex_error_info: Option, #[allow(dead_code)] - #[serde(rename = "additionalDetails")] pub(super) additional_details: Option, } +impl<'de> Deserialize<'de> for TurnError { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + let entries = value + .as_object() + .ok_or_else(|| Error::custom("expected app-server turn error object"))?; + let message = entries + .get("message") + .and_then(string_like_json_value) + .ok_or_else(|| Error::custom("expected app-server turn error message"))?; + let codex_error_info = entries + .get("codexErrorInfo") + .or_else(|| entries.get("codex_error_info")) + .and_then(string_like_json_value); + let additional_details = + entries.get("additionalDetails").or_else(|| entries.get("additional_details")).cloned(); + + Ok(Self { message, codex_error_info, additional_details }) + } +} #[derive(Debug, Eq, PartialEq, Deserialize)] #[serde(rename_all = "camelCase")] @@ -661,16 +725,40 @@ pub(super) struct TurnCompletedNotification { pub(super) turn: TurnStatusPayload, } -#[derive(Debug, Deserialize)] +#[derive(Debug)] pub(super) struct ErrorNotification { pub(super) error: TurnError, - #[serde(rename = "willRetry")] pub(super) will_retry: Option, - #[serde(rename = "threadId")] pub(super) thread_id: Option, - #[serde(rename = "turnId")] pub(super) turn_id: Option, } +impl<'de> Deserialize<'de> for ErrorNotification { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + let entries = value + .as_object() + .ok_or_else(|| Error::custom("expected app-server error notification object"))?; + let error_value = entries + .get("error") + .ok_or_else(|| Error::custom("expected app-server error notification error"))?; + let error = TurnError::deserialize(error_value.clone()).map_err(Error::custom)?; + let will_retry = + entries.get("willRetry").or_else(|| entries.get("will_retry")).and_then(Value::as_bool); + let thread_id = entries + .get("threadId") + .or_else(|| entries.get("thread_id")) + .and_then(string_like_json_value); + let turn_id = entries + .get("turnId") + .or_else(|| entries.get("turn_id")) + .and_then(string_like_json_value); + + Ok(Self { error, will_retry, thread_id, turn_id }) + } +} #[derive(Debug, Deserialize)] pub(super) struct DynamicToolCallParams { @@ -795,6 +883,25 @@ pub(super) enum UserInput { Text { text: String }, } +fn string_like_json_value(value: &Value) -> Option { + match value { + Value::String(text) if !text.is_empty() => Some(text.clone()), + Value::Number(number) => Some(number.to_string()), + Value::Bool(value) => Some(value.to_string()), + Value::Object(entries) => + ["message", "text", "id", "codexErrorInfo", "type", "kind", "code", "reason", "name"] + .iter() + .find_map(|key| entries.get(*key).and_then(string_like_json_value)) + .or_else(|| { + (entries.len() == 1) + .then(|| entries.values().next().and_then(string_like_json_value)) + .flatten() + }), + Value::Array(items) => items.iter().find_map(string_like_json_value), + _ => None, + } +} + fn externally_tagged_value_name(value: &Value) -> Option { match value { Value::String(value) if !value.is_empty() => Some(value.clone()), @@ -833,6 +940,31 @@ mod tests { assert_eq!(notification.will_retry, None); } + #[test] + fn error_notifications_accept_structured_string_fields() { + let notification: super::ErrorNotification = serde_json::from_value(serde_json::json!({ + "error": { + "message": { + "type": "streamDisconnected", + "message": "stream disconnected" + }, + "codexErrorInfo": { + "type": "transientNetworkError" + } + }, + "threadId": { "id": "thread-1" }, + "turnId": { "id": "turn-1" }, + "willRetry": true + })) + .expect("structured error notification should parse"); + + assert_eq!(notification.error.message, "stream disconnected"); + assert_eq!(notification.error.codex_error_info.as_deref(), Some("transientNetworkError")); + assert_eq!(notification.thread_id.as_deref(), Some("thread-1")); + assert_eq!(notification.turn_id.as_deref(), Some("turn-1")); + assert_eq!(notification.will_retry, Some(true)); + } + #[test] fn chatgpt_auth_tokens_login_uses_app_server_protocol_shape() { let value = serde_json::to_value(super::LoginAccountParams::ChatgptAuthTokens { diff --git a/apps/decodex/src/agent/app_server/tests.rs b/apps/decodex/src/agent/app_server/tests.rs index 0819ff79..a4cf273e 100644 --- a/apps/decodex/src/agent/app_server/tests.rs +++ b/apps/decodex/src/agent/app_server/tests.rs @@ -405,6 +405,166 @@ fn command_exec_health_check_uses_bounded_standalone_request() { assert!(value.get("permissionProfile").is_none()); } +#[test] +fn turn_completion_ignores_orphan_json_rpc_response() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let worktree_path = temp_dir.path().join("worktree"); + let marker_path = temp_dir.path().join("activity"); + let fake_bin_dir = install_fake_codex_script(&temp_dir, orphan_response_fake_codex_script()); + let path_env = env::var("PATH").unwrap_or_default(); + let _path_guard = + TestEnvVarGuard::set("PATH", &format!("{}:{path_env}", fake_bin_dir.display())); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let mut request = minimal_run_request(); + + fs::create_dir_all(&worktree_path).expect("worktree directory should create"); + + request.run_id = String::from("orphan-response-run"); + request.issue_id = String::from("orphan-response-issue"); + request.cwd = worktree_path.display().to_string(); + request.timeout = Duration::from_secs(5); + request.activity_marker_path = Some(marker_path.clone()); + + let result = super::execute_app_server_run(&request, &state_store) + .expect("orphan response during turn wait should not fail the run"); + + assert_eq!(result.thread_id, "thread-1"); + assert_eq!(result.turn_id, "turn-1"); + assert_eq!(result.final_output, "ORPHAN_OK"); + + let marker = state::read_run_activity_marker_snapshot(&marker_path) + .expect("marker snapshot should load") + .expect("marker snapshot should exist"); + let protocol_activity = + marker.protocol_activity().expect("protocol activity should be captured"); + + assert!(state_store.event_count(&request.run_id).expect("event count should load") > 0); + assert!( + protocol_activity.recent_events.iter().any(|event| event.event_type == "json-rpc/response") + ); + assert_eq!(marker.last_event_type(), Some("turn/completed")); +} + +fn install_fake_codex_script(temp_dir: &TempDir, script: &str) -> PathBuf { + let fake_bin_dir = temp_dir.path().join("fake-bin"); + let fake_codex_path = fake_bin_dir.join("codex"); + + fs::create_dir_all(&fake_bin_dir).expect("fake bin directory should create"); + fs::write(&fake_codex_path, script).expect("fake codex script should write"); + + let mut permissions = + fs::metadata(&fake_codex_path).expect("fake codex metadata should read").permissions(); + + #[cfg(unix)] + PermissionsExt::set_mode(&mut permissions, 0o755); + fs::set_permissions(&fake_codex_path, permissions) + .expect("fake codex script should be executable"); + + fake_bin_dir +} + +fn orphan_response_fake_codex_script() -> &'static str { + r#"#!/usr/bin/env python3 +import json +import os +import sys + +def send(value): + print(json.dumps(value), flush=True) + +for line in sys.stdin: + message = json.loads(line) + method = message.get("method") + message_id = message.get("id") + params = message.get("params") or {} + + def reply(result): + send({"id": message_id, "result": result}) + + if method == "initialize": + reply({ + "userAgent": "fake-codex", + "codexHome": os.environ["CODEX_HOME"], + "platformFamily": "unix", + "platformOs": "macos" + }) + elif method == "initialized": + continue + elif method == "config/read": + reply({"config": { + "model": "gpt-5.5", + "model_provider": "openai", + "approval_policy": {"type": "never"}, + "sandbox_mode": {"type": "dangerFullAccess"} + }}) + elif method == "model/list": + reply({"data": [{ + "id": "gpt-5.5", + "model": "gpt-5.5", + "displayName": "GPT-5.5", + "isDefault": True, + "hidden": False + }], "nextCursor": None}) + elif method == "modelProvider/capabilities/read": + reply({"imageGeneration": True, "namespaceTools": True, "webSearch": True}) + elif method == "skills/list": + cwd = params.get("cwds", [""])[0] + reply({"data": [{"cwd": cwd, "errors": [], "skills": [{ + "enabled": True, + "name": "fake-skill", + "scope": "user" + }]}]}) + elif method == "plugin/list": + reply({"marketplaces": [{"name": "fake", "plugins": [{ + "enabled": True, + "id": "fake-plugin", + "installed": True, + "name": "Fake Plugin" + }]}], "marketplaceLoadErrors": []}) + elif method == "mcpServerStatus/list": + reply({"data": [], "nextCursor": None}) + elif method == "thread/start": + cwd = params.get("cwd") + reply({ + "thread": {"id": "thread-1"}, + "model": "gpt-5.5", + "modelProvider": "openai", + "serviceTier": None, + "cwd": cwd, + "instructionSources": [], + "approvalPolicy": {"type": "never"}, + "approvalsReviewer": "user", + "sandbox": {"type": "dangerFullAccess"}, + "reasoningEffort": None + }) + elif method == "turn/start": + reply({"turn": {"id": "turn-1", "status": "running", "error": None}}) + send({"method": "thread/status/changed", "params": { + "threadId": "thread-1", + "status": {"type": "active", "activeFlags": []} + }}) + send({"method": "turn/started", "params": { + "threadId": "thread-1", + "turn": {"id": "turn-1", "status": "running", "error": None} + }}) + send({"id": 999, "result": {"late": True}}) + send({"method": "item/completed", "params": { + "threadId": "thread-1", + "turnId": "turn-1", + "item": {"type": "agentMessage", "text": "ORPHAN_OK"} + }}) + send({"method": "turn/completed", "params": { + "threadId": "thread-1", + "turn": {"id": "turn-1", "status": "completed", "error": None} + }}) + else: + send({"id": message_id, "error": { + "code": -32601, + "message": "unexpected method " + str(method) + }}) +"# +} + #[test] fn archive_thread_after_success_calls_app_server_archive_and_records_event() { let temp_dir = TempDir::new().expect("tempdir should create"); diff --git a/apps/decodex/src/agent/json_rpc.rs b/apps/decodex/src/agent/json_rpc.rs index 86b54d7b..9c81e522 100644 --- a/apps/decodex/src/agent/json_rpc.rs +++ b/apps/decodex/src/agent/json_rpc.rs @@ -378,9 +378,20 @@ impl JsonRpcConnection { )); }, JsonRpcMessage::Request(request) => handle_request(self, &wire_message, request)?, - JsonRpcMessage::Response(_) | JsonRpcMessage::Error(_) => { + JsonRpcMessage::Response(response) => { + tracing::debug!( + method, + response_id = %response.id, + expected_id = %expected_id, + "Recorded and ignored orphan app-server JSON-RPC response while waiting for request." + ); + }, + JsonRpcMessage::Error(error) => { return Err(eyre::eyre!( - "Received an unexpected JSON-RPC response while waiting for `{method}`." + "Received an unexpected JSON-RPC error while waiting for `{method}`: id {} failed with {}: {}", + error.id, + error.error.code, + error.error.message )); }, } @@ -688,10 +699,9 @@ mod tests { path::PathBuf, process::{Command, Stdio}, sync::{Arc, Mutex, mpsc}, + time::Duration, }; - use serde_json::json; - use crate::agent::json_rpc::{ AppServerHomePreflightFailure, AppServerOutputTimeout, AppServerProcessEnv, AppServerTransportFailure, JsonRpcConnection, JsonRpcMessage, @@ -708,7 +718,7 @@ mod tests { match message.message { JsonRpcMessage::Notification(notification) => { assert_eq!(notification.method, "thread/status/changed"); - assert_eq!(notification.params["threadId"], json!("thread-1")); + assert_eq!(notification.params["threadId"], serde_json::json!("thread-1")); }, other => panic!("unexpected message: {other:?}"), } @@ -722,13 +732,31 @@ mod tests { match message.message { JsonRpcMessage::Response(response) => { - assert_eq!(response.id, json!(1)); - assert_eq!(response.result["userAgent"], json!("decodex-test")); + assert_eq!(response.id, serde_json::json!(1)); + assert_eq!(response.result["userAgent"], serde_json::json!("decodex-test")); }, other => panic!("unexpected message: {other:?}"), } } + #[test] + fn request_wait_ignores_orphan_response_before_expected_response() { + let mut connection = test_connection_with_messages([ + r#"{"id":99,"result":{"late":true}}"#, + r#"{"id":1,"result":{"ok":true}}"#, + ]); + let response: serde_json::Value = connection + .request_with_handler( + "thread/start", + &serde_json::json!({}), + Duration::from_secs(1), + |_, _, _| Ok(()), + ) + .expect("orphan response should not fail the pending request"); + + assert_eq!(response, serde_json::json!({"ok": true})); + } + #[test] fn app_server_command_inherits_noninteractive_git_environment() { let process_env = AppServerProcessEnv::with_github_credentials( @@ -936,4 +964,29 @@ mod tests { assert!(error.downcast_ref::().is_some()); } + + fn test_connection_with_messages(messages: [&str; N]) -> JsonRpcConnection { + let mut child = Command::new("sh") + .args(["-c", "cat >/dev/null"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("child process should spawn"); + let stdin = child.stdin.take().expect("child stdin should be captured"); + let (stdout_tx, stdout_rx) = mpsc::channel(); + + for message in messages { + stdout_tx.send(message.to_owned()).expect("test message should send"); + } + + JsonRpcConnection { + child, + stdin, + stdout_rx, + stderr_tail: Arc::new(Mutex::new(VecDeque::new())), + pending_messages: VecDeque::new(), + next_request_id: 1, + } + } } diff --git a/apps/decodex/src/cli.rs b/apps/decodex/src/cli.rs index dbf5111b..1a126dc3 100644 --- a/apps/decodex/src/cli.rs +++ b/apps/decodex/src/cli.rs @@ -397,6 +397,15 @@ impl ProjectCommand { println!("disabled project {}", args.service_id); }, + ProjectSubcommand::Remove(args) => { + let removed = state_store.remove_project(&args.service_id)?; + + println!( + "removed project {} at {}", + removed.service_id(), + removed.config_path().display() + ); + }, } Ok(()) @@ -764,6 +773,8 @@ enum ProjectSubcommand { Enable(ProjectToggleCommand), /// Disable one registered project for `decodex serve`. Disable(ProjectToggleCommand), + /// Remove one registered project from the local registry. + Remove(ProjectToggleCommand), } #[derive(Debug, Subcommand)] @@ -1057,6 +1068,16 @@ mod tests { )); } + #[test] + fn parses_project_remove() { + let cli = Cli::parse_from(["decodex", "project", "remove", "vibe-mono"]); + + assert!(matches!( + cli.command, + Command::Project(ProjectCommand { command: ProjectSubcommand::Remove(_) }) + )); + } + #[test] fn parses_account_use_with_auth_json_override() { let cli = Cli::parse_from([ diff --git a/apps/decodex/src/orchestrator/entrypoints.rs b/apps/decodex/src/orchestrator/entrypoints.rs index a810d394..73b5010a 100644 --- a/apps/decodex/src/orchestrator/entrypoints.rs +++ b/apps/decodex/src/orchestrator/entrypoints.rs @@ -527,6 +527,7 @@ fn run_control_plane_api_only_tick(state_store: &StateStore) -> Result { let _ = error; + project_status.connector_state = String::from("config_error"); project_status.warning_count = project_status.warning_count.saturating_add(1); add_operator_snapshot_warning(&mut snapshot, "operator_snapshot_build_failed"); diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html index 9e277c2b..4916ce0f 100644 --- a/apps/decodex/src/orchestrator/operator_dashboard.html +++ b/apps/decodex/src/orchestrator/operator_dashboard.html @@ -7923,7 +7923,7 @@

Run History

) { continue; } - const message = warningNotice(warning); + const message = warningNotice(warning, snapshot); notices.push({ tone: message.tone, title: message.title, @@ -8013,7 +8013,40 @@

Run History

return notices; } - function warningNotice(warning) { + function snapshotBuildFailureProjects(snapshot) { + const warnings = new Set(snapshot?.warnings ?? []); + const apiOnlyBaseline = warnings.has("automation_disabled"); + + return (snapshot?.projects ?? []).filter((project) => { + if (!project?.enabled) { + return false; + } + if (project.connector_state === "config_error") { + return true; + } + if (apiOnlyBaseline && (project.warning_count ?? 0) > 1) { + return true; + } + + return project.connector_state === "degraded" && (project.warning_count ?? 0) > 0; + }); + } + + function snapshotBuildFailureProjectSummary(snapshot) { + const projectIDs = snapshotBuildFailureProjects(snapshot) + .map((project) => project.project_id) + .filter(Boolean); + if (!projectIDs.length) { + return ""; + } + if (projectIDs.length === 1) { + return projectIDs[0]; + } + + return `${projectIDs[0]} +${projectIDs.length - 1}`; + } + + function warningNotice(warning, snapshot) { if (warning === "tracker_rate_limited") { return { tone: "warning", @@ -8028,6 +8061,19 @@

Run History

copy: "Snapshot used local state without live tracker refresh.", }; } + if (warning === "operator_snapshot_build_failed") { + const projectSummary = snapshotBuildFailureProjectSummary(snapshot); + + return { + tone: "warning", + title: projectSummary + ? `Snapshot build failed: ${projectSummary}` + : "Snapshot build failed", + copy: projectSummary + ? "A registered project could not be loaded from its config path; remove it or re-register the project." + : "A registered project could not be loaded from its config path.", + }; + } return { tone: "warning", @@ -8406,6 +8452,13 @@

Run History

title: "Tracker sync paused by rate limit.", }; } + if (project.connector_state === "config_error") { + return { + label: "config error", + tone: "tone-wait", + title: "Registered project config could not be loaded.", + }; + } if ( (project.warning_count ?? 0) > 0 || ["degraded", "stale_cache"].includes(project.connector_state) @@ -8461,6 +8514,9 @@

Run History

if ((project.cleanup_pending_count ?? 0) > 0) { return `${project.cleanup_pending_count} cleanup pending`; } + if (project.connector_state === "config_error") { + return "config error"; + } if ((project.warning_count ?? 0) > 0) { return pluralize(project.warning_count, "warning"); } @@ -8484,6 +8540,9 @@

Run History

if (project.connector_state === "degraded") { return "degraded"; } + if (project.connector_state === "config_error") { + return "config error"; + } return "ok"; } @@ -8729,7 +8788,7 @@

Run History

if (projectHasActiveWork(project)) { return 5; } - if (["backoff", "degraded", "stale_cache"].includes(project.connector_state)) { + if (["backoff", "config_error", "degraded", "stale_cache"].includes(project.connector_state)) { return 6; } if ((project.warning_count ?? 0) > 0) { diff --git a/apps/decodex/src/orchestrator/operator_http.rs b/apps/decodex/src/orchestrator/operator_http.rs index 6cb1fd30..cf66cbbb 100644 --- a/apps/decodex/src/orchestrator/operator_http.rs +++ b/apps/decodex/src/orchestrator/operator_http.rs @@ -463,10 +463,12 @@ fn build_operator_run_activity_event(state_store: &StateStore) -> Result crate::prelude::Result { let now_unix_epoch = OffsetDateTime::now_utc().unix_timestamp(); let (active_runs, recent_runs) = state_store.list_project_runs(project.service_id(), limit)?; + let project_display_name = operator_project_display_name(project); let recent_runs = recent_runs .into_iter() - .map(|run| operator_run_status(project, run, now_unix_epoch)) + .map(|run| operator_run_status(project, &project_display_name, run, now_unix_epoch)) .collect::>>()?; let mut active_runs = active_runs .into_iter() - .map(|run| operator_run_status(project, run, now_unix_epoch)) + .map(|run| operator_run_status(project, &project_display_name, run, now_unix_epoch)) .collect::>>()? .into_iter() .filter(operator_run_counts_as_active) @@ -3428,8 +3429,20 @@ fn hydrate_status_snapshot_state( Ok(()) } +fn append_primary_account_if_missing( + accounts: &mut Vec, + account: Option<&CodexAccountActivitySummary>, +) { + if accounts.is_empty() + && let Some(account) = account + { + accounts.push(account.clone()); + } +} + fn operator_run_status( project: &ServiceConfig, + project_display_name: &str, run: ProjectRunStatus, now_unix_epoch: i64, ) -> crate::prelude::Result { @@ -3479,11 +3492,7 @@ fn operator_run_status( .map(|marker| marker.accounts().to_vec()) .unwrap_or_default(); - if accounts.is_empty() - && let Some(account) = &account - { - accounts.push(account.clone()); - } + append_primary_account_if_missing(&mut accounts, account.as_ref()); let branch_name = run.branch_name().map(str::to_owned); let worktree_path = run @@ -3500,6 +3509,7 @@ fn operator_run_status( Ok(OperatorRunStatus { project_id: project.service_id().to_owned(), + project_display_name: project_display_name.to_owned(), run_id: run.run_id().to_owned(), issue_id: run.issue_id().to_owned(), issue_identifier, @@ -3567,6 +3577,84 @@ fn operator_run_private_evidence( ) } +fn operator_project_display_name(project: &ServiceConfig) -> String { + github_repo_slug_from_origin(project.repo_root()) + .or_else(|| repo_root_path_display_name(project.repo_root())) + .unwrap_or_else(|| project.service_id().to_owned()) +} + +fn github_repo_slug_from_origin(repo_root: &Path) -> Option { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .args(["config", "--get", "remote.origin.url"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let remote_url = String::from_utf8(output.stdout).ok()?; + + parse_github_remote_slug(remote_url.trim()) +} + +fn parse_github_remote_slug(remote_url: &str) -> Option { + let path = remote_url + .strip_prefix("git@github.com:") + .or_else(|| remote_url.strip_prefix("git@github.com-x:")) + .or_else(|| remote_url.strip_prefix("git@github.com-y:")) + .or_else(|| github_remote_path_with_authority(remote_url))?; + let path = path.trim_start_matches('/').trim_end_matches(".git"); + let mut components = path.split('/').filter(|component| !component.trim().is_empty()); + let owner = components.next()?.trim(); + let repo = components.next()?.trim(); + + if components.next().is_some() { + return None; + } + + Some(format!("{owner}/{repo}")) +} + +fn github_remote_path_with_authority(remote_url: &str) -> Option<&str> { + let rest = remote_url + .strip_prefix("https://") + .or_else(|| remote_url.strip_prefix("http://")) + .or_else(|| remote_url.strip_prefix("ssh://"))?; + let (authority, path) = rest.split_once('/')?; + let host = authority.rsplit('@').next().unwrap_or(authority); + let host = host.split(':').next().unwrap_or(host); + + if !matches!(host, "github.com" | "github.com-x" | "github.com-y") { + return None; + } + + Some(path) +} + +fn repo_root_path_display_name(repo_root: &Path) -> Option { + let repo = repo_root.file_name()?.to_string_lossy(); + let repo = repo.trim(); + + if repo.is_empty() { + return None; + } + + let Some(parent) = repo_root.parent().and_then(Path::file_name) else { + return Some(repo.to_owned()); + }; + let parent = parent.to_string_lossy(); + let parent = parent.trim(); + + if parent.is_empty() { + return Some(repo.to_owned()); + } + + Some(format!("{parent}/{repo}")) +} + fn load_operator_run_marker( run: &ProjectRunStatus, ) -> crate::prelude::Result> { diff --git a/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs b/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs index 29dfa39a..f5bdd022 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs @@ -65,6 +65,37 @@ fn control_plane_api_only_snapshot_does_not_tick_enabled_projects() { assert!(!snapshot.warnings.contains(&String::from("no_enabled_projects"))); } +#[test] +fn control_plane_api_only_snapshot_marks_unloadable_project_config() { + let (temp_dir, config, _workflow) = temp_project_layout(); + let _home_guard = + TestEnvVarGuard::set("HOME", temp_dir.path().to_str().expect("home should be utf-8")); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let missing_config_path = temp_dir.path().join("missing/project.toml"); + let registration = ProjectRegistration::from_config( + config.service_id(), + &missing_config_path, + &config, + true, + "test-fingerprint", + ); + + state_store.upsert_project(®istration).expect("project should register"); + + let snapshot = orchestrator::run_control_plane_api_only_tick(&state_store) + .expect("api-only snapshot should still build"); + let project = snapshot.projects.first().expect("enabled project should be listed"); + + assert_eq!(snapshot.projects.len(), 1); + assert_eq!(project.project_id, "pubfi"); + assert!(project.enabled); + assert_eq!(project.connector_state, "config_error"); + assert_eq!(project.warning_count, 2); + assert!(snapshot.active_runs.is_empty()); + assert!(snapshot.warnings.contains(&String::from("automation_disabled"))); + assert!(snapshot.warnings.contains(&String::from("operator_snapshot_build_failed"))); +} + #[test] fn control_plane_api_only_snapshot_includes_local_active_runs() { let (temp_dir, config, _workflow) = temp_project_layout(); diff --git a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs index c56d7b40..36a22e62 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs @@ -718,7 +718,10 @@ fn operator_dashboard_accounts_keeps_compact_table_layout() { assert!(response.contains("run-meta-line")); assert!(response.contains("account-pool-list")); assert!(response.contains("account-pool-guide")); + assert!(response.contains("
")); assert!(response.contains("[\"account\", \"Account\"]")); assert!(response.contains("[\"plan\", \"Plan\"]")); @@ -1190,8 +1193,12 @@ fn operator_dashboard_projects_show_compact_activity_work_and_location() { assert!(response.contains("return { label: \"cleanup blocked\", tone: \"tone-wait\"")); assert!(response.contains("return { label: \"cleanup pending\", tone: \"tone-retained\"")); assert!(response.contains("label: \"sync backoff\"")); + assert!(response.contains("label: \"config error\"")); assert!(response.contains("label: \"sync degraded\"")); assert!(response.contains("label: \"sync degraded\", tone: \"tone-muted\"")); + assert!(response.contains("project.connector_state === \"config_error\"")); + assert!(response.contains("title: projectSummary")); + assert!(response.contains("remove it or re-register the project")); assert!(response.contains("return { label: \"ok\", tone: \"tone-ready\"")); assert!(!response.contains("function projectSyncMeta(project, health)")); assert!(!response.contains("const connectorCopy = projectSyncMeta(project, health);")); diff --git a/apps/decodex/src/orchestrator/tests/operator/status/http.rs b/apps/decodex/src/orchestrator/tests/operator/status/http.rs index 6acba0a1..572f04ad 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/http.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/http.rs @@ -1103,6 +1103,11 @@ fn operator_dashboard_run_activity_event_summarizes_active_runs() { ..Default::default() }; + git_status_success( + config.repo_root(), + &["remote", "add", "origin", "git@github.com:hack-ink/pubfi-mono-v2.git"], + ); + state_store.upsert_project(®istration).expect("project should register"); state_store .record_run_attempt("run-1", &issue.id, 1, "running") @@ -1164,8 +1169,13 @@ fn operator_dashboard_run_activity_event_summarizes_active_runs() { assert_eq!(fingerprint["accountControl"]["mode"], "balanced"); assert!(fingerprint["accounts"].is_array()); assert_eq!(fingerprint["activeRuns"][0]["run_id"], "run-1"); + assert_eq!( + fingerprint["activeRuns"][0]["project_display_name"], + "hack-ink/pubfi-mono-v2" + ); assert_eq!(data["activeRuns"][0]["run_id"], "run-1"); assert_eq!(data["activeRuns"][0]["project_id"], "pubfi"); + assert_eq!(data["activeRuns"][0]["project_display_name"], "hack-ink/pubfi-mono-v2"); assert_eq!(data["activeRuns"][0]["protocol_activity"]["waiting_reason"], "model"); assert_eq!(data["activeRuns"][0]["account"]["account_fingerprint"], "acct-1"); assert_eq!(data["activeRuns"][0]["accounts"][0]["account_fingerprint"], "acct-1"); 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 18a18efd..2ec264d7 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs @@ -18,6 +18,11 @@ fn operator_status_snapshot_includes_active_runs_and_repo_relative_paths() { let issue = sample_issue("Todo", &[]); let worktree_path = config.worktree_root().join("PUB-101"); + git_status_success( + config.repo_root(), + &["remote", "add", "origin", "git@github.com:hack-ink/pubfi-mono-v2.git"], + ); + state_store .record_run_attempt("run-1", &issue.id, 1, "running") .expect("run attempt should record"); @@ -44,6 +49,7 @@ fn operator_status_snapshot_includes_active_runs_and_repo_relative_paths() { assert_eq!(snapshot.active_runs.len(), 1); assert_eq!(snapshot.recent_runs.len(), 1); assert_eq!(snapshot.active_runs[0].project_id, "pubfi"); + assert_eq!(snapshot.active_runs[0].project_display_name, "hack-ink/pubfi-mono-v2"); assert_eq!(snapshot.active_runs[0].run_id, "run-1"); assert_eq!(snapshot.active_runs[0].phase, "executing"); assert_eq!(snapshot.active_runs[0].current_operation, state::RUN_OPERATION_AGENT_RUN); @@ -204,6 +210,11 @@ fn live_operator_status_snapshot_hydrates_active_run_issue_display_metadata() { issue.title = String::from("Hydrate issue display metadata on run rows"); + git_status_success( + config.repo_root(), + &["remote", "add", "origin", "git@github.com:hack-ink/pubfi-mono-v2.git"], + ); + let tracker = FakeTracker::new(vec![issue.clone()]); state_store @@ -226,6 +237,7 @@ fn live_operator_status_snapshot_hydrates_active_run_issue_display_metadata() { let snapshot_json = serde_json::to_value(&snapshot).expect("snapshot should serialize"); assert_eq!(active_run.project_id, config.service_id()); + assert_eq!(active_run.project_display_name, "hack-ink/pubfi-mono-v2"); assert_eq!(active_run.issue_identifier.as_deref(), Some("XY-392")); assert_eq!(active_run.title.as_deref(), Some("Hydrate issue display metadata on run rows")); assert_eq!(active_run.author.as_deref(), Some("Yvette")); @@ -237,6 +249,10 @@ fn live_operator_status_snapshot_hydrates_active_run_issue_display_metadata() { assert_eq!(recent_run.title.as_deref(), Some("Hydrate issue display metadata on run rows")); assert_eq!(recent_run.author.as_deref(), Some("Yvette")); assert_eq!(snapshot_json["active_runs"][0]["project_id"], "pubfi"); + assert_eq!( + snapshot_json["active_runs"][0]["project_display_name"], + "hack-ink/pubfi-mono-v2" + ); assert_eq!(snapshot_json["active_runs"][0]["issue_identifier"], "XY-392"); assert_eq!( snapshot_json["active_runs"][0]["title"], diff --git a/apps/decodex/src/orchestrator/tests/operator/status_support.rs b/apps/decodex/src/orchestrator/tests/operator/status_support.rs index dba80d14..e9c4247c 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status_support.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status_support.rs @@ -206,6 +206,7 @@ fn operator_status_text_active_run() -> orchestrator::OperatorRunStatus { orchestrator::OperatorRunStatus { project_id: String::from("pubfi"), + project_display_name: String::from("hack-ink/pubfi-mono-v2"), run_id: String::from("run-1"), issue_id: String::from("issue-1"), issue_identifier: Some(String::from("PUB-101")), diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs index 05e77519..7555ff71 100644 --- a/apps/decodex/src/orchestrator/types.rs +++ b/apps/decodex/src/orchestrator/types.rs @@ -763,6 +763,7 @@ struct OperatorHistoryLedgerOutcome { #[derive(Clone, Debug, Eq, PartialEq, Serialize)] struct OperatorRunStatus { project_id: String, + project_display_name: String, run_id: String, issue_id: String, issue_identifier: Option, diff --git a/apps/decodex/src/state/internal.rs b/apps/decodex/src/state/internal.rs index 1a0e4185..7519b736 100644 --- a/apps/decodex/src/state/internal.rs +++ b/apps/decodex/src/state/internal.rs @@ -405,6 +405,13 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; Ok(()) } + fn delete_project(&mut self, service_id: &str) -> Result<()> { + self.connection + .execute("DELETE FROM projects WHERE service_id = ?1", params![service_id])?; + + Ok(()) + } + fn upsert_run_attempt(&self, attempt: &RunAttemptRecord) -> Result<()> { self.connection.execute( "INSERT OR REPLACE INTO run_attempts ( diff --git a/apps/decodex/src/state/store.rs b/apps/decodex/src/state/store.rs index f6b33695..94294161 100644 --- a/apps/decodex/src/state/store.rs +++ b/apps/decodex/src/state/store.rs @@ -102,6 +102,20 @@ impl StateStore { Ok(projects) } + /// Remove one registered project from the local control-plane registry. + pub(crate) fn remove_project(&self, service_id: &str) -> Result { + let mut state = self.lock()?; + let removed = state + .projects + .remove(service_id) + .ok_or_else(|| eyre::eyre!("Decodex project `{service_id}` is not registered."))?; + + self.persist_runtime_state_locked(&state)?; + self.delete_project_locked(service_id)?; + + Ok(removed) + } + /// Enable or disable one registered project. pub(crate) fn set_project_enabled(&self, service_id: &str, enabled: bool) -> Result<()> { let mut state = self.lock()?; @@ -1334,6 +1348,17 @@ impl StateStore { sqlite.persist_runtime_state(state) } + fn delete_project_locked(&self, service_id: &str) -> Result<()> { + let Some(sqlite) = self.sqlite.as_ref() else { + return Ok(()); + }; + let mut sqlite = sqlite + .lock() + .map_err(|_| eyre::eyre!("StateStore SQLite mutex is poisoned."))?; + + sqlite.delete_project(service_id) + } + fn upsert_run_attempt_locked(&self, attempt: &RunAttemptRecord) -> 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 74e297d5..bd2ebeef 100644 --- a/apps/decodex/src/state/tests.rs +++ b/apps/decodex/src/state/tests.rs @@ -2013,3 +2013,37 @@ fn state_store_open_refreshes_pubfi_project_registry_across_instances() { "pubfi refresh should replace the stale workflow path" ); } + +#[test] +fn remove_project_deletes_persistent_registry_row() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let state_path = temp_dir.path().join("runtime.db"); + let store = StateStore::open(&state_path).expect("state store should open"); + let registration = ProjectRegistration { + service_id: String::from("vibe-mono"), + config_path: temp_dir.path().join("project.toml"), + repo_root: temp_dir.path().join("repo"), + worktree_root: temp_dir.path().join("repo/.worktrees"), + workflow_path: temp_dir.path().join("repo/WORKFLOW.md"), + tracker_api_key_env_var: String::from("LINEAR_API_KEY_HACKINK"), + github_token_env_var: String::from("GITHUB_PAT_Y"), + enabled: true, + config_fingerprint: String::from("abc123"), + updated_at: String::from("2026-05-25T00:00:00Z"), + updated_at_unix: 1_779_667_200, + }; + + store.upsert_project(®istration).expect("project should persist"); + + let removed = store.remove_project("vibe-mono").expect("project should remove"); + + assert_eq!(removed.service_id(), "vibe-mono"); + assert!(store.list_projects().expect("projects should list").is_empty()); + + let reopened = StateStore::open(&state_path).expect("state store should reopen"); + + assert!( + reopened.list_projects().expect("project registry should load").is_empty(), + "removed project must not remain in SQLite registry" + ); +}