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
30 changes: 25 additions & 5 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,16 +161,36 @@ adapter capabilities rather than leaked persistence.
hands the TUI watcher its dir set so the path convention isn't reconstructed
outside the store. Task dependency fields are graph-owned: generic create/set/edit
paths cannot introduce a semantic delta, and text-level lint repair skips a would-be
dependency normalization instead of manufacturing unchecked edges. The future guarded
dependency port will own the repository-wide read/validate/write critical section.
dependency normalization instead of manufacturing unchecked edges.
`TaskGraphMutationStore` is the control-inverted write capability: `FS` takes the
repository guard, loads the canonical strict snapshot, invokes a pure core planner over
taskflow-owned values, asks core to validate the complete plan and every recovery prefix,
applies surgical task dependency writes through lock-free internal helpers, and releases
the guard. The planner callback is exclusive at canonical-planning-root scope: any Store
call through the same or another `FS` during that brief phase fails with `ErrConflict`
instead of escaping the snapshot or self-deadlocking. This intentionally includes an
unrelated concurrent caller; a future long-lived adapter may retry after the mutation.
Multi-file plans are deterministic and resumable. Planner-provided write order is
semantic recovery data (stable-ID sorting is not generally prefix-safe); each replacement
is atomic, every supplied prefix is validated as non-broken before the first write, and an
error result identifies the durable applied prefix. A private byte-version on each scanned
task supports a whole-snapshot CAS before application plus a per-target CAS immediately
before each replacement; planner-facing task projections never expose those tokens.
Dependency writes stamp `updated_at` from the caller-injected clock only when graph-owned
fields actually change. A dry run holds the same exclusive guard for an authoritative
preview but, because it writes nothing, makes no CAS durability claim about later apply.
Concurrency is **version-CAS** (epic 24): every write, just
before committing, re-resolves the file by its **id** and re-hashes it
against the content read at the start of the op (`verifyUnchanged` in `cas.go` — a
strong whole-file SHA-256 computed on read, **never stored**), so a concurrent
in-place edit is `ErrConflict` (exit 14). A repo-wide advisory `flock`
(`writeLock`, unix; a no-op stub elsewhere) serializes the verify→write so that CAS is
in-place edit is `ErrConflict` (exit 14). The repository guard combines a
canonical-root in-process mutex with root-directory `flock` on Unix. The supported
release matrix is macOS and Linux; Windows and other non-Unix source builds reject
repository mutation explicitly until they have a runtime-tested, shared-repository lock
identity. This serializes verify→write for cooperating writers so that CAS is
*atomic* — without it two writers both pass their verify before either renames and the
later silently clobbers the earlier (the verify→rename window, widened by the temp fsync).
later silently clobbers the earlier. Raw editors do not honor the advisory lock; the
immediate content check narrows but cannot eliminate their verify→rename race.
The token is **internal**: scriptable mutations auto-retry it in `core.Service` (bounded +
jittered, so agents don't reimplement the loop), the human `edit` surfaces the conflict
(no retry, and the lock is held only for the write, never the editor session), and creates map
Expand Down
40 changes: 39 additions & 1 deletion internal/core/dependency_graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package core
import (
"fmt"
"path/filepath"
"slices"
"sort"
"strings"
"sync"
Expand Down Expand Up @@ -727,7 +728,44 @@ func (g *TaskGraph) LegacyDiagnostics() []LegacyDependencyDiagnostic {

func (g *TaskGraph) Task(taskID string) (domain.Task, bool) {
task, ok := g.tasks[taskID]
return cloneTask(task), ok
task = cloneTask(task)
task.SourceVersion = "" // persistence token is not planner/query data
return task, ok
}

// SameSourceSnapshot reports whether two graphs came from the same exact task
// files. It exposes no version token: the store can use it as a whole-repository
// CAS after planning, while callbacks receive only Task() projections with the
// tokens cleared. Health and paths catch new unreadable/renamed entities; byte
// hashes catch every in-place edit, including non-graph fields.
func (g *TaskGraph) SameSourceSnapshot(other *TaskGraph) bool {
if other == nil || g.health != other.health || !slices.Equal(g.ids, other.ids) {
return false
}
for _, taskID := range g.ids {
left, right := g.tasks[taskID], other.tasks[taskID]
if left.Path != right.Path || left.SourceVersion == "" || left.SourceVersion != right.SourceVersion {
return false
}
}
return slices.EqualFunc(g.problems, other.problems, sameGraphProblem) &&
slices.EqualFunc(g.legacy, other.legacy, sameLegacyDiagnostic)
}

func sameGraphProblem(left, right GraphProblem) bool {
return left.Code == right.Code && left.TaskID == right.TaskID &&
left.RelatedTaskID == right.RelatedTaskID && left.Field == right.Field &&
left.Path == right.Path && left.Message == right.Message &&
slices.Equal(left.Cycle, right.Cycle)
}

func sameLegacyDiagnostic(left, right LegacyDependencyDiagnostic) bool {
return left.TaskID == right.TaskID && left.TaskSlug == right.TaskSlug &&
left.TaskPath == right.TaskPath && left.Field == right.Field &&
slices.EqualFunc(left.References, right.References, func(a, b LegacyReference) bool {
return a.Value == b.Value && a.Resolution == b.Resolution &&
a.Edge == b.Edge && slices.Equal(a.CandidateIDs, b.CandidateIDs)
})
}

func (g *TaskGraph) TaskIDs() []string { return append([]string(nil), g.ids...) }
Expand Down
119 changes: 119 additions & 0 deletions internal/core/dependency_graph_mutation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package core

import (
"fmt"
"sort"

"github.com/andy-esch/taskflow/internal/domain"
"github.com/andy-esch/taskflow/internal/id"
)

// ValidateTaskGraphMutationSource rejects an authoritative snapshot that cannot
// support a sound write. Degraded legacy snapshots remain eligible for the one
// guarded migration that converges them to the canonical field.
func ValidateTaskGraphMutationSource(graph *TaskGraph) error {
if graph == nil {
return fmt.Errorf("%w: authoritative task graph is required", domain.ErrValidation)
}
if graph.Health() == GraphBroken {
return fmt.Errorf("%w: repository task graph is broken; repair it before mutation: %s",
domain.ErrValidation, graphMutationHealthDetail(graph))
}
return nil
}

// ValidateTaskGraphMutationPlan normalizes task-owned sets and proves that every
// planner-ordered durable prefix, plus the final state, remains a sound graph. It
// is pure so command planners can validate and preview without a filesystem; the
// store owns only locking, materialization, CAS, and atomic replacement.
func ValidateTaskGraphMutationPlan(graph *TaskGraph, plan TaskGraphMutationPlan) (TaskGraphMutationPlan, error) {
if graph == nil {
return TaskGraphMutationPlan{}, fmt.Errorf("%w: authoritative task graph is required", domain.ErrValidation)
}
normalized := TaskGraphMutationPlan{TaskWrites: make([]TaskDependencyWrite, len(plan.TaskWrites))}
copy(normalized.TaskWrites, plan.TaskWrites)
for i := range normalized.TaskWrites {
normalized.TaskWrites[i].DependsOn = append([]string(nil), normalized.TaskWrites[i].DependsOn...)
}

seenTasks := make(map[string]bool, len(normalized.TaskWrites))
taskIDs := graph.TaskIDs()
prospective := make(map[string]domain.Task, len(taskIDs))
for _, taskID := range taskIDs {
task, _ := graph.Task(taskID)
prospective[taskID] = task
}
for i := range normalized.TaskWrites {
write := &normalized.TaskWrites[i]
if !id.Valid(write.TaskID) {
return TaskGraphMutationPlan{}, fmt.Errorf("%w: planned task id %q is not a stable task id", domain.ErrValidation, write.TaskID)
}
if seenTasks[write.TaskID] {
return TaskGraphMutationPlan{}, fmt.Errorf("%w: graph mutation plans task %s more than once", domain.ErrValidation, write.TaskID)
}
seenTasks[write.TaskID] = true
task, exists := prospective[write.TaskID]
if !exists {
return TaskGraphMutationPlan{}, fmt.Errorf("%w: planned task %s does not exist in the authoritative snapshot", domain.ErrValidation, write.TaskID)
}

seenDependencies := make(map[string]bool, len(write.DependsOn))
for _, prerequisite := range write.DependsOn {
switch {
case !id.Valid(prerequisite):
return TaskGraphMutationPlan{}, fmt.Errorf("%w: planned dependency %q for task %s is not a stable task id", domain.ErrValidation, prerequisite, write.TaskID)
case prerequisite == write.TaskID:
return TaskGraphMutationPlan{}, fmt.Errorf("%w: task %s cannot depend on itself", domain.ErrValidation, write.TaskID)
case seenDependencies[prerequisite]:
return TaskGraphMutationPlan{}, fmt.Errorf("%w: task %s repeats planned dependency %s", domain.ErrValidation, write.TaskID, prerequisite)
}
if _, exists := prospective[prerequisite]; !exists {
return TaskGraphMutationPlan{}, fmt.Errorf("%w: planned dependency %s for task %s does not exist", domain.ErrValidation, prerequisite, write.TaskID)
}
seenDependencies[prerequisite] = true
}
sort.Strings(write.DependsOn)
task.DependsOn = append([]string(nil), write.DependsOn...)
if write.ClearLegacy {
task.LegacyBlockedBy = nil
task.LegacyDependencies = nil
task.LegacyBlocks = nil
}
prospective[write.TaskID] = task

prefixGraph := taskGraphFromMap(taskIDs, prospective)
if prefixGraph.Health() == GraphBroken {
return TaskGraphMutationPlan{}, fmt.Errorf("%w: planned write prefix ending at task %s would leave a broken graph: %s",
domain.ErrValidation, write.TaskID, graphMutationHealthDetail(prefixGraph))
}
}

finalGraph := taskGraphFromMap(taskIDs, prospective)
if !finalGraph.MutationReady() {
return TaskGraphMutationPlan{}, fmt.Errorf("%w: planned dependency state is %s; mutation requires a healthy final graph: %s",
domain.ErrValidation, finalGraph.Health(), graphMutationHealthDetail(finalGraph))
}
return normalized, nil
}

func taskGraphFromMap(taskIDs []string, tasksByID map[string]domain.Task) *TaskGraph {
tasks := make([]domain.Task, 0, len(taskIDs))
for _, taskID := range taskIDs {
tasks = append(tasks, tasksByID[taskID])
}
return NewTaskGraph(tasks, nil)
}

func graphMutationHealthDetail(graph *TaskGraph) string {
if problems := graph.Problems(); len(problems) > 0 {
detail := problems[0].Message
if len(problems) > 1 {
detail += fmt.Sprintf(" (%d additional problem(s); run lint for the full sweep)", len(problems)-1)
}
return detail
}
if legacy := graph.LegacyDiagnostics(); len(legacy) > 0 {
return fmt.Sprintf("%d legacy dependency field occurrence(s) remain; run the guarded migration", len(legacy))
}
return "graph health is not mutation-ready"
}
34 changes: 34 additions & 0 deletions internal/core/dependency_graph_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package core

import (
"errors"
"fmt"
"math/rand"
"reflect"
Expand Down Expand Up @@ -477,6 +478,39 @@ func TestTaskGraphReopenInvalidatesCompletedDescendantsWithoutRewriting(t *testi
}
}

func TestTaskGraphSameSourceSnapshotComparesUnreadableIdentity(t *testing.T) {
left := NewTaskGraph(nil, []domain.FileProblem{{Path: "/planning/tasks/aaaaaaaaaaaa-left.md", Message: "bad yaml"}})
right := NewTaskGraph(nil, []domain.FileProblem{{Path: "/planning/tasks/bbbbbbbbbbbb-right.md", Message: "bad yaml"}})
if left.SameSourceSnapshot(right) {
t.Fatal("different unreadable task sets compared as the same source snapshot")
}
}

func TestValidateTaskGraphMutationPlanPreservesSemanticWriteOrder(t *testing.T) {
alpha := graphRecord("alpha", domain.StatusReadyToStart)
beta := graphRecord("beta", domain.StatusReadyToStart, alpha.ID)
graph := NewTaskGraph([]domain.Task{alpha, beta}, nil)
plan := TaskGraphMutationPlan{TaskWrites: []TaskDependencyWrite{
{TaskID: beta.ID},
{TaskID: alpha.ID, DependsOn: []string{beta.ID}},
}}
validated, err := ValidateTaskGraphMutationPlan(graph, plan)
if err != nil {
t.Fatal(err)
}
if validated.TaskWrites[0].TaskID != beta.ID || validated.TaskWrites[1].TaskID != alpha.ID {
t.Fatalf("validator reordered semantic durable prefixes: %+v", validated.TaskWrites)
}

unsafe := TaskGraphMutationPlan{TaskWrites: []TaskDependencyWrite{
{TaskID: alpha.ID, DependsOn: []string{beta.ID}},
{TaskID: beta.ID},
}}
if _, err := ValidateTaskGraphMutationPlan(graph, unsafe); !errors.Is(err, domain.ErrValidation) {
t.Fatalf("unsafe planner order error = %v", err)
}
}

func sortStrings(values []string) {
slices.Sort(values)
}
16 changes: 10 additions & 6 deletions internal/core/service_task.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,16 @@ func (s *Service) ShowTask(slug string) (domain.Task, string, error) {
return s.store.GetTask(slug)
}

// ReadTaskGraph performs one resilient repository scan and projects it into the
// strict, immutable dependency snapshot. Files remain repair-listable through the
// ordinary store API; graph consumers instead inspect Health/Problems and fail
// closed when the snapshot is degraded or broken.
func (s *Service) ReadTaskGraph() (*TaskGraph, error) {
tasks, problems, err := s.store.ListTasks()
// LoadTaskGraph is the one canonical filesystem-agnostic snapshot loader used by
// the guarded mutation boundary. Diagnostic consumers that already own task bodies
// (notably lint) construct the same strict projection with NewTaskGraph rather than
// performing a second repository scan.
type TaskGraphSource interface {
ListTasks() ([]domain.Task, []domain.FileProblem, error)
}

func LoadTaskGraph(source TaskGraphSource) (*TaskGraph, error) {
tasks, problems, err := source.ListTasks()
if err != nil {
return nil, err
}
Expand Down
41 changes: 41 additions & 0 deletions internal/core/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,47 @@ type TaskStore interface {
RenameTask(slug, newTitle string, dryRun bool) (domain.Task, int, error)
}

// TaskDependencyWrite is one semantic task-file change returned by a pure graph
// mutation planner. The store owns YAML surgery and atomic replacement; planners
// name only the task and its complete canonical dependency set. ClearLegacy is
// reserved for the guarded migration from blocked_by/dependencies/blocks.
type TaskDependencyWrite struct {
TaskID string
DependsOn []string
ClearLegacy bool
}

// TaskGraphMutationPlan is the complete deterministic write set produced from one
// immutable repository snapshot. Writes are applied in the planner-provided order:
// ordering is semantic recovery data because every durable prefix must remain sound.
// Each file is replaced atomically; AppliedTaskIDs in the result is the durable prefix
// when a later write fails, which makes a multi-file operation diagnosable and resumable.
type TaskGraphMutationPlan struct {
TaskWrites []TaskDependencyWrite
}

// TaskGraphMutationResult reports what the store planned and which task-file
// replacements actually landed. Dry runs return the normalized plan with no applied
// IDs. A non-nil error may accompany a non-empty AppliedTaskIDs prefix.
type TaskGraphMutationResult struct {
Plan TaskGraphMutationPlan
AppliedTaskIDs []string
DryRun bool
}

// TaskGraphPlanner is deliberately control-inverted: the store calls a pure core
// planner while it owns the repository guard. The callback receives only the
// immutable taskflow graph and returns taskflow-owned semantic values; it must not
// call a Store method or begin another mutation.
type TaskGraphPlanner func(*TaskGraph) (TaskGraphMutationPlan, error)

// TaskGraphMutationStore owns the repository-wide graph read/validate/write
// critical section. It is a sibling capability rather than part of Store so read-
// only/test adapters do not acquire a mutation method they cannot implement.
type TaskGraphMutationStore interface {
MutateTaskGraph(now time.Time, dryRun bool, planner TaskGraphPlanner) (TaskGraphMutationResult, error)
}

// EpicStore is the epic-persistence port.
type EpicStore interface {
ListEpics() ([]domain.Epic, []domain.FileProblem, error)
Expand Down
4 changes: 4 additions & 0 deletions internal/domain/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ package domain
type Task struct {
Slug string `yaml:"-"`
Path string `yaml:"-"`
// SourceVersion is the store-internal hash of the exact bytes that produced this
// record. TaskGraph retains it for whole-snapshot CAS but clears it from Task()
// projections, so planners never receive persistence tokens.
SourceVersion string `yaml:"-"`
// StatusFellBack is set by the store when the frontmatter status is missing or
// unrecognized — under the flat layout (ADR-0003 §4) there is no directory to fall
// back to, so Status keeps its raw value; the task still lists and lint flags it
Expand Down
15 changes: 15 additions & 0 deletions internal/store/auditstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import (
// ListAudits scans every audit bucket. Unreadable audits are skipped and
// reported as FileProblems.
func (s *FS) ListAudits() ([]domain.Audit, []domain.FileProblem, error) {
if err := s.rejectGraphPlannerCall(); err != nil {
return nil, nil, err
}
return scanDir(s.auditsDir, func(path string, content []byte) (domain.Audit, error) {
return parseAudit(content, path)
})
Expand All @@ -25,6 +28,9 @@ func (s *FS) ListAudits() ([]domain.Audit, []domain.FileProblem, error) {
// so Summary reads each audit once for both the tally and the findings rollup
// instead of re-reading every body through GetAuditByPath.
func (s *FS) ListAuditsWithFindings() ([]core.AuditWithFindings, []domain.FileProblem, error) {
if err := s.rejectGraphPlannerCall(); err != nil {
return nil, nil, err
}
return scanDir(s.auditsDir, func(path string, content []byte) (core.AuditWithFindings, error) {
a, findings, err := parseAuditWithFindings(content, path)
return core.AuditWithFindings{Audit: a, Findings: findings}, err
Expand All @@ -33,6 +39,9 @@ func (s *FS) ListAuditsWithFindings() ([]core.AuditWithFindings, []domain.FilePr

// GetAudit returns one audit plus its markdown body.
func (s *FS) GetAudit(slug string) (domain.Audit, string, error) {
if err := s.rejectGraphPlannerCall(); err != nil {
return domain.Audit{}, "", err
}
path, err := s.resolveAudit(slug)
if err != nil {
return domain.Audit{}, "", err
Expand All @@ -54,6 +63,9 @@ func (s *FS) GetAudit(slug string) (domain.Audit, string, error) {
// slug. The finding/lint sweeps use this to read each audit ListAudits already
// found exactly once, which also closes the concurrent-edit window a re-resolve opens.
func (s *FS) GetAuditByPath(path string) (domain.Audit, string, error) {
if err := s.rejectGraphPlannerCall(); err != nil {
return domain.Audit{}, "", err
}
content, err := os.ReadFile(path)
if err != nil {
return domain.Audit{}, "", fmt.Errorf("read audit %s: %w", path, err)
Expand All @@ -70,6 +82,9 @@ func (s *FS) GetAuditByPath(path string) (domain.Audit, string, error) {
// `bucket:` frontmatter in place — under the flat layout (ADR-0003 §4) there is no bucket
// directory to move between. Moving to the bucket it already declares is an idempotent no-op.
func (s *FS) MoveAudit(slug string, to domain.AuditBucket, dryRun bool) (domain.Audit, error) {
if err := s.rejectGraphPlannerCall(); err != nil {
return domain.Audit{}, err
}
if !to.Valid() {
return domain.Audit{}, fmt.Errorf("%q: %w", to, domain.ErrValidation)
}
Expand Down
Loading