iop/apps/agent/internal/taskloop/workflow.go

665 lines
20 KiB
Go

package taskloop
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
"iop/packages/go/agentconfig"
"iop/packages/go/agentguard"
"iop/packages/go/agenttask"
)
const (
planPrefix = "PLAN-"
reviewPrefix = "CODE_REVIEW-"
taskGroupPrefix = "m-"
completeFileName = "complete.log"
)
// SelectionStore keeps the device-local explicit Milestone selection separate
// from the repository-owned runtime defaults.
type SelectionStore interface {
SelectedMilestone(context.Context, string) (string, error)
}
// Workflow normalizes registered project artifacts for the shared manager.
// It never mutates a project checkout.
type Workflow struct {
snapshot agentconfig.RuntimeSnapshot
selections SelectionStore
}
var _ agenttask.WorkflowAdapter = (*Workflow)(nil)
func NewWorkflow(snapshot agentconfig.RuntimeSnapshot, selections SelectionStore) (*Workflow, error) {
if snapshot.Revision() == "" {
return nil, errors.New("taskloop: runtime snapshot is required")
}
return &Workflow{snapshot: snapshot, selections: selections}, nil
}
func (workflow *Workflow) RegisteredProjects(ctx context.Context) ([]agenttask.ProjectID, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
config := workflow.snapshot.Config()
projects := make([]agenttask.ProjectID, 0, len(config.Projects))
for id, registration := range config.Projects {
if registration.Enabled {
projects = append(projects, agenttask.ProjectID(id))
}
}
sort.Slice(projects, func(left, right int) bool { return projects[left] < projects[right] })
return projects, nil
}
func (workflow *Workflow) Snapshot(
ctx context.Context,
projectID agenttask.ProjectID,
) (agenttask.ProjectWorkflowSnapshot, error) {
if err := ctx.Err(); err != nil {
return agenttask.ProjectWorkflowSnapshot{}, err
}
registration, ok := workflow.snapshot.Project(string(projectID))
if !ok || !registration.Enabled {
return agenttask.ProjectWorkflowSnapshot{}, fmt.Errorf(
"taskloop: project %q is not registered and enabled",
projectID,
)
}
milestone := registration.SelectedMilestone
if workflow.selections != nil {
selected, err := workflow.selections.SelectedMilestone(ctx, string(projectID))
if err != nil {
return agenttask.ProjectWorkflowSnapshot{}, err
}
if selected != "" {
milestone = selected
}
}
if strings.TrimSpace(milestone) == "" {
return agenttask.ProjectWorkflowSnapshot{}, fmt.Errorf(
"taskloop: project %q has no selected milestone",
projectID,
)
}
root, err := canonicalDirectory(registration.Workspace)
if err != nil {
return agenttask.ProjectWorkflowSnapshot{}, fmt.Errorf(
"taskloop: project %q workspace: %w",
projectID,
err,
)
}
units, evidence, err := scanWorkflow(root, milestone)
if err != nil {
return agenttask.ProjectWorkflowSnapshot{}, err
}
return agenttask.ProjectWorkflowSnapshot{
ProjectID: projectID,
WorkspaceID: WorkspaceIdentity(root),
Revision: agenttask.WorkflowRevision(
digestStrings("workflow", string(projectID), milestone, evidence),
),
Units: units,
ObservedAt: time.Now().UTC(),
}, nil
}
// WorkspaceIdentity is a stable, path-redacted identity for one canonical
// registered project root.
func WorkspaceIdentity(root string) agenttask.WorkspaceID {
sum := sha256.Sum256([]byte(filepath.Clean(root)))
return agenttask.WorkspaceID("workspace-" + hex.EncodeToString(sum[:]))
}
func scanWorkflow(root, milestone string) ([]agenttask.WorkUnit, string, error) {
activeRoot := filepath.Join(root, "agent-task", taskGroupPrefix+milestone)
entries, err := os.ReadDir(activeRoot)
if err != nil {
return nil, "", fmt.Errorf("taskloop: read active task group: %w", err)
}
units := make([]agenttask.WorkUnit, 0, len(entries))
evidence := make([]string, 0, len(entries))
seen := make(map[agenttask.WorkUnitID]string)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
unit, unitEvidence, include, err := readActiveUnit(root, activeRoot, milestone, entry.Name())
if err != nil {
return nil, "", err
}
if !include {
continue
}
if prior, duplicate := seen[unit.ID]; duplicate {
return nil, "", fmt.Errorf(
"taskloop: work identity %q is ambiguous between %q and %q",
unit.ID,
prior,
entry.Name(),
)
}
seen[unit.ID] = entry.Name()
units = append(units, unit)
evidence = append(evidence, unitEvidence)
}
archived, archivedEvidence, err := readArchivedUnits(root, milestone, seen)
if err != nil {
return nil, "", err
}
units = append(units, archived...)
evidence = append(evidence, archivedEvidence...)
if len(units) == 0 {
return nil, "", fmt.Errorf(
"taskloop: selected milestone %q has no active PLAN artifacts",
milestone,
)
}
sort.Slice(units, func(left, right int) bool { return units[left].ID < units[right].ID })
sort.Strings(evidence)
return units, strings.Join(evidence, "\n"), nil
}
// InspectTaskGroup provides a configuration-free, read-only projection for an
// operator dry-run. It deliberately uses the same workflow parser as Runtime
// and never constructs a manager, provider, lease, or durable state store.
func InspectTaskGroup(root, group string) ([]agenttask.WorkUnit, error) {
if !strings.HasPrefix(group, taskGroupPrefix) || len(group) == len(taskGroupPrefix) || strings.ContainsAny(group, "/\\\x00\r\n") {
return nil, fmt.Errorf("taskloop: task group must be an exact m- prefixed identifier")
}
canonicalRoot, err := canonicalDirectory(root)
if err != nil {
return nil, err
}
units, _, err := scanWorkflow(canonicalRoot, strings.TrimPrefix(group, taskGroupPrefix))
return units, err
}
func readArchivedUnits(
root, milestone string,
seen map[agenttask.WorkUnitID]string,
) ([]agenttask.WorkUnit, []string, error) {
pattern := filepath.Join(
root,
"agent-task",
"archive",
"*",
"*",
taskGroupPrefix+milestone,
"*",
completeFileName,
)
completions, err := filepath.Glob(pattern)
if err != nil {
return nil, nil, err
}
sort.Strings(completions)
var units []agenttask.WorkUnit
var evidence []string
for _, completion := range completions {
taskDirectory := filepath.Base(filepath.Dir(completion))
id, _, aliases, err := parseTaskDirectory(taskDirectory)
if err != nil {
return nil, nil, fmt.Errorf("taskloop: archived task %q: %w", taskDirectory, err)
}
workID := agenttask.WorkUnitID(id)
if _, active := seen[workID]; active {
continue
}
content, err := os.ReadFile(completion)
if err != nil {
return nil, nil, err
}
seen[workID] = taskDirectory
units = append(units, agenttask.WorkUnit{
ID: workID,
MilestoneID: agenttask.MilestoneID(milestone),
Aliases: aliases,
WriteSetKind: agenttask.WriteSetUnknown,
IsolationMode: agentguard.IsolationModeOverlay,
Completed: true,
Metadata: map[string]string{"task_directory": taskDirectory},
})
relative, _ := filepath.Rel(root, completion)
evidence = append(evidence, digestBytes(filepath.ToSlash(relative), content))
}
return units, evidence, nil
}
func readActiveUnit(
root, activeRoot, milestone, directory string,
) (agenttask.WorkUnit, string, bool, error) {
taskRoot := filepath.Join(activeRoot, directory)
plans, err := matchingMarkdown(taskRoot, planPrefix)
if err != nil {
return agenttask.WorkUnit{}, "", false, err
}
if len(plans) == 0 {
return agenttask.WorkUnit{}, "", false, nil
}
if len(plans) != 1 {
return agenttask.WorkUnit{}, "", false, fmt.Errorf(
"taskloop: task %q must contain exactly one active PLAN, found %d",
directory,
len(plans),
)
}
reviews, err := matchingMarkdown(taskRoot, reviewPrefix)
if err != nil {
return agenttask.WorkUnit{}, "", false, err
}
if len(reviews) != 1 {
return agenttask.WorkUnit{}, "", false, fmt.Errorf(
"taskloop: task %q must contain exactly one active CODE_REVIEW, found %d",
directory,
len(reviews),
)
}
planBytes, err := os.ReadFile(plans[0])
if err != nil {
return agenttask.WorkUnit{}, "", false, err
}
writeSet, err := parseModifiedFilesSummary(root, planBytes)
if err != nil {
return agenttask.WorkUnit{}, "", false, fmt.Errorf(
"taskloop: task %q PLAN: %w",
directory,
err,
)
}
id, predecessors, aliases, err := parseTaskDirectory(directory)
if err != nil {
return agenttask.WorkUnit{}, "", false, err
}
planRelative, err := filepath.Rel(root, plans[0])
if err != nil {
return agenttask.WorkUnit{}, "", false, err
}
reviewRelative, err := filepath.Rel(root, reviews[0])
if err != nil {
return agenttask.WorkUnit{}, "", false, err
}
completed := false
if info, statErr := os.Stat(filepath.Join(taskRoot, completeFileName)); statErr == nil {
completed = info.Mode().IsRegular()
} else if !errors.Is(statErr, fs.ErrNotExist) {
return agenttask.WorkUnit{}, "", false, statErr
}
dependencies := make([]agenttask.DependencyRef, len(predecessors))
for index, predecessor := range predecessors {
dependencies[index] = agenttask.DependencyRef{Ref: predecessor}
}
kind := agenttask.WriteSetUnknown
if len(writeSet) > 0 {
kind = agenttask.WriteSetDisjoint
}
unit := agenttask.WorkUnit{
ID: agenttask.WorkUnitID(id),
MilestoneID: agenttask.MilestoneID(milestone),
Aliases: aliases,
ExplicitPredecessors: dependencies,
WriteSetKind: kind,
DeclaredWriteSet: writeSet,
IsolationMode: agentguard.IsolationModeOverlay,
Completed: completed,
Metadata: map[string]string{
"task_directory": directory,
"plan_path": filepath.ToSlash(planRelative),
"review_path": filepath.ToSlash(reviewRelative),
},
}
return unit, digestBytes(filepath.ToSlash(planRelative), planBytes), true, nil
}
func matchingMarkdown(root, prefix string) ([]string, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
var matches []string
for _, entry := range entries {
if entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) ||
!strings.HasSuffix(entry.Name(), ".md") {
continue
}
matches = append(matches, filepath.Join(root, entry.Name()))
}
sort.Strings(matches)
return matches, nil
}
func parseTaskDirectory(directory string) (string, []string, []string, error) {
head, slug, found := strings.Cut(directory, "_")
if !found || head == "" || slug == "" {
return "", nil, nil, fmt.Errorf(
"taskloop: task directory %q does not have <id>_<slug> form",
directory,
)
}
id := head
var predecessors []string
if primary, dependencyText, hasDependencies := strings.Cut(head, "+"); hasDependencies {
id = primary
if id == "" || dependencyText == "" {
return "", nil, nil, fmt.Errorf(
"taskloop: task directory %q has malformed explicit predecessors",
directory,
)
}
for _, predecessor := range strings.Split(dependencyText, ",") {
if predecessor == "" {
return "", nil, nil, fmt.Errorf(
"taskloop: task directory %q has an empty predecessor",
directory,
)
}
predecessors = append(predecessors, predecessor)
}
}
if strings.TrimSpace(id) != id || strings.ContainsAny(id, "/\\\x00\r\n") {
return "", nil, nil, fmt.Errorf("taskloop: task directory %q has invalid identity", directory)
}
aliases := []string{directory, head, slug}
if id != head {
aliases = append(aliases, id)
}
return id, predecessors, uniqueStrings(aliases), nil
}
// ValidatePlanWriteSet validates a candidate PLAN without constructing a
// runtime, provider, lease, or durable store. The candidate may be outside
// workspace, but every declared write target must be a contained literal file.
func ValidatePlanWriteSet(workspace, planPath string) ([]string, error) {
root, err := canonicalDirectory(workspace)
if err != nil {
return nil, fmt.Errorf("taskloop: workspace: %w", err)
}
if strings.TrimSpace(planPath) == "" {
return nil, errors.New("taskloop: candidate PLAN path is required")
}
plan, err := os.ReadFile(planPath)
if err != nil {
return nil, fmt.Errorf("taskloop: read candidate PLAN: %w", err)
}
return parseModifiedFilesSummary(root, plan)
}
func parseModifiedFilesSummary(workspace string, plan []byte) ([]string, error) {
lines := strings.Split(string(plan), "\n")
heading := -1
for index, line := range lines {
if isModifiedFilesSummaryHeading(line) {
if heading >= 0 {
return nil, errors.New("Modified Files Summary appears more than once")
}
heading = index
}
}
if heading < 0 {
return nil, errors.New("Modified Files Summary is missing")
}
var files []string
seen := make(map[string]struct{})
for _, line := range lines[heading+1:] {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "## ") {
break
}
if !strings.HasPrefix(trimmed, "|") {
continue
}
columns := strings.Split(strings.Trim(trimmed, "|"), "|")
if len(columns) == 0 {
continue
}
cell := strings.TrimSpace(columns[0])
if isModifiedFilesHeader(cell) || strings.Trim(cell, "-: ") == "" {
continue
}
claims, err := backtickClaims(cell)
if err != nil {
return nil, err
}
for _, claim := range claims {
path, err := canonicalWriteClaim(workspace, claim)
if err != nil {
return nil, err
}
if _, duplicate := seen[path]; duplicate {
return nil, fmt.Errorf("file path %q is duplicated", path)
}
seen[path] = struct{}{}
files = append(files, path)
}
}
if len(files) == 0 {
return nil, errors.New("Modified Files Summary has no file rows")
}
sort.Strings(files)
return files, nil
}
func isModifiedFilesSummaryHeading(line string) bool {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "##") || strings.HasPrefix(trimmed, "###") {
return false
}
return strings.TrimSpace(strings.TrimPrefix(trimmed, "##")) == "Modified Files Summary" ||
strings.TrimSpace(strings.TrimPrefix(trimmed, "##")) == "수정 파일 요약"
}
func isModifiedFilesHeader(cell string) bool {
switch strings.ToLower(strings.TrimSpace(cell)) {
case "file", "files", "path", "paths", "파일", "경로":
return true
default:
return false
}
}
func backtickClaims(cell string) ([]string, error) {
if strings.Contains(cell, "``") {
return nil, errors.New("empty file path claim is not allowed")
}
var claims []string
for rest := cell; ; {
start := strings.IndexByte(rest, '`')
if start < 0 {
break
}
rest = rest[start+1:]
end := strings.IndexByte(rest, '`')
if end < 0 {
return nil, fmt.Errorf("file cell %q has an unmatched backtick", cell)
}
claim := strings.TrimSpace(rest[:end])
if claim == "" {
return nil, errors.New("empty file path claim is not allowed")
}
claims = append(claims, claim)
rest = rest[end+1:]
}
if len(claims) == 0 {
return nil, fmt.Errorf("file cell %q must contain a backtick path", cell)
}
return claims, nil
}
func canonicalWriteClaim(workspace, value string) (string, error) {
claim := strings.TrimSpace(value)
claim = trimOptionalLineSuffix(claim)
if claim == "" {
return "", errors.New("empty file path claim is not allowed")
}
lower := strings.ToLower(claim)
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
return "", fmt.Errorf("URL claim %q is not allowed", claim)
}
if strings.Contains(claim, "\\") || strings.ContainsAny(claim, "*?[]{}<>") || strings.Contains(claim, "...") ||
containsPlaceholderPathToken(lower) {
return "", fmt.Errorf("file path %q is not a contained literal file", claim)
}
if strings.HasSuffix(claim, "/") {
return "", fmt.Errorf("directory claim %q is not allowed", claim)
}
candidate := filepath.FromSlash(claim)
if !filepath.IsAbs(candidate) {
candidate = filepath.Join(workspace, candidate)
}
candidate = filepath.Clean(candidate)
resolved, err := canonicalizeCandidate(candidate)
if err != nil {
return "", fmt.Errorf("file path %q cannot be canonicalized: %w", claim, err)
}
relative, err := filepath.Rel(workspace, resolved)
if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
if relative == "." {
return "", errors.New("workspace root claim is not allowed")
}
return "", fmt.Errorf("file path %q is outside the workspace", claim)
}
if info, err := os.Stat(resolved); err == nil && info.IsDir() {
return "", fmt.Errorf("directory claim %q is not allowed", claim)
} else if err != nil && !errors.Is(err, fs.ErrNotExist) {
return "", fmt.Errorf("file path %q cannot be inspected: %w", claim, err)
}
return filepath.ToSlash(relative), nil
}
func trimOptionalLineSuffix(value string) string {
last := strings.LastIndexByte(value, ':')
if last < 0 || last == len(value)-1 || !allDigits(value[last+1:]) {
return value
}
value = value[:last]
last = strings.LastIndexByte(value, ':')
if last >= 0 && last < len(value)-1 && allDigits(value[last+1:]) {
return value[:last]
}
return value
}
func allDigits(value string) bool {
return value != "" && strings.Trim(value, "0123456789") == ""
}
func containsPlaceholderPathToken(value string) bool {
for _, token := range strings.FieldsFunc(value, func(r rune) bool {
return r == '/' || r == '_' || r == '.' || r == '-'
}) {
if token == "tbd" || token == "todo" || token == "placeholder" {
return true
}
}
return false
}
func canonicalizeCandidate(candidate string) (string, error) {
return canonicalizeCandidateSeen(filepath.Clean(candidate), make(map[string]struct{}), 0)
}
const maxCandidateSymlinkResolutions = 255
// canonicalizeCandidateSeen resolves existing path components one at a time so
// a dangling leaf symlink cannot be mistaken for a contained new file. A
// genuinely new suffix is retained after its nearest physical parent is
// resolved, which preserves normal PLAN entries for files that do not exist
// yet.
func canonicalizeCandidateSeen(candidate string, seen map[string]struct{}, depth int) (string, error) {
if depth > maxCandidateSymlinkResolutions {
return "", errors.New("too many symlink resolutions")
}
if resolved, err := filepath.EvalSymlinks(candidate); err == nil {
return filepath.Clean(resolved), nil
} else if !errors.Is(err, fs.ErrNotExist) {
return "", err
}
info, err := os.Lstat(candidate)
if err == nil && info.Mode()&os.ModeSymlink != 0 {
if _, duplicate := seen[candidate]; duplicate {
return "", errors.New("symlink loop")
}
seen[candidate] = struct{}{}
target, err := os.Readlink(candidate)
if err != nil {
return "", err
}
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(candidate), target)
}
return canonicalizeCandidateSeen(filepath.Clean(target), seen, depth+1)
}
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return "", err
}
parent, leaf := filepath.Dir(candidate), filepath.Base(candidate)
// Walking ordinary missing path components does not resolve a symlink. Keep
// the resolution budget for actual links so a valid, deeply nested new file
// is not rejected merely because its parent directories do not exist yet.
resolvedParent, err := canonicalizeCandidateSeen(parent, seen, depth)
if err != nil {
return "", err
}
return filepath.Join(resolvedParent, leaf), nil
}
func canonicalDirectory(path string) (string, error) {
if strings.TrimSpace(path) == "" || !filepath.IsAbs(path) ||
filepath.Clean(path) != path {
return "", errors.New("path must be absolute and clean")
}
canonical, err := filepath.EvalSymlinks(path)
if err != nil {
return "", err
}
info, err := os.Stat(canonical)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", errors.New("path is not a directory")
}
return filepath.Clean(canonical), nil
}
func digestBytes(label string, data []byte) string {
sum := sha256.Sum256(append(append([]byte(label), 0), data...))
return "sha256:" + hex.EncodeToString(sum[:])
}
func digestStrings(parts ...string) string {
hash := sha256.New()
for _, part := range parts {
_, _ = fmt.Fprintf(hash, "%d:", len(part))
_, _ = hash.Write([]byte(part))
}
return "sha256:" + hex.EncodeToString(hash.Sum(nil))
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}