Skip to content
Merged
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
12 changes: 12 additions & 0 deletions GraphcodeKit/Sources/Domain/LoopType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,16 @@ public enum LoopType: String, Codable, CaseIterable, Sendable {
public var runsUnattended: Bool {
self == .timeBased || self == .goalBased
}

/// The one other shape a committed type can take after creation. Goal and time are each
/// other's only destination because they are the two unattended types: the session keeps
/// running and only what ends it changes. Turn waits on a human and composite is a
/// sub-graph, so neither is one decision away from anything.
public var retypeTarget: LoopType? {
switch self {
case .goalBased: .timeBased
case .timeBased: .goalBased
case .sketch, .turnBased, .composite: nil
}
}
}
2 changes: 1 addition & 1 deletion GraphcodeKit/Sources/Domain/SketchPromotion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Foundation
/// context, and a promoted goal loop starts already knowing what was found.
///
/// The same cases retype a goal loop as time-based and a time-based loop as a goal loop
/// (`GraphStore.retypeTarget(of:)`): each is the one decision the other type never asked
/// (`LoopType.retypeTarget`): each is the one decision the other type never asked
/// for, and the session, edges and memory carry over the same way.
///
/// Promotion is one-way by construction. There is no case that lands on `.sketch`, so
Expand Down
16 changes: 2 additions & 14 deletions GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1287,7 +1287,7 @@
onRemoveMemory: onRemoveMemory,
onRefinePlaybook: onRefinePlaybook,
onRollbackPlaybook: onRollbackPlaybook,
onAnnounceError: effects.errors.append,

Check warning on line 1290 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

converting non-Sendable function value to '@sendable (String) -> Void' may introduce data races
// The board's gate forwards like any other side effect: a loop inside a piloted
// composite is a real loop whose session got the standard briefing — teaching
// verbs the child store would refuse is exactly the incoherence the gate exists
Expand Down Expand Up @@ -1891,7 +1891,7 @@
? "update refused: nothing in it applies to a main loop — give it a shape "
+ "first with `graphcode node promote`"
: "update refused: nothing in it applies to a \(node.loopType) loop"
+ (Self.retypeTarget(of: node.loopType) == nil
+ (node.loopType.retypeTarget == nil
? "" : " — change its type with `graphcode node promote`"))
return
}
Expand Down Expand Up @@ -1961,7 +1961,7 @@
}
let source = node.loopType
let retypes = source != .sketch
guard !retypes || Self.retypeTarget(of: source) == promotion.targetType else {
guard !retypes || source.retypeTarget == promotion.targetType else {
announceError(
"promotion refused: \(node.title) is a \(source) loop — a main loop takes any shape, "
+ "a goal loop can become time-based, and a time-based loop a goal loop")
Expand Down Expand Up @@ -2097,18 +2097,6 @@
}
}

/// The one other shape each committed type can take. Goal and time are each other's
/// only destination because they are the two unattended types: the session keeps
/// running and only what ends it changes. Turn waits on a human and composite is a
/// sub-graph, so neither is one decision away from anything.
static func retypeTarget(of source: LoopType) -> LoopType? {
switch source {
case .goalBased: .timeBased
case .timeBased: .goalBased
case .sketch, .turnBased, .composite: nil
}
}

/// What a retyped session is told, in order: drop the old shape, then the new shape's
/// own opening prompt — verbatim, so a `/goal` or `/loop` directive arms the way a fresh
/// launch would arm it rather than arriving as prose about one.
Expand Down Expand Up @@ -2162,7 +2150,7 @@
pendingFollowUps.append(
PendingFollowUp(id: UUID(), nodeID: nodeID, text: message, watchedPostID: nil))
}
await drainAndBroadcast()

Check warning on line 2153 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'drainAndBroadcast(broadcastErrors:)' is unused
}

