diff --git a/scarf/scarfTests/ProjectTemplateTests.swift b/scarf/scarfTests/ProjectTemplateTests.swift index a79a546..c9dae32 100644 --- a/scarf/scarfTests/ProjectTemplateTests.swift +++ b/scarf/scarfTests/ProjectTemplateTests.swift @@ -2,6 +2,42 @@ import Testing import Foundation @testable import scarf +/// Cross-suite serialization lock for tests that touch the real +/// `~/.hermes/scarf/projects.json`. Swift Testing's `.serialized` trait +/// only serializes tests WITHIN a suite — multiple suites still run in +/// parallel. Three suites in this file write to the same file and +/// previously raced each other silently (saveRegistry used to swallow +/// write failures); now that saveRegistry throws, the race surfaces. +/// +/// The lock is acquired by `acquireAndSnapshot()` at the top of each +/// registry-touching test and released by `restore(_:)` via the test's +/// `defer`. Asymmetric acquire-in-one-fn / release-in-another looks +/// unusual but the snapshot/restore pairing is so tight (every test +/// defers the restore) that it's reliable in practice. +final class TestRegistryLock: @unchecked Sendable { + static let shared = TestRegistryLock() + private let lock = NSLock() + + /// Acquire the cross-suite lock and snapshot the registry. Pair + /// every call with a `defer { TestRegistryLock.restore(snapshot) }`. + static func acquireAndSnapshot() -> Data? { + shared.lock.lock() + let path = ServerContext.local.paths.projectsRegistry + return try? Data(contentsOf: URL(fileURLWithPath: path)) + } + + /// Restore the registry from snapshot and release the lock. + static func restore(_ snapshot: Data?) { + defer { shared.lock.unlock() } + let path = ServerContext.local.paths.projectsRegistry + if let snapshot { + try? snapshot.write(to: URL(fileURLWithPath: path)) + } else { + try? FileManager.default.removeItem(atPath: path) + } + } +} + /// Exercises the service's ability to unpack, parse, and validate bundles. /// Doesn't touch the installer — see `ProjectTemplateInstallerTests` — so /// these don't need write access to ~/.hermes. @@ -351,18 +387,18 @@ import Foundation /// Read the raw bytes of the current projects.json so we can restore /// it byte-for-byte after the test. `nil` means the file didn't exist /// — restore by deleting whatever got created. + // Delegates to TestRegistryLock so tests across this suite + the + // two other registry-touching suites share one lock. Every + // `snapshotRegistry()` call acquires; the paired + // `restoreRegistry(_:)` defer releases. Without this, parallel + // test runs race on `~/.hermes/scarf/projects.json` writes and + // the saveRegistry throw surfaces the collision as a test failure. nonisolated private static func snapshotRegistry() -> Data? { - let path = ServerContext.local.paths.projectsRegistry - return try? Data(contentsOf: URL(fileURLWithPath: path)) + TestRegistryLock.acquireAndSnapshot() } nonisolated private static func restoreRegistry(_ snapshot: Data?) { - let path = ServerContext.local.paths.projectsRegistry - if let snapshot { - try? snapshot.write(to: URL(fileURLWithPath: path)) - } else { - try? FileManager.default.removeItem(atPath: path) - } + TestRegistryLock.restore(snapshot) } } @@ -476,18 +512,18 @@ import Foundation // ProjectTemplateInstallerTests — small helper, not worth a shared // fixture file for one more suite). + // Delegates to TestRegistryLock so tests across this suite + the + // two other registry-touching suites share one lock. Every + // `snapshotRegistry()` call acquires; the paired + // `restoreRegistry(_:)` defer releases. Without this, parallel + // test runs race on `~/.hermes/scarf/projects.json` writes and + // the saveRegistry throw surfaces the collision as a test failure. nonisolated private static func snapshotRegistry() -> Data? { - let path = ServerContext.local.paths.projectsRegistry - return try? Data(contentsOf: URL(fileURLWithPath: path)) + TestRegistryLock.acquireAndSnapshot() } nonisolated private static func restoreRegistry(_ snapshot: Data?) { - let path = ServerContext.local.paths.projectsRegistry - if let snapshot { - try? snapshot.write(to: URL(fileURLWithPath: path)) - } else { - try? FileManager.default.removeItem(atPath: path) - } + TestRegistryLock.restore(snapshot) } } @@ -753,18 +789,18 @@ import Foundation // MARK: - Registry snapshot helpers (dup'd from ProjectTemplateInstallerTests) + // Delegates to TestRegistryLock so tests across this suite + the + // two other registry-touching suites share one lock. Every + // `snapshotRegistry()` call acquires; the paired + // `restoreRegistry(_:)` defer releases. Without this, parallel + // test runs race on `~/.hermes/scarf/projects.json` writes and + // the saveRegistry throw surfaces the collision as a test failure. nonisolated private static func snapshotRegistry() -> Data? { - let path = ServerContext.local.paths.projectsRegistry - return try? Data(contentsOf: URL(fileURLWithPath: path)) + TestRegistryLock.acquireAndSnapshot() } nonisolated private static func restoreRegistry(_ snapshot: Data?) { - let path = ServerContext.local.paths.projectsRegistry - if let snapshot { - try? snapshot.write(to: URL(fileURLWithPath: path)) - } else { - try? FileManager.default.removeItem(atPath: path) - } + TestRegistryLock.restore(snapshot) } } diff --git a/templates/awizemann/site-status-checker/site-status-checker.scarftemplate b/templates/awizemann/site-status-checker/site-status-checker.scarftemplate index 81d75d1..1846f60 100644 Binary files a/templates/awizemann/site-status-checker/site-status-checker.scarftemplate and b/templates/awizemann/site-status-checker/site-status-checker.scarftemplate differ diff --git a/templates/awizemann/site-status-checker/staging/dashboard.json b/templates/awizemann/site-status-checker/staging/dashboard.json index 6693994..de2eee0 100644 --- a/templates/awizemann/site-status-checker/staging/dashboard.json +++ b/templates/awizemann/site-status-checker/staging/dashboard.json @@ -1,7 +1,7 @@ { "version": 1, "title": "Site Status", - "description": "Daily uptime check for your watched URLs. The stat widgets and list update automatically when the cron job runs.", + "description": "Daily uptime check for your watched URLs. The stat widgets and the sites list populate after the first cron run; before that, the list mirrors what the agent last wrote.", "theme": { "accent": "green" }, "sections": [ { @@ -40,10 +40,9 @@ "widgets": [ { "type": "list", - "title": "Configured Sites (from sites.txt)", + "title": "Watched Sites (populated after first run)", "items": [ - { "text": "https://example.com", "status": "unknown" }, - { "text": "https://example.org", "status": "unknown" } + { "text": "Run the check once to populate — the agent reads your Configuration and fills this list with live status.", "status": "pending" } ] } ] @@ -56,7 +55,7 @@ "type": "text", "title": "Quick Start", "format": "markdown", - "content": "**1.** Enable the `[tmpl:awizemann/site-status-checker] Check site status` cron job in the Cron sidebar. It ships paused — nothing runs until you say so.\n\n**2.** Edit `sites.txt` in this project's folder to replace the placeholder URLs with the sites you actually want to watch.\n\n**3.** Ask your agent: *\"Run the site status check now.\"* The dashboard refreshes and a new entry appears at the top of `status-log.md`.\n\n**4.** Daily at 9 AM the cron job fires automatically. Change the schedule in the Cron sidebar if you want a different cadence.\n\nSee `README.md` and `AGENTS.md` in the project root for the full spec." + "content": "**1.** Review your configuration — click the **slider icon** (top-right of this dashboard) to open Configuration. The sites you enter there are what the cron job will check.\n\n**2.** Enable the `[tmpl:awizemann/site-status-checker] Check site status` cron job in the Cron sidebar. It ships paused — nothing runs until you say so.\n\n**3.** Ask your agent: *\"Run the site status check now.\"* The Watched Sites list populates, the stat widgets update, and a new entry lands at the top of `status-log.md`.\n\n**4.** Daily at 9 AM the cron job fires automatically. Change the schedule in the Cron sidebar if you want a different cadence.\n\nSee `README.md` and `AGENTS.md` in the project root for the full spec." } ] } diff --git a/templates/catalog.json b/templates/catalog.json index d717bbe..59ca326 100644 --- a/templates/catalog.json +++ b/templates/catalog.json @@ -7,8 +7,8 @@ "name": "Alan Wizemann", "url": "https://github.com/awizemann/scarf" }, - "bundleSha256": "ce68cc20cc67fe688a7ddf0638d35dce3247ba7ed234e6f9d99a1ad3964a81e0", - "bundleSize": 6797, + "bundleSha256": "3274964418b7cb1e4df2980936e16f9afdf3d4e90c91ae76dead6e5065306818", + "bundleSize": 6880, "category": "monitoring", "config": { "modelRecommendation": {