Skip to content
Draft
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
118 changes: 118 additions & 0 deletions macos/Sources/BurrowStreamReport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//
// BurrowStreamReport.swift
// Burrow
//
// Reduces the NDJSON progress feed from `burrow-engine clean --stream` / `optimize --stream`
// into the (groups, summary) shape TaskReportView renders — the same TaskRunReport the old
// human-text `parseTaskReport` produced, so the Clean/Optimize views + completion notification
// are unchanged. The engine emits one JSON object per line:
//
// clean live: {"event":"removed","path":P,"bytes":N} | {"event":"failed",...} |
// {"event":"protected",...} then {"event":"done","freed_bytes":N,
// "freed_human":H,"removed":N,"failed":N,"protected":N}
// clean preview: {"event":"would_remove","path":P,"bytes":N} then {"event":"done",
// "dry_run":true,"would_free_bytes":N,"would_free_human":H,"count":N}
// optimize live: {"event":"task","name":T,"ok":B,"error":E|null} then {"event":"done",
// "ok":B,"tasks":N,"failed":N}
// optimize prev: {"event":"would_run","name":T,"description":D} then {"event":"done",
// "dry_run":true,"tasks":N}
//
// Parsed line-by-line with JSONSerialization (like BurrowEnvelope) — the reduce is called on the
// accumulated `[String]` after every streamed line (throttled) and once at exit.
//

import Foundation

enum BurrowStreamReport {
/// Reduce the accumulated NDJSON lines into a TaskRunReport. Unparseable / non-event lines are
/// skipped, so a stray warning on stderr never breaks the result screen.
static func reduce(_ lines: [String]) -> TaskRunReport {
var items: [TaskItem] = []
var summary: TaskSummary?

for line in lines {
guard let obj = object(from: line), let event = obj["event"] as? String else { continue }
switch event {
case "removed":
if let p = obj["path"] as? String { items.append(TaskItem(marker: .ok, text: p)) }
case "would_remove":
if let p = obj["path"] as? String { items.append(TaskItem(marker: .action, text: p)) }
case "failed":
if let p = obj["path"] as? String { items.append(TaskItem(marker: .error, text: p)) }
case "protected":
if let p = obj["path"] as? String { items.append(TaskItem(marker: .review, text: p)) }
case "task":
if let name = obj["name"] as? String {
let ok = obj["ok"] as? Bool ?? true
items.append(TaskItem(marker: ok ? .ok : .error, text: name))
}
case "would_run":
if let name = obj["name"] as? String { items.append(TaskItem(marker: .action, text: name)) }
case "done":
summary = makeSummary(from: obj, itemCount: items.count)
default:
break
}
}

let groups = items.isEmpty
? []
: [TaskGroup(title: NSLocalizedString("Cleanup", comment: "streamed op group title"),
items: items)]
return (groups: groups, summary: summary)
}

/// A short human label for the HUD detail line, extracted from one NDJSON event. Empty for the
/// terminal `done` (the HUD keeps the last item line) or an unparseable line.
static func hudLine(_ line: String) -> String {
guard let obj = object(from: line), let event = obj["event"] as? String else { return "" }
switch event {
case "removed", "would_remove", "failed", "protected":
if let p = obj["path"] as? String { return (p as NSString).lastPathComponent }
case "task", "would_run":
if let name = obj["name"] as? String { return name }
default:
break
}
return ""
}

// MARK: - internals

private static func object(from line: String) -> [String: Any]? {
let t = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard t.first == "{", let data = t.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return nil }
return obj
}

/// Build the summary from a `done` event, covering both the live and preview shapes of clean
/// and optimize. `itemCount` is the number of accumulated item lines (fallback when a specific
/// count field is absent).
private static func makeSummary(from done: [String: Any], itemCount: Int) -> TaskSummary {
// Freed disk (live clean) vs would-free (preview) vs no size (optimize).
let freedHuman = done["freed_human"] as? String
let wouldFreeHuman = done["would_free_human"] as? String
let space = freedHuman ?? wouldFreeHuman ?? ""

// Item count: removed (clean live) → count (clean preview) → tasks (optimize) → accumulated.
let count = intField(done, "removed")
?? intField(done, "count")
?? intField(done, "tasks")
?? itemCount
let items = count > 0 ? String(count) : ""

// Live clean prints the actual freed size → surface it as the freed-space number.
let freeChange = freedHuman ?? ""

return TaskSummary(space: space, items: items, categories: "", freeChange: freeChange, freeNow: "")
}