/// A learned note into a node's memory log — `graphcode node memo`, the agent-written
Expand Down Expand Up @@ -2320,7 +2308,7 @@
let followUp = PendingFollowUp(id: UUID(), nodeID: nodeID, text: prompt, watchedPostID: nil)
pendingFollowUps.append(followUp)
goalFollowUps[nodeID] = followUp.id
await drainAndBroadcast()

Check warning on line 2311 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'drainAndBroadcast(broadcastErrors:)' is unused
}

/// Opening a resolved loop whose session was ended brings its conversation back. Panes
Expand Down Expand Up @@ -2860,7 +2848,7 @@
return
}
if node.loopType == .composite {
await runInSubGraph(nodeID, .restartSessions, broadcastErrors: false)

Check warning on line 2851 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'runInSubGraph(_:_:broadcastErrors:)' is unused
return
}
await restart([node])
Expand All @@ -2869,7 +2857,7 @@
private func restartSessions() async {
let live = graph.nodes.filter { !$0.isResolved }
for composite in live where composite.loopType == .composite {
await runInSubGraph(composite.id, .restartSessions, broadcastErrors: false)

Check warning on line 2860 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'runInSubGraph(_:_:broadcastErrors:)' is unused
}
await restart(live.filter { $0.loopType != .composite })
}
Expand Down Expand Up @@ -2925,7 +2913,7 @@
// set below — a graph whose nodes have all stopped aggregates to `.idle`.
if node.loopType == .composite, let subGraph = node.subGraph {
for child in subGraph.nodes where !child.isResolved {
await runInSubGraph(

Check warning on line 2916 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'runInSubGraph(_:_:broadcastErrors:)' is unused
node.id, .stopNode(child.id), broadcastErrors: false)
}
}
Expand Down Expand Up @@ -4035,7 +4023,7 @@
onRemoveMemory: onRemoveMemory,
onRefinePlaybook: onRefinePlaybook,
onRollbackPlaybook: onRollbackPlaybook,
onAnnounceError: effects.errors.append,

Check warning on line 4026 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

converting non-Sendable function value to '@sendable (String) -> Void' may introduce data races
onMailroomEnabled: onMailroomEnabled,
goalCache: goalCache,
recurrence: effects.recurrence,
Expand All @@ -4051,7 +4039,7 @@
processRecurrence(effects.recurrence)
graph.nodes[id: ownerID]?.subGraph = await child.graph
rollUpComposite(ownerID)
await drainAndBroadcast()

Check warning on line 4042 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'drainAndBroadcast(broadcastErrors:)' is unused
}

/// Applies the recurrence requests a child store handed up, in order — an update's
Expand Down Expand Up @@ -4122,7 +4110,7 @@
now.timeIntervalSince(node.createdAt) >= stallAfter
{
markStalled(nodeID)
await drainAndBroadcast()

Check warning on line 4113 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'drainAndBroadcast(broadcastErrors:)' is unused
return
}

Expand Down
5 changes: 5 additions & 0 deletions graphcode/Sources/Features/Project/ProjectCanvasCards.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ extension ProjectCanvasView {
}
}
}
if let target = node.loopType.retypeTarget, node.state != .stopped {
Button("Change to \(target.displayName)…") {
store.send(.promoteNodeRequested(node.id, to: target))
}
}
// Available on a resolved loop too: a finished loop is still something you read the
// graph by, and its name is what you read.
Button("Rename…") { store.send(.renameNodeRequested(node.id)) }
Expand Down
21 changes: 15 additions & 6 deletions graphcode/Sources/Features/Project/ProjectFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,19 @@ struct ProjectFeature {
var nodePendingRename: UUID?
var draftRenameTitle = ""

/// Set while a sketch's promotion form is up: which sketch, which shape it is
/// taking, and the one field that shape asks for. Flat fields to match how the
/// rename prompt and the creation form's `draft*` fields already work here.
/// Set while a promotion form is up — a sketch taking a shape, or a goal or time loop
/// swapping to the other: which loop, which shape it is taking, and the one field
/// that shape asks for. Flat fields to match how the rename prompt and the creation
/// form's `draft*` fields already work here.
var nodePendingPromotion: UUID?
var promotionTarget: LoopType = .goalBased
var promotionGoal = ""
var promotionPausesBeforeWritesOnly = false
var promotionInterval: IntervalChoice = .hourly
var promotionCustomInterval = ""
/// What each pass does when a goal loop turns time-based. A sketch repeats its note;
/// a goal loop has none, and repeating its withdrawn goal would be the wrong task.
var promotionTask = ""

/// Loops a human said really are a beginning, despite having no edges — the answer
/// to a card's "Mark as entry". View state, not graph state: the graph's own answer
Expand Down Expand Up @@ -692,18 +696,23 @@ extension ProjectFeature {
return remembered == .composite ? .sketch : (remembered ?? .sketch)
}

/// Resets the promotion form's one field and opens it for the chosen target — only
/// ever for a sketch; anything already shaped has nothing to promote.
/// Resets the promotion form's fields and opens it for the chosen target — for a sketch
/// taking any shape, or a goal or time loop taking the other one. The daemon refuses a
/// stopped loop, so the form never opens on one.
private func openPromotionForm(
_ state: inout State, nodeID: UUID, target: LoopType
) -> Effect<Action> {
guard state.graph.nodes[id: nodeID]?.loopType == .sketch else { return .none }
guard let node = state.graph.nodes[id: nodeID],
node.loopType == .sketch
|| (node.loopType.retypeTarget == target && node.state != .stopped)
else { return .none }
state.nodePendingPromotion = nodeID
state.promotionTarget = target
state.promotionGoal = ""
state.promotionPausesBeforeWritesOnly = false
state.promotionInterval = .hourly
state.promotionCustomInterval = ""
state.promotionTask = ""
return .none
}

Expand Down
22 changes: 17 additions & 5 deletions graphcode/Sources/Features/Project/ProjectFeatureState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -330,18 +330,30 @@ extension ProjectFeature.State {
case .turnBased:
return .turn(pausesBeforeWritesOnly: promotionPausesBeforeWritesOnly)
case .timeBased:
let note =
nodePendingPromotion
.flatMap { graph.nodes[id: $0]?.firstInstruction }?
.trimmingCharacters(in: .whitespaces) ?? ""
let task = note.isEmpty ? "Carry on with what this session has been doing." : note
let task: String
if promotionSource == .goalBased {
task = promotionTask.trimmingCharacters(in: .whitespacesAndNewlines)
guard !task.isEmpty else { return nil }
} else {
let note =
nodePendingPromotion
.flatMap { graph.nodes[id: $0]?.firstInstruction }?
.trimmingCharacters(in: .whitespaces) ?? ""
task = note.isEmpty ? "Carry on with what this session has been doing." : note
}
let cadence = promotionInterval.directiveValue(custom: promotionCustomInterval)
return .timed(triggerPrompt: "/loop \(cadence) \(task)")
case .sketch, .composite:
return nil
}
}

/// The type the loop under the promotion form has now — `.sketch` for a promotion,
/// `.goalBased` or `.timeBased` for a retype.
var promotionSource: LoopType? {
nodePendingPromotion.flatMap { graph.nodes[id: $0]?.loopType }
}

/// The composed `/loop` line the promotion form shows before it is written.
var promotionTriggerPreview: String {
guard promotionTarget == .timeBased, case .timed(let prompt)? = promotion else { return "" }
Expand Down
54 changes: 42 additions & 12 deletions graphcode/Sources/Features/Project/SketchPromotionForm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import ComposableArchitecture
import GraphcodeKit
import SwiftUI

/// The one-field form a sketch's "Promote to…" opens — never a re-brief.
/// The one-field form a sketch's "Promote to…" opens — never a re-brief. A goal or time
/// loop's "Change to…" opens it too, for the other of the two.
///
/// The dialog asks for exactly what the target type needs and a sketch never did:
/// a done check for Goal, where to pause for Turn, a cadence for Timed. Everything
Expand All @@ -15,6 +16,8 @@ struct SketchPromotionForm: View {
store.nodePendingPromotion.flatMap { store.graph.nodes[id: $0] }
}

private var retypes: Bool { (store.promotionSource ?? .sketch) != .sketch }

var body: some View {
VStack(alignment: .leading, spacing: 16) {
header
Expand All @@ -35,15 +38,24 @@ struct SketchPromotionForm: View {
RoundedRectangle(cornerRadius: 2.5)
.fill(store.promotionTarget.accent)
.frame(width: 9, height: 9)
Text("Promote to \(store.promotionTarget.displayName)")
Text("\(retypes ? "Change" : "Promote") to \(store.promotionTarget.displayName)")
.font(.system(size: 16, weight: .semibold))
}
Text(
"Keep the session, add a shape — \(node?.title ?? "this loop") stays "
+ "exactly where it is."
)
.font(.system(size: 12))
.foregroundStyle(.white.opacity(0.45))
Text(subtitle)
.font(.system(size: 12))
.foregroundStyle(.white.opacity(0.45))
}
}

private var subtitle: String {
let title = node?.title ?? "this loop"
switch store.promotionSource {
case .goalBased:
return "Keep the session, change what ends it — \(title) stops working toward its goal."
case .timeBased:
return "Keep the session, change what ends it — \(title) stops its cadence."
default:
return "Keep the session, add a shape — \(title) stays exactly where it is."
}
}

Expand Down Expand Up @@ -71,9 +83,19 @@ struct SketchPromotionForm: View {
}
}
case .timeBased:
if store.promotionSource == .goalBased {
DraftField(
label: "What should each pass do?",
help: "Its goal is withdrawn, so each pass needs a task of its own."
) {
DraftProseField(
placeholder: "check the flake hasn't come back", text: $store.promotionTask)
}
}
DraftField(
label: "How often?",
help: "The work it repeats is what the session is already doing."
help: store.promotionSource == .goalBased
? nil : "The work it repeats is what the session is already doing."
) {
VStack(alignment: .leading, spacing: 8) {
Picker("", selection: $store.promotionInterval) {
Expand Down Expand Up @@ -108,8 +130,8 @@ struct SketchPromotionForm: View {
.font(.system(size: 12.5))
.foregroundStyle(.white.opacity(0.7))
Spacer(minLength: 8)
if store.promotion == nil, store.promotionTarget == .goalBased {
Text("Say what done looks like to continue")
if store.promotion == nil, let hint = missingFieldHint {
Text(hint)
.font(.system(size: 11.5))
.foregroundStyle(.white.opacity(0.62))
.lineLimit(1)
Expand All @@ -118,13 +140,21 @@ struct SketchPromotionForm: View {
}
}

private var missingFieldHint: String? {
switch store.promotionTarget {
case .goalBased: "Say what done looks like to continue"
case .timeBased: "Say what each pass does to continue"
default: nil
}
}

private var promoteButton: some View {
let isValid = store.promotion != nil
return Button {
store.send(.promotionConfirmed)
} label: {
HStack(spacing: 6) {
Text("Promote")
Text(retypes ? "Change" : "Promote")
.font(.system(size: 13, weight: .semibold))
Text("⏎").font(.system(size: 11, design: .monospaced)).opacity(0.7)
}
Expand Down
126 changes: 126 additions & 0 deletions graphcode/Tests/LoopRetypeFormTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import ComposableArchitecture
import Foundation
import GraphcodeKit
import Testing

@testable import graphcode

/// The canvas half of goal ↔ time retyping: "Change to…" opens the promotion form on a
/// goal or time loop for the other type, and confirming sends the same `promoteNode` the
/// CLI's `node promote` does.
@Suite
@MainActor
struct LoopRetypeFormTests {
private static let project = ProjectRef(path: "/tmp/retype", name: "retype")

private let goal = LoopNode(
title: "Fix", loopType: .goalBased, goal: GoalSpec(summary: "the flake is fixed"))
private let time = LoopNode(
title: "Watch", loopType: .timeBased, triggerPrompt: "/loop 1h triage new issues")

private func state(_ nodes: [LoopNode]) -> ProjectFeature.State {
ProjectFeature.State(
graph: LoopGraph(project: Self.project, nodes: IdentifiedArray(uniqueElements: nodes)))
}

@Test
func eachUnattendedTypeRetypesOnlyToTheOther() {
#expect(LoopType.goalBased.retypeTarget == .timeBased)
#expect(LoopType.timeBased.retypeTarget == .goalBased)
#expect(LoopType.turnBased.retypeTarget == nil)
#expect(LoopType.composite.retypeTarget == nil)
#expect(LoopType.sketch.retypeTarget == nil)
}

@Test
func aGoalLoopsTimedFormNeedsATaskOfItsOwn() {
var state = state([goal])
state.nodePendingPromotion = goal.id
state.promotionTarget = .timeBased
state.promotionInterval = .hourly

// A goal loop has no note to repeat, and repeating its withdrawn goal would be wrong.
#expect(state.promotion == nil)
state.promotionTask = "check the flake hasn't come back"
#expect(state.promotion == .timed(triggerPrompt: "/loop 1h check the flake hasn't come back"))
}

@Test
func changingAGoalLoopToTimedSendsThePromotion() async {
let sent = SentCommandsBox()
let store = TestStore(initialState: state([goal])) {
ProjectFeature()
} withDependencies: {
$0.orchestratorClient.send = { command in await sent.append(command) }
}
store.exhaustivity = .off

await store.send(.promoteNodeRequested(goal.id, to: .timeBased))
#expect(store.state.nodePendingPromotion == goal.id)
#expect(store.state.promotionSource == .goalBased)

await store.send(.binding(.set(\.promotionTask, "check the flake hasn't come back")))
await store.send(.promotionConfirmed)
await store.finish()

#expect(store.state.nodePendingPromotion == nil)
#expect(
await sent.all == [
.graphCommand(
projectPath: Self.project.path,
command: .promoteNode(
goal.id, promotion: .timed(triggerPrompt: "/loop 1h check the flake hasn't come back"),
promotedBy: nil))
])
}

@Test
func changingATimeLoopToGoalSendsThePromotion() async {
let sent = SentCommandsBox()
let store = TestStore(initialState: state([time])) {
ProjectFeature()
} withDependencies: {
$0.orchestratorClient.send = { command in await sent.append(command) }
}
store.exhaustivity = .off

await store.send(.promoteNodeRequested(time.id, to: .goalBased))
await store.send(.binding(.set(\.promotionGoal, "the backlog is empty")))
await store.send(.promotionConfirmed)
await store.finish()

#expect(
await sent.all == [
.graphCommand(
projectPath: Self.project.path,
command: .promoteNode(
time.id, promotion: .goal(GoalSpec(summary: "the backlog is empty")),
promotedBy: nil))
])
}

/// The daemon refuses these; the form must not open on them and let a human fill it in
/// for nothing.
@Test
func theFormStaysShutForAnythingTheDaemonWouldRefuse() async {
let turn = LoopNode(title: "Review", checkDescription: "Sound?")
var stopped = time
stopped.state = .stopped
let store = TestStore(initialState: state([goal, turn, stopped])) {
ProjectFeature()
}
store.exhaustivity = .off

await store.send(.promoteNodeRequested(goal.id, to: .turnBased))
await store.send(.promoteNodeRequested(goal.id, to: .goalBased))
await store.send(.promoteNodeRequested(turn.id, to: .timeBased))
await store.send(.promoteNodeRequested(stopped.id, to: .goalBased))

#expect(store.state.nodePendingPromotion == nil)
}
}

private actor SentCommandsBox {
private(set) var all: [DaemonCommand] = []
func append(_ command: DaemonCommand) { all.append(command) }
}
Loading