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 := readOptionalTaskGroup(activeRoot) if err != nil { return nil, "", fmt.Errorf("taskloop: read active task group: %w", err) } units := make([]agenttask.WorkUnit, 0, len(entries)+1) evidence := make([]string, 0, len(entries)+1) seen := make(map[agenttask.WorkUnitID]string) single, singleEvidence, singleActive, err := readSinglePlanUnit(root, activeRoot, milestone) if err != nil { return nil, "", err } if singleActive { seen[single.ID] = taskGroupPrefix + milestone units = append(units, single) evidence = append(evidence, singleEvidence) } 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 singleActive { return nil, "", fmt.Errorf( "taskloop: task group %q mixes a single-plan root pair with split-plan task directory %q", taskGroupPrefix+milestone, entry.Name(), ) } 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 or archived task 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 } func readOptionalTaskGroup(root string) ([]os.DirEntry, error) { entries, err := os.ReadDir(root) if errors.Is(err, fs.ErrNotExist) { return nil, nil } return entries, err } // 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) { directPattern := filepath.Join( root, "agent-task", "archive", "*", "*", taskGroupPrefix+milestone, completeFileName, ) directCompletions, err := filepath.Glob(directPattern) if err != nil { return nil, nil, err } pattern := filepath.Join( root, "agent-task", "archive", "*", "*", taskGroupPrefix+milestone, "*", completeFileName, ) completions, err := filepath.Glob(pattern) if err != nil { return nil, nil, err } sort.Strings(directCompletions) sort.Strings(completions) var units []agenttask.WorkUnit var evidence []string directID := singlePlanWorkUnitID(milestone) for _, completion := range directCompletions { unit, unitEvidence, include, err := readArchivedUnit( root, milestone, completion, directID, []string{taskGroupPrefix + milestone, milestone}, taskGroupPrefix+milestone, seen, ) if err != nil { return nil, nil, err } if include { units = append(units, unit) evidence = append(evidence, unitEvidence) } } 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) } unit, unitEvidence, include, err := readArchivedUnit( root, milestone, completion, agenttask.WorkUnitID(id), aliases, taskDirectory, seen, ) if err != nil { return nil, nil, err } if include { units = append(units, unit) evidence = append(evidence, unitEvidence) } } return units, evidence, nil } func readArchivedUnit( root, milestone, completion string, workID agenttask.WorkUnitID, aliases []string, taskDirectory string, seen map[agenttask.WorkUnitID]string, ) (agenttask.WorkUnit, string, bool, error) { if _, exists := seen[workID]; exists { return agenttask.WorkUnit{}, "", false, nil } content, err := os.ReadFile(completion) if err != nil { return agenttask.WorkUnit{}, "", false, err } seen[workID] = taskDirectory relative, err := filepath.Rel(root, completion) if err != nil { return agenttask.WorkUnit{}, "", false, err } return agenttask.WorkUnit{ ID: workID, MilestoneID: agenttask.MilestoneID(milestone), Aliases: uniqueStrings(aliases), WriteSetKind: agenttask.WriteSetUnknown, IsolationMode: agentguard.IsolationModeOverlay, Completed: true, Metadata: map[string]string{"task_directory": taskDirectory}, }, digestBytes(filepath.ToSlash(relative), content), true, nil } func readSinglePlanUnit( root, activeRoot, milestone string, ) (agenttask.WorkUnit, string, bool, error) { taskGroup := taskGroupPrefix + milestone plan, review, include, err := matchingActivePair(activeRoot, taskGroup) if err != nil || !include { return agenttask.WorkUnit{}, "", include, err } return normalizeActiveUnit( root, activeRoot, milestone, taskGroup, plan, review, string(singlePlanWorkUnitID(milestone)), nil, []string{taskGroup, milestone}, ) } func readActiveUnit( root, activeRoot, milestone, directory string, ) (agenttask.WorkUnit, string, bool, error) { taskRoot := filepath.Join(activeRoot, directory) plan, review, include, err := matchingActivePair(taskRoot, directory) if err != nil || !include { return agenttask.WorkUnit{}, "", include, err } id, predecessors, aliases, err := parseTaskDirectory(directory) if err != nil { return agenttask.WorkUnit{}, "", false, err } return normalizeActiveUnit( root, taskRoot, milestone, directory, plan, review, id, predecessors, aliases, ) } func matchingActivePair(root, task string) (string, string, bool, error) { plans, err := matchingMarkdown(root, planPrefix) if errors.Is(err, fs.ErrNotExist) { return "", "", false, nil } if err != nil { return "", "", false, err } reviews, err := matchingMarkdown(root, reviewPrefix) if err != nil { return "", "", false, err } if len(plans) == 0 && len(reviews) == 0 { return "", "", false, nil } if len(plans) != 1 { return "", "", false, fmt.Errorf( "taskloop: task %q must contain exactly one active PLAN, found %d", task, len(plans), ) } if len(reviews) != 1 { return "", "", false, fmt.Errorf( "taskloop: task %q must contain exactly one active CODE_REVIEW, found %d", task, len(reviews), ) } return plans[0], reviews[0], true, nil } func normalizeActiveUnit( root, taskRoot, milestone, taskDirectory, plan, review, id string, predecessors, aliases []string, ) (agenttask.WorkUnit, string, bool, error) { planBytes, err := os.ReadFile(plan) 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", taskDirectory, err, ) } planRelative, err := filepath.Rel(root, plan) if err != nil { return agenttask.WorkUnit{}, "", false, err } reviewRelative, err := filepath.Rel(root, review) 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": taskDirectory, "plan_path": filepath.ToSlash(planRelative), "review_path": filepath.ToSlash(reviewRelative), }, } return unit, digestBytes(filepath.ToSlash(planRelative), planBytes), true, nil } func singlePlanWorkUnitID(milestone string) agenttask.WorkUnitID { return agenttask.WorkUnitID(taskGroupPrefix + milestone) } 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 _ 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 }