/// Read an integer field regardless of whether JSONSerialization bridged it to Int or Double.
private static func intField(_ obj: [String: Any], _ key: String) -> Int? {
if let n = obj[key] as? Int { return n }
if let d = obj[key] as? Double { return Int(d) }
return nil
}
}
6 changes: 4 additions & 2 deletions macos/Sources/MoleCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,10 @@ enum MoleCLI {
/// over any system `mo`: users run our engine with zero install and never touch upstream
/// (GPL-relicensed) mo. It's part of the signed app bundle, so it's a trusted location.
static func bundledExecutable() -> String? {
guard let url = Bundle.main.url(forResource: "mole", withExtension: nil,
subdirectory: "engine") else { return nil }
// The single bundled `burrow-engine` binary, staged as Resources/burrow (bundle-burrow.sh).
// It replaced the old Resources/engine/mole digger — one binary that does the work AND
// speaks the envelope. Same file BurrowConductor.executableURL resolves.
guard let url = Bundle.main.url(forResource: "burrow", withExtension: nil) else { return nil }
return FileManager.default.isExecutableFile(atPath: url.path) ? url.path : nil
}

Expand Down
6 changes: 4 additions & 2 deletions macos/Sources/OperationFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -291,9 +291,11 @@ extension ToolOperation where Report == TaskRunReport {
static func moleStream(_ args: [String], gate: Gate = .none,
elevated: Bool = false, label: String?,
notifyOnEnd: Bool = false) -> ToolOperation {
// The bundled engine streams NDJSON (clean/optimize --stream); reduce those events into the
// same (groups, summary) shape the human-text parser produced. See BurrowStreamReport.
ToolOperation(label: label, arguments: args, gate: gate, elevated: elevated,
reduce: { parseTaskReport($0) },
hudLine: { TaskReportText.line($0) },
reduce: { BurrowStreamReport.reduce($0) },
hudLine: { BurrowStreamReport.hudLine($0) },
notifyOnEnd: notifyOnEnd,
finalDetail: { $0.summary?.completionLine ?? "" })
}
Expand Down
74 changes: 74 additions & 0 deletions macos/Tests/BurrowStreamReportTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// BurrowStreamReportTests.swift
// BurrowTests
//
// Locks the NDJSON → TaskRunReport contract that OperationFlow uses for streamed
// clean/optimize. The engine emits one JSON object per line; the reducer must build the
// same (groups, summary) shape the views render.
//

import XCTest
@testable import Burrow

final class BurrowStreamReportTests: XCTestCase {
func testCleanPreview_yieldsCleanedSummary() {
let lines = [
#"{"event":"would_remove","path":"/Users/x/Library/Caches/npm","bytes":201129000}"#,
#"{"event":"done","dry_run":true,"would_free_bytes":402438000,"would_free_human":"383.8MB","count":372}"#,
]
let report = BurrowStreamReport.reduce(lines)
XCTAssertEqual(report.groups.count, 1)
XCTAssertEqual(report.groups.first?.items.count, 1)
XCTAssertEqual(report.summary?.space, "383.8MB")
XCTAssertEqual(report.summary?.items, "372")
// No freeChange on a preview → "Cleaned", not "Freed".
XCTAssertEqual(report.summary?.completionLine, "Cleaned 383.8MB · 372 items")
}

func testCleanLive_yieldsFreedSummary() {
let lines = [
#"{"event":"removed","path":"/a/x","bytes":10}"#,
#"{"event":"failed","path":"/a/y","error":"denied"}"#,
#"{"event":"protected","path":"/a/keep"}"#,
#"{"event":"done","freed_bytes":2048,"freed_human":"2.0KB","removed":1,"failed":1,"protected":1}"#,
]
let report = BurrowStreamReport.reduce(lines)
XCTAssertEqual(report.groups.first?.items.count, 3, "removed + failed + protected are shown")
// A live done carries freed_human → freeChange → "Freed".
XCTAssertEqual(report.summary?.completionLine, "Freed 2.0KB · 1 items")
}

func testOptimize_taskEvents() {
let lines = [
#"{"event":"task","name":"flush_dns","ok":true,"error":null}"#,
#"{"event":"task","name":"restart_dock","ok":false,"error":"no proc"}"#,
#"{"event":"done","ok":false,"tasks":2,"failed":1}"#,
]
let report = BurrowStreamReport.reduce(lines)
XCTAssertEqual(report.groups.first?.items.count, 2)
XCTAssertEqual(report.summary?.items, "2", "task count")
}

func testGarbageLinesAreIgnored() {
// A stray non-JSON line (e.g. a warning) must not break the reduce.
let lines = [
"warning: something on stderr",
#"{"event":"removed","path":"/a","bytes":1}"#,
"",
#"{"event":"done","freed_bytes":1,"freed_human":"1B","removed":1,"failed":0,"protected":0}"#,
]
let report = BurrowStreamReport.reduce(lines)
XCTAssertEqual(report.groups.first?.items.count, 1)
XCTAssertNotNil(report.summary)
}

func testHudLine_extractsReadableLabel() {
XCTAssertEqual(
BurrowStreamReport.hudLine(#"{"event":"removed","path":"/a/b/npm cache","bytes":1}"#),
"npm cache")
XCTAssertEqual(
BurrowStreamReport.hudLine(#"{"event":"task","name":"flush_dns","ok":true}"#),
"flush_dns")
XCTAssertEqual(BurrowStreamReport.hudLine("not json"), "")
}
}
13 changes: 12 additions & 1 deletion macos/Tests/OperationFlowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,24 @@ final class OperationFlowTests: XCTestCase {
}

/// Canned dry-run output in mo's report shape (parseTaskReport-compatible).
// The human-text output the `cleanOp()` fixture's `parseTaskReport` reducer consumes.
static let cannedClean: [ProcessEvent] = [
.line("➤ Developer tools"),
.line(" → npm cache, 191.8MB"),
.line("Potential space: 383.8MB | Items: 372 | Categories: 20"),
.exited(0),
]

// The engine's `clean --stream` NDJSON preview, which the `moleStream(...)` op's
// BurrowStreamReport reducer consumes. A dry-run done carries `would_free_human` →
// summary.space (no freeChange), so the completion line reads "Cleaned 383.8MB · 372 items"
// — same summary the old human-text "Potential space" preview produced.
static let cannedCleanStream: [ProcessEvent] = [
.line(#"{"event":"would_remove","path":"/Users/x/Library/Caches/npm cache","bytes":201129000}"#),
.line(#"{"event":"done","dry_run":true,"would_free_bytes":402438000,"would_free_human":"383.8MB","count":372}"#),
.exited(0),
]

typealias CleanReport = (groups: [TaskGroup], summary: TaskSummary?)

static func cleanOp(gate: ToolOperation<CleanReport>.Gate = .none,
Expand Down Expand Up @@ -166,7 +177,7 @@ final class OperationFlowTests: XCTestCase {
// and the parsed summary replaces the last streamed line as the final
// detail — that's the body a completion notification carries.
func testNotifyOnEnd_andFinalDetail_reachOperationCenter() async throws {
let port = FakeProcessPort(script: Self.cannedClean)
let port = FakeProcessPort(script: Self.cannedCleanStream)
let center = OperationCenter()
let flow = OperationFlow<TaskRunReport>(process: port, hasFullDiskAccess: { true },
resolveMo: { _ in "/usr/local/bin/mo" }, center: center)
Expand Down
53 changes: 19 additions & 34 deletions macos/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,43 +94,28 @@ targets:
- name: Bundle burrow-engine (MIT)
basedOnDependencyAnalysis: false
script: |
# Stage the MIT engine into the app's Resources so users run our engine with zero
# install (never upstream GPL mo). Activates when the engine source is present —
# a release/CI vendors it (vendor/burrow-engine) or sets BURROW_ENGINE_SRC; dev
# builds without it fall back to a system-installed engine.
# Build the ONE MIT `burrow-engine` binary and stage it as Resources/burrow — it does all
# the work natively AND emits the stable Burrow envelope + NDJSON streaming, so it replaces
# both the old burrow-cli conductor and the burrow-digger engine (a single binary, no
# separate engine dir). Activates when a burrow-engine checkout is present — a release/CI
# vendors it (vendor/burrow-engine) or sets BURROW_ENGINE_SRC; dev builds without it fall
# back to a system-installed engine.
#
# NOTE: Xcode's CodeSign phase runs AFTER all build phases, so it re-signs the app
# over the freshly-added engine and the resource seal ends up stale. The final seal
# therefore happens POST-build: use scripts/build.sh locally, and the release
# pipeline's inside-out re-sign (#178) seals it for distribution.
# NOTE: Xcode's CodeSign phase runs AFTER all build phases, so it re-signs the app over the
# freshly-added binary and the resource seal ends up stale. The final seal therefore
# happens POST-build: use scripts/build.sh locally, and the release pipeline's inside-out
# re-sign (#178) seals it for distribution.
ENGINE_SRC="${BURROW_ENGINE_SRC:-$SRCROOT/vendor/burrow-engine}"
# Require a real engine checkout, not just the directory: a plain
# (no-submodule) checkout — e.g. the PR `ci` job — leaves an EMPTY
# vendor/burrow-engine dir for the gitlink, so `[ -d ]` alone would be
# true and bundle-engine.sh would then fail building from missing
# source. Gate on mole + cmd/ so we bundle only a populated checkout
# and otherwise fall through to the system engine (build still green).
if [ -f "$ENGINE_SRC/mole" ] && [ -d "$ENGINE_SRC/cmd" ]; then
"$SRCROOT/scripts/bundle-engine.sh" "$ENGINE_SRC" "$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH"
# Require a real engine checkout, not just the gitlink dir: a plain (no-submodule)
# checkout — e.g. the PR `ci` job — leaves an EMPTY vendor/burrow-engine dir, so `[ -d ]`
# alone would be true and the cargo build would fail from missing source. Gate on
# Cargo.toml + src/ + cargo so we bundle only a populated checkout, and otherwise fall
# through to the system engine (build still green).
if [ -f "$ENGINE_SRC/Cargo.toml" ] && [ -d "$ENGINE_SRC/src" ] && command -v cargo >/dev/null 2>&1; then
"$SRCROOT/scripts/bundle-burrow.sh" "$ENGINE_SRC" "$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH" \
|| echo "warning: burrow-engine build failed — Burrow falls back to a system engine."
else
echo "warning: burrow-engine source not present at $ENGINE_SRC — Burrow falls back to a system engine. Set BURROW_ENGINE_SRC or init the vendor/burrow-engine submodule."
fi
- name: Bundle burrow (conductor)
basedOnDependencyAnalysis: false
script: |
# Stage the FSL `burrow` conductor beside the MIT engine so the GUI can shell out to
# `burrow <cmd> --json` (stable envelope + NDJSON streaming) instead of the engine
# directly. Gated on a POPULATED vendor/burrow-cli checkout: a plain (no-submodule)
# checkout — e.g. the PR `ci` job — leaves an EMPTY gitlink dir, so we bundle only when
# Cargo.toml is really present and otherwise fall through to the direct engine (build
# still green, exactly like the engine phase above). Final resource seal happens
# POST-build (scripts/build.sh locally; the release pipeline's inside-out re-sign).
BURROW_CLI_SRC="${BURROW_CLI_SRC:-$SRCROOT/vendor/burrow-cli}"
if [ -f "$BURROW_CLI_SRC/Cargo.toml" ] && [ -d "$BURROW_CLI_SRC/src" ] && command -v cargo >/dev/null 2>&1; then
"$SRCROOT/scripts/bundle-burrow.sh" "$BURROW_CLI_SRC" "$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH" \
|| echo "warning: burrow conductor build failed — Burrow falls back to the direct engine."
else
echo "warning: burrow-cli source or cargo not present at $BURROW_CLI_SRC — Burrow falls back to the direct engine. Set BURROW_CLI_SRC or init the vendor/burrow-cli submodule."
echo "warning: burrow-engine source or cargo not present at $ENGINE_SRC — Burrow falls back to a system engine. Set BURROW_ENGINE_SRC or init the vendor/burrow-engine submodule."
fi
- name: Bundle fclones (MIT sidecar)
basedOnDependencyAnalysis: false
Expand Down
Loading
Loading