Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
name: ci

# Work happens on a long-lived feature branch (feature/embedded-swift-mcp);
# CI runs on PRs to main, on pushes to main (the merge), and on release tags.
# The package targets macOS and its only dependency is the MCP Swift SDK, so
# every job runs on macOS runners.
# Work happens on long-lived feature branches (feature/**), which may stack on
# each other rather than always targeting main. CI runs on every PR, on pushes
# to main (the merge) and to feature branches (so stacked branches are
# validated even when their PR base is another feature branch), and on release
# tags. The package targets macOS and its only dependency is the MCP Swift SDK,
# so every job runs on macOS runners.
on:
pull_request:
branches: [main]
push:
branches: [main]
branches: [main, 'feature/**']
tags: ['v*']

permissions:
Expand Down Expand Up @@ -40,9 +41,15 @@ jobs:
- name: Build
run: swift build -v

# Unit suite: offline. Args/platform/pins/cache/sha/bundle-extraction.
# The conformance tests skip themselves unless STACKQL_MCP_INTEGRATION
# is set, so they are inert here.
# Build the CloudLens demo app executable explicitly so a GUI-target
# break is attributed clearly (swift build above already covers it).
- name: Build CloudLens demo app
run: swift build -v --product CloudLens

# Unit suite: offline. Covers the package (args/platform/pins/cache/sha/
# bundle-extraction) and CloudLensCore (pulse parsing, finding diff,
# agent prompt/response handling). The conformance tests skip themselves
# unless STACKQL_MCP_INTEGRATION is set, so they are inert here.
- name: Unit tests
run: swift test -v

Expand Down
1 change: 1 addition & 0 deletions .st
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PENDING
25 changes: 23 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ let package = Package(
.macOS(.v13)
],
products: [
.library(name: "StackQLMCP", targets: ["StackQLMCP"])
.library(name: "StackQLMCP", targets: ["StackQLMCP"]),
// CloudLens: the menu bar cloud sentinel demo app. Built as a SwiftPM
// executable so CI can compile it; the signed/notarised .app is
// assembled in the packaging step documented in docs/.
.executable(name: "CloudLens", targets: ["CloudLens"]),
],
dependencies: [
.package(
Expand All @@ -35,6 +39,23 @@ let package = Package(
.testTarget(
name: "StackQLMCPTests",
dependencies: ["StackQLMCP"]
)
),
// CloudLensCore holds the testable app logic (pulses, finding diff,
// the Anthropic agent client, Keychain access) with no SwiftUI, so it
// can be unit-tested on CI without a GUI.
.target(
name: "CloudLensCore",
dependencies: ["StackQLMCP"]
),
// CloudLens is the thin SwiftUI MenuBarExtra shell: @main App, menu
// bar icon state, popover, notifications.
.executableTarget(
name: "CloudLens",
dependencies: ["CloudLensCore", "StackQLMCP"]
),
.testTarget(
name: "CloudLensCoreTests",
dependencies: ["CloudLensCore"]
),
]
)
49 changes: 49 additions & 0 deletions Sources/CloudLens/CloudLensApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import SwiftUI
import AppKit
import CloudLensCore

/// CloudLens: a menu bar cloud sentinel. The menu bar icon reflects overall
/// state (calm / attention / unknown); the popover shows the three pulses; new
/// attention findings fire native notifications that include the SQL behind
/// them.
///
/// Built as a SwiftPM executable so CI can compile it. The signed, notarised
/// .app that bundles the stackql binary is assembled in the packaging step
/// documented in docs/bundling-and-notarisation.md.
@main
struct CloudLensApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate

var body: some Scene {
MenuBarExtra {
PopoverView(vm: delegate.viewModel)
} label: {
// A dedicated observing view so the menu bar glyph updates when the
// view model's @Published state changes.
MenuBarLabel(vm: delegate.viewModel)
}
.menuBarExtraStyle(.window)
}
}

/// Owns the view model and the unattended schedule. Using an app delegate keeps
/// the sentinel running on its timer whether or not the popover is open, and
/// gives a clean place to hang the launch sequence.
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
let viewModel = SentinelViewModel()

/// How often the pulse suite runs unattended.
private static let refreshInterval: TimeInterval = 15 * 60
private var timer: Timer?

func applicationDidFinishLaunching(_ notification: Notification) {
Notifications.shared.requestAuthorization()
Task { await viewModel.refresh() }
timer = Timer.scheduledTimer(
withTimeInterval: Self.refreshInterval, repeats: true
) { [viewModel] _ in
Task { @MainActor in await viewModel.refresh() }
}
}
}
11 changes: 11 additions & 0 deletions Sources/CloudLens/MenuBarLabel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import SwiftUI

/// The menu bar glyph. Split into its own observing view so SwiftUI re-renders
/// the icon when the view model's @Published state changes.
struct MenuBarLabel: View {
@ObservedObject var vm: SentinelViewModel

var body: some View {
Image(systemName: vm.stateSymbol)
}
}
50 changes: 50 additions & 0 deletions Sources/CloudLens/Notifications.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation
import CloudLensCore
import UserNotifications
import os

/// Wraps UNUserNotificationCenter to post a native notification per new
/// finding. Per the design, the notification body includes the SQL behind the
/// finding so it is auditable from the notification itself.
@MainActor
final class Notifications {
static let shared = Notifications()
private let log = Logger(subsystem: "io.stackql.cloudlens", category: "notifications")

/// Ask once for permission. Safe to call on every launch.
func requestAuthorization() {
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .sound]
) { [log] granted, error in
if let error {
log.error("notification auth error: \(error.localizedDescription)")
} else {
log.debug("notification auth granted=\(granted)")
}
}
}

/// Post one notification per new finding. Only attention-level findings
/// notify; info-level changes update the popover silently.
func post(_ findings: [Finding]) {
let center = UNUserNotificationCenter.current()
for finding in findings where finding.severity == .attention {
let content = UNMutableNotificationContent()
content.title = finding.title
// Body carries the human detail plus the SQL behind the finding.
content.body = "\(finding.detail)\n\nSQL:\n\(finding.sql)"
content.sound = .default

let request = UNNotificationRequest(
identifier: finding.id, // stable id de-dupes repeat notifications
content: content,
trigger: nil
)
center.add(request) { [log] error in
if let error {
log.error("post notification failed: \(error.localizedDescription)")
}
}
}
}
}
107 changes: 107 additions & 0 deletions Sources/CloudLens/PopoverView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import SwiftUI
import CloudLensCore

/// The popover content: overall state header, the three pulses grouped, and a
/// refresh control. Each finding shows its title, detail, and the SQL behind
/// it (the same SQL that rides along in the notification).
struct PopoverView: View {
@ObservedObject var vm: SentinelViewModel

var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
Divider()
if vm.findings.isEmpty {
Text(vm.isRunning ? "Checking..." : "No findings yet.")
.foregroundStyle(.secondary)
} else {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
ForEach(PulseKind.allCases, id: \.self) { kind in
pulseSection(kind)
}
}
}
.frame(maxHeight: 320)
}
Divider()
footer
}
.padding(14)
.frame(width: 360)
}

private var header: some View {
HStack(spacing: 8) {
Image(systemName: vm.stateSymbol)
.foregroundStyle(color(for: vm.state))
Text(vm.stateLabel).font(.headline)
Spacer()
if vm.isRunning { ProgressView().controlSize(.small) }
}
}

private func pulseSection(_ kind: PulseKind) -> some View {
let items = vm.findings.filter { $0.kind == kind }
return Group {
if !items.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text(title(for: kind))
.font(.caption).bold()
.foregroundStyle(.secondary)
ForEach(items) { finding in
findingRow(finding)
}
}
}
}
}

private func findingRow(_ finding: Finding) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .top, spacing: 6) {
Image(systemName: finding.severity == .attention
? "exclamationmark.triangle.fill" : "info.circle")
.foregroundStyle(finding.severity == .attention ? .orange : .secondary)
.font(.caption)
VStack(alignment: .leading, spacing: 2) {
Text(finding.title).font(.callout)
Text(finding.detail).font(.caption).foregroundStyle(.secondary)
Text(finding.sql)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary)
.textSelection(.enabled)
}
}
}
}

private var footer: some View {
HStack {
if let lastRun = vm.lastRun {
Text("Last checked \(lastRun.formatted(date: .omitted, time: .shortened))")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button("Refresh") { Task { await vm.refresh() } }
.disabled(vm.isRunning)
Button("Quit") { NSApplication.shared.terminate(nil) }
}
}

private func title(for kind: PulseKind) -> String {
switch kind {
case .spend: return "Spend pulse"
case .exposure: return "Exposure pulse"
case .posture: return "Org posture (github)"
}
}

private func color(for state: SentinelState) -> Color {
switch state {
case .calm: return .green
case .attention: return .orange
case .unknown: return .secondary
}
}
}
65 changes: 65 additions & 0 deletions Sources/CloudLens/SentinelViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation
import Combine
import CloudLensCore
import StackQLMCP

/// SwiftUI-facing view model. It owns a `SentinelModel` (the framework-free
/// core) and republishes its state as `@Published` properties so the menu bar
/// and popover update. Kept in the app target so the core stays UI-agnostic.
@MainActor
final class SentinelViewModel: ObservableObject {
@Published private(set) var state: SentinelState = .unknown
@Published private(set) var findings: [Finding] = []
@Published private(set) var lastRun: Date?
@Published private(set) var isRunning = false

private let model: SentinelModel

init() {
// Demo configuration: the github org-posture pulse in null_auth mode
// (zero cloud creds) plus the cloud pulses, which degrade gracefully to
// "not configured" until AWS credentials are added.
let pulses: [any Pulse] = [
PosturePulse(org: "stackql"),
SpendPulse(),
ExposurePulse(),
]
var options = Options()
options.mode = .readOnly
options.auth = ["github": ["type": "null_auth"]]

self.model = SentinelModel(
pulses: pulses,
serverOptions: options,
onNewFindings: { fresh in
Notifications.shared.post(fresh)
}
)
}

var stateSymbol: String {
switch state {
case .calm: return "checkmark.seal"
case .attention: return "exclamationmark.triangle.fill"
case .unknown: return "questionmark.circle"
}
}

var stateLabel: String {
switch state {
case .calm: return "All calm"
case .attention: return "Needs attention"
case .unknown: return "Not checked yet"
}
}

/// Run the pulse suite and copy the model's state across for the views.
func refresh() async {
isRunning = true
await model.runOnce()
state = model.state
findings = model.orderedFindings
lastRun = model.lastRun
isRunning = model.isRunning
}
}
Loading
Loading