mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
fix(cron): Run now now actually runs + markdown rendering in install sheet
Two fixes chained from manually testing site-status-checker v1.1.0.
---
Cron Run now was a no-op when the Hermes gateway scheduler wasn't
already running. `hermes cron run <id>` only marks a job as due on
the next scheduler tick — it doesn't execute. During dev or right
after install (gateway stopped, as the logs the user pasted showed),
the user's click resulted in nothing happening: job queued, tick
never comes, zero agent sessions, zero output, dashboard never
updates. Exactly the failure mode they hit.
Fix: CronViewModel.runNow now calls `hermes cron run <id>` followed
by `hermes cron tick` after a short delay. `tick` runs all due jobs
once and exits — so the just-queued job actually executes, and
exits cleanly whether the scheduler is running or not. Redundant
(not duplicative) when the gateway is live. The user sees a status
message whether it succeeded or failed instead of silent nothing.
---
Markdown rendering in install-sheet screens. Template READMEs,
manifest descriptions, field help text, and cron prompts all
reasonably contain markdown — but the install preview sheet was
rendering everything as plain text, so `[Create one](https://…)`
would appear verbatim instead of as a link, `# Site Status Checker`
as a literal pound sign, etc.
New Features/Templates/Views/TemplateMarkdown.swift — a tiny,
dependency-free markdown renderer scoped to what template authors
actually write:
- Headings (#..######) → larger bold Text with vertical spacing
- Bullet and numbered lists → hanging-indent rows with •/1. prefix
- Fenced code blocks (```) → monospaced with quaternary background
- Paragraphs → regular Text, with inline formatting via SwiftUI's
built-in AttributedString(markdown:) so **bold**, *italic*,
`code`, and [links](urls) work
- Blank lines separate blocks
Two entry points: `TemplateMarkdown.render(_ source:)` returns a
View for multi-block content (README preview), and
`TemplateMarkdown.inlineText(_ source:)` returns a Text for
one-line strings where block structure doesn't apply (field
descriptions, manifest tagline).
Wired into:
- TemplateInstallSheet.readmeSection — was plain Text(readme), now
renders the full README with structure.
- TemplateInstallSheet.manifestHeader description — inline-only
(taglines rarely have block structure).
- TemplateInstallSheet.cronSection — new DisclosureGroup per cron
job exposes the full prompt with markdown rendering. Users can
now verify what the installer will register with Hermes before
clicking Install. {{PROJECT_DIR}} / {{TEMPLATE_ID}} tokens show
unresolved here; they get substituted when the installer calls
hermes cron create.
- TemplateConfigSheet field descriptions — inline markdown so
`[Create a token](https://...)`-style links render as real links.
Not a full CommonMark implementation — no tables, no blockquotes,
no images, no HTML passthrough. Those can evolve as templates need
them. Safe with untrusted input: never executes scripts or renders
raw HTML.
Scope stays tight: 57/57 Swift tests + 24/24 Python tests still pass.
No new tests for the markdown helper itself — rendering is visual,
hard to unit-test meaningfully without snapshot-testing infra, and
the surface is small enough that changes would be caught by the
visual regression of any template install.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,7 +65,38 @@ final class CronViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runNow(_ job: HermesCronJob) {
|
func runNow(_ job: HermesCronJob) {
|
||||||
runAndReload(["cron", "run", job.id], success: "Scheduled for next tick")
|
// `hermes cron run <id>` only marks the job as due on the next
|
||||||
|
// scheduler tick — it doesn't actually execute. If the Hermes
|
||||||
|
// gateway's scheduler isn't running (common during dev + right
|
||||||
|
// after install), the user's "Run now" click results in zero
|
||||||
|
// visible effect because the tick never comes. We follow up
|
||||||
|
// with `hermes cron tick` which runs all due jobs once and
|
||||||
|
// exits. Redundant-but-harmless when the gateway is running;
|
||||||
|
// the actual trigger when it isn't.
|
||||||
|
let svc = fileService
|
||||||
|
let jobID = job.id
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
let runResult = svc.runHermesCLI(args: ["cron", "run", jobID], timeout: 30)
|
||||||
|
// Give `cron run` a moment to register the queue entry
|
||||||
|
// before forcing the tick. A few hundred ms is enough;
|
||||||
|
// longer only delays the user-visible feedback.
|
||||||
|
try? await Task.sleep(for: .milliseconds(250))
|
||||||
|
let tickResult = svc.runHermesCLI(args: ["cron", "tick"], timeout: 60)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if runResult.exitCode == 0 && tickResult.exitCode == 0 {
|
||||||
|
self.message = "Job executed (see Output panel for details)"
|
||||||
|
} else {
|
||||||
|
let errOutput = runResult.exitCode != 0 ? runResult.output : tickResult.output
|
||||||
|
self.message = "Run failed: \(errOutput.prefix(200))"
|
||||||
|
self.logger.warning("cron runNow failed: run=\(runResult.exitCode), tick=\(tickResult.exitCode) output=\(errOutput)")
|
||||||
|
}
|
||||||
|
self.load()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
|
self?.message = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteJob(_ job: HermesCronJob) {
|
func deleteJob(_ job: HermesCronJob) {
|
||||||
|
|||||||
@@ -113,7 +113,11 @@ struct TemplateConfigSheet: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
if let description = field.description, !description.isEmpty {
|
if let description = field.description, !description.isEmpty {
|
||||||
Text(description)
|
// Inline markdown so descriptions can include
|
||||||
|
// `[Create one](https://…)`-style links to token
|
||||||
|
// generation pages, **bold** emphasis on important
|
||||||
|
// prerequisites, etc.
|
||||||
|
TemplateMarkdown.inlineText(description)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|||||||
@@ -175,7 +175,10 @@ struct TemplateInstallSheet: View {
|
|||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Text(manifest.description)
|
// Inline-only markdown — descriptions are a sentence or two;
|
||||||
|
// bold/italic/code/links are all that reasonable template
|
||||||
|
// authors use there.
|
||||||
|
TemplateMarkdown.inlineText(manifest.description)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
if let author = manifest.author {
|
if let author = manifest.author {
|
||||||
@@ -220,16 +223,40 @@ struct TemplateInstallSheet: View {
|
|||||||
|
|
||||||
private func cronSection(plan: TemplateInstallPlan) -> some View {
|
private func cronSection(plan: TemplateInstallPlan) -> some View {
|
||||||
section(title: "Cron jobs (created disabled — you can enable each one manually)", subtitle: nil) {
|
section(title: "Cron jobs (created disabled — you can enable each one manually)", subtitle: nil) {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
ForEach(plan.cronJobs, id: \.name) { job in
|
ForEach(plan.cronJobs, id: \.name) { job in
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Image(systemName: "clock.arrow.circlepath")
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
.foregroundStyle(.secondary)
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
|
||||||
Text(job.name).font(.callout.monospaced())
|
|
||||||
Text("schedule: \(job.schedule)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(job.name).font(.callout.monospaced())
|
||||||
|
Text("schedule: \(job.schedule)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prompt preview — disclosed in an expandable
|
||||||
|
// group so the preview stays compact when the
|
||||||
|
// user doesn't care to read it. Markdown-rendered
|
||||||
|
// so prompts that include `code`, **bold**, or
|
||||||
|
// enumerated steps look right. Tokens like
|
||||||
|
// {{PROJECT_DIR}} are still visible here — they
|
||||||
|
// get substituted when the installer calls
|
||||||
|
// `hermes cron create`.
|
||||||
|
if let prompt = job.prompt, !prompt.isEmpty {
|
||||||
|
DisclosureGroup("Prompt") {
|
||||||
|
ScrollView {
|
||||||
|
TemplateMarkdown.render(prompt)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 140)
|
||||||
|
.padding(8)
|
||||||
|
.background(.quaternary.opacity(0.4))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.leading, 26)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,11 +329,10 @@ struct TemplateInstallSheet: View {
|
|||||||
if let readme = viewModel.readmeBody {
|
if let readme = viewModel.readmeBody {
|
||||||
section(title: "README", subtitle: nil) {
|
section(title: "README", subtitle: nil) {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
Text(readme)
|
TemplateMarkdown.render(readme)
|
||||||
.font(.callout)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.frame(maxHeight: 200)
|
.frame(maxHeight: 260)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Minimal markdown renderer used by the template install/config UIs.
|
||||||
|
///
|
||||||
|
/// SwiftUI `Text` has built-in inline-markdown support via
|
||||||
|
/// `AttributedString(markdown:)` — bold, italic, inline code, links.
|
||||||
|
/// That's enough for field descriptions + template taglines. For
|
||||||
|
/// longer content (README preview, full doc blocks), this helper adds
|
||||||
|
/// block-level handling: lines starting with `#`/`##`/`###` render
|
||||||
|
/// as bigger bold text; lines starting with `-`/`*`/`1.` render as
|
||||||
|
/// list items with a hanging indent; fenced ``` ``` blocks render as
|
||||||
|
/// monospaced; blank lines become paragraph breaks.
|
||||||
|
///
|
||||||
|
/// Scope is intentionally small. This isn't a full CommonMark
|
||||||
|
/// renderer — it's "enough markdown to make template READMEs look
|
||||||
|
/// right in the install sheet without pulling in a dependency." If
|
||||||
|
/// the set of templates needs more over time, evolve this file or
|
||||||
|
/// graduate to a proper library.
|
||||||
|
enum TemplateMarkdown {
|
||||||
|
|
||||||
|
/// Render a markdown source string as a SwiftUI view. Preserves
|
||||||
|
/// reading order and approximate visual hierarchy. Safe with
|
||||||
|
/// untrusted input — we never execute HTML or scripts.
|
||||||
|
@ViewBuilder
|
||||||
|
static func render(_ source: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
let blocks = parse(source)
|
||||||
|
ForEach(blocks.indices, id: \.self) { i in
|
||||||
|
block(blocks[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inline-only markdown (bold/italic/code/links) as a single
|
||||||
|
/// `Text`. Use for short strings where block structure doesn't
|
||||||
|
/// apply — field labels, one-line descriptions.
|
||||||
|
static func inlineText(_ source: String) -> Text {
|
||||||
|
if let attr = try? AttributedString(
|
||||||
|
markdown: source,
|
||||||
|
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||||
|
) {
|
||||||
|
return Text(attr)
|
||||||
|
}
|
||||||
|
return Text(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Block model
|
||||||
|
|
||||||
|
fileprivate enum Block {
|
||||||
|
case paragraph(AttributedString)
|
||||||
|
case heading(level: Int, text: AttributedString)
|
||||||
|
case bullet(AttributedString)
|
||||||
|
case numbered(index: Int, text: AttributedString)
|
||||||
|
case code(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Parser
|
||||||
|
|
||||||
|
fileprivate static func parse(_ source: String) -> [Block] {
|
||||||
|
var blocks: [Block] = []
|
||||||
|
var lines = source.components(separatedBy: "\n")
|
||||||
|
var i = 0
|
||||||
|
while i < lines.count {
|
||||||
|
let line = lines[i]
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
// Fenced code block.
|
||||||
|
if trimmed.hasPrefix("```") {
|
||||||
|
var body: [String] = []
|
||||||
|
i += 1
|
||||||
|
while i < lines.count {
|
||||||
|
let inner = lines[i]
|
||||||
|
if inner.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
|
||||||
|
i += 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
body.append(inner)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
blocks.append(.code(body.joined(separator: "\n")))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heading.
|
||||||
|
if let headingMatch = trimmed.firstMatch(of: /^(#{1,6})\s+(.*)$/) {
|
||||||
|
let level = (headingMatch.1).count
|
||||||
|
let text = String(headingMatch.2)
|
||||||
|
blocks.append(.heading(level: level, text: renderInline(text)))
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bullet list.
|
||||||
|
if let bulletMatch = line.firstMatch(of: /^\s*[-*]\s+(.*)$/) {
|
||||||
|
let text = String(bulletMatch.1)
|
||||||
|
blocks.append(.bullet(renderInline(text)))
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numbered list.
|
||||||
|
if let numMatch = line.firstMatch(of: /^\s*(\d+)\.\s+(.*)$/) {
|
||||||
|
let index = Int(String(numMatch.1)) ?? 1
|
||||||
|
let text = String(numMatch.2)
|
||||||
|
blocks.append(.numbered(index: index, text: renderInline(text)))
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blank line — skip.
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph — collect contiguous non-blank lines that
|
||||||
|
// aren't headings/lists/fences into one paragraph block.
|
||||||
|
var paragraphLines: [String] = [line]
|
||||||
|
i += 1
|
||||||
|
while i < lines.count {
|
||||||
|
let next = lines[i]
|
||||||
|
let nextTrim = next.trimmingCharacters(in: .whitespaces)
|
||||||
|
if nextTrim.isEmpty { break }
|
||||||
|
if nextTrim.hasPrefix("```") { break }
|
||||||
|
if nextTrim.firstMatch(of: /^#{1,6}\s/) != nil { break }
|
||||||
|
if next.firstMatch(of: /^\s*[-*]\s+/) != nil { break }
|
||||||
|
if next.firstMatch(of: /^\s*\d+\.\s+/) != nil { break }
|
||||||
|
paragraphLines.append(next)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
let joined = paragraphLines.joined(separator: " ")
|
||||||
|
blocks.append(.paragraph(renderInline(joined)))
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse inline markdown (bold, italic, inline code, links) into
|
||||||
|
/// an AttributedString. Falls back to plain text on parse failure.
|
||||||
|
fileprivate static func renderInline(_ source: String) -> AttributedString {
|
||||||
|
if let attr = try? AttributedString(
|
||||||
|
markdown: source,
|
||||||
|
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||||
|
) {
|
||||||
|
return attr
|
||||||
|
}
|
||||||
|
return AttributedString(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rendering
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
fileprivate static func block(_ b: Block) -> some View {
|
||||||
|
switch b {
|
||||||
|
case .paragraph(let text):
|
||||||
|
Text(text)
|
||||||
|
.font(.callout)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
case .heading(let level, let text):
|
||||||
|
headingText(text: text, level: level)
|
||||||
|
case .bullet(let text):
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
|
Text("•").font(.callout)
|
||||||
|
Text(text).font(.callout)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
case .numbered(let index, let text):
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
|
Text("\(index).").font(.callout.monospacedDigit())
|
||||||
|
Text(text).font(.callout)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
case .code(let src):
|
||||||
|
Text(src)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.padding(8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
fileprivate static func headingText(text: AttributedString, level: Int) -> some View {
|
||||||
|
switch level {
|
||||||
|
case 1: Text(text).font(.title2.bold()).padding(.top, 8)
|
||||||
|
case 2: Text(text).font(.title3.bold()).padding(.top, 6)
|
||||||
|
case 3: Text(text).font(.headline).padding(.top, 4)
|
||||||
|
default: Text(text).font(.subheadline.bold()).padding(.top, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user