Files
scarf/scarf/scarf/Core/Services/KanbanTenantResolver.swift
T
Alan Wizemann adcc984091 feat(kanban): full read/write board with per-project tenants
Lifts Scarf's Kanban surface from the v2.6 read-only list to a
drag-and-drop board with the complete Hermes v0.12 mutation surface
wired up, plus per-project boards bound to a Scarf-minted tenant slug
and a read-only board on iOS.

Why now: the v2.6 list was a placeholder shipped while upstream Kanban
collab was still mid-rework. v0.12 stabilized the 27-verb CLI; this
release makes Scarf a real GUI client for it. Driving real tasks
end-to-end exposed and closed a connected bug pattern (claim vs
dispatch, silent skipped_unassigned, integer-vs-ISO timestamps,
parser-leaked "(no" sentinel) that would have shipped as latent UX
papercuts otherwise.

ScarfCore: KanbanService actor (Sendable, pure I/O) wrapping every
verb; KanbanTenantReader cross-platform manifest projection; eight
new model types (TaskDetail, Comment, Event, Run, Stats, Assignee,
CreateRequest, Filters); KanbanError; pure transition planner that
maps drag-drop column changes to verb sequences, tested against
canonical Hermes JSON fixtures.

Mac: KanbanBoardView orchestrator with five-column drag-drop layout,
optimistic-merge state, KanbanInspectorPane side-pane (Comments /
Events / Runs / Log tabs, Log streams worker stdout every 2s while
running), inline assignee picker, health banner for unassigned and
last-failed-run states. New Task sheet defaults to active profile
and auto-fires kanban dispatch on submit. Sidebar moved Kanban from
Manage to Monitor. Read-only KanbanListView preserved as Board|List
toggle for narrow windows / accessibility.

Per-project: DashboardTab.kanban tab on every project gated on
hasKanban; KanbanTenantResolver mints scarf:<slug> tenants on first
interaction and persists to .scarf/manifest.json (immutable across
rename); ProjectAgentContextService surfaces the tenant in the
AGENTS.md scarf-managed block so agents pass --tenant <slug> on
kanban create. New kanban_summary dashboard widget; vocabulary
mirrored in tools/widget-schema.json and site/widgets.js.

iOS: read-only board on the project tab via paged single-column
Picker, modal detail sheet with Comments / Events / Runs. Mutations
+ drag-drop deferred to v2.8.

Tests: 19 new pure-logic tests covering decoding, planner verb
mapping, argv assembly, glance string formatting, and parser
rejection of the kanban assignees empty-state sentinel. All 348
ScarfCore tests pass.

Constraints documented in CLAUDE.md: no within-column reorder
(Hermes has no update --priority verb); no live watch streaming
yet (5s polling for board, 2s for log); no bulk re-tag for legacy
NULL-tenant tasks. Pre-v0.12 Hermes hosts gracefully hide the
surface end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:59:21 +02:00

185 lines
7.2 KiB
Swift

import Foundation
import os
import ScarfCore
/// Resolves and mints per-project Kanban tenant slugs.
///
/// Hermes Kanban has no `project_id` column the closest namespace
/// primitive is the optional `tenant TEXT` column on `tasks`. Scarf
/// uses it as a surrogate project key: each Scarf project gets a
/// stable `scarf:<slug>` tenant minted on first kanban interaction
/// and persisted to `<project>/.scarf/manifest.json`.
///
/// **Invariants:**
/// - Once minted, the tenant is immutable across renames. Tasks
/// already on the board carry the original slug; renaming the
/// project would orphan them.
/// - The `scarf:` prefix prevents collisions with hand-typed
/// tenants from CLI users.
/// - Bare projects (no manifest) get a minimal `manifest.json`
/// with only `kanbanTenant` set on first mint.
struct KanbanTenantResolver: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "KanbanTenantResolver")
/// Prefix that distinguishes Scarf-minted tenants from hand-typed
/// ones. Public for callers that group "scarf-managed" projects in
/// the global tenant filter.
static let prefix = "scarf:"
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
// MARK: - Public
/// Returns the existing tenant for a project, or `nil` if none has
/// been minted yet. Read-only never writes.
nonisolated func tenant(for project: ProjectEntry) -> String? {
readManifest(for: project)?.kanbanTenant
}
/// Returns the existing tenant or mints a new one if absent. Writes
/// the new tenant back to the project's manifest.json. Idempotent
/// calling twice on a fresh project returns the same value.
nonisolated func resolveOrMint(for project: ProjectEntry) throws -> String {
if let existing = tenant(for: project), !existing.isEmpty {
return existing
}
let candidate = Self.makeSlug(for: project.name)
let unique = uniquify(candidate, against: project)
try persist(tenant: unique, for: project)
Self.logger.info("minted kanban tenant '\(unique, privacy: .public)' for project '\(project.name, privacy: .public)'")
return unique
}
// MARK: - Slug generation (pure)
/// Build a `scarf:<slug>` tenant from a project name. Lowercased,
/// hyphenated, 48 chars after the prefix. Public for tests.
nonisolated static func makeSlug(for name: String) -> String {
let lower = name.lowercased()
let mapped = lower.unicodeScalars.map { scalar -> Character in
let c = Character(scalar)
if c.isLetter || c.isNumber { return c }
return "-"
}
let collapsed = String(mapped)
.split(separator: "-", omittingEmptySubsequences: true)
.joined(separator: "-")
let trimmed = collapsed.isEmpty ? "project" : collapsed
let bounded = String(trimmed.prefix(48))
return prefix + bounded
}
// MARK: - Private
/// Disambiguate against tenants already used by other projects on
/// this host. Reads every project's manifest; `O(projects)` fine
/// for typical project counts (handful to dozens). Suffixes `-2`,
/// `-3`, until unique.
nonisolated private func uniquify(_ candidate: String, against project: ProjectEntry) -> String {
let used = Set(allMintedTenants(excluding: project))
if !used.contains(candidate) { return candidate }
var n = 2
while n < 1000 {
let next = candidate + "-\(n)"
if !used.contains(next) { return next }
n += 1
}
// Defensive should never hit. Fall back to a UUID suffix.
return candidate + "-" + UUID().uuidString.prefix(6).lowercased()
}
/// Collect every Scarf-minted tenant currently on disk, excluding
/// the given project. Used to dedup new mints.
nonisolated private func allMintedTenants(excluding project: ProjectEntry) -> [String] {
let registryPath = context.paths.home + "/scarf/projects.json"
guard let data = context.readData(registryPath),
let registry = try? JSONDecoder().decode(ProjectRegistry.self, from: data)
else {
return []
}
return registry.projects.compactMap { other in
guard other.id != project.id else { return nil }
return readManifest(for: other)?.kanbanTenant
}
}
nonisolated private func readManifest(for project: ProjectEntry) -> ProjectTemplateManifest? {
let path = manifestPath(for: project)
let transport = context.makeTransport()
guard transport.fileExists(path),
let data = try? transport.readFile(path)
else {
return nil
}
return try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data)
}
/// Write the tenant back to `<project>/.scarf/manifest.json`. If
/// the file doesn't exist yet (bare project), create a minimal
/// manifest with just the kanbanTenant set. The remaining
/// manifest fields use sentinel values that the
/// `ProjectAgentContextService` reader tolerates: id stays at the
/// project's slug-form, version stays "0.0.0", and contents claims
/// nothing none of which the reader requires for the Kanban
/// tenant line.
nonisolated private func persist(tenant: String, for project: ProjectEntry) throws {
let path = manifestPath(for: project)
let transport = context.makeTransport()
// Ensure .scarf/ exists.
let scarfDir = project.scarfDir
if !transport.fileExists(scarfDir) {
try transport.createDirectory(scarfDir)
}
let updated: ProjectTemplateManifest
if let existing = readManifest(for: project) {
// Mutate the existing manifest in place. var fields permit
// this; let fields are preserved.
var copy = existing
copy.kanbanTenant = tenant
updated = copy
} else {
updated = ProjectTemplateManifest(
schemaVersion: 3,
id: "scarf/\(project.id)",
name: project.name,
version: "0.0.0",
minScarfVersion: nil,
minHermesVersion: nil,
author: nil,
description: "",
category: nil,
tags: nil,
icon: nil,
screenshots: nil,
contents: TemplateContents(
dashboard: false,
agentsMd: false,
instructions: nil,
skills: nil,
cron: nil,
memory: nil,
config: nil,
slashCommands: nil
),
config: nil,
kanbanTenant: tenant
)
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(updated)
try transport.writeFile(path, data: data)
}
nonisolated private func manifestPath(for project: ProjectEntry) -> String {
project.scarfDir + "/manifest.json"
}
}