iop/apps/agent/internal/taskloop/workflow.go
toki 45d4bd98fd fix(agent): task-loop 검증 경계와 상태 검사를 보강한다
리뷰에서 확인된 Milestone 식별자와 컴파일 바이너리 상태 검증의 빈틈을 보완하고, 활성 agent-task에서는 dispatcher만 실행 경로로 사용하도록 고정한다.
2026-07-31 18:40:40 +09:00

1021 lines
30 KiB
Go

package taskloop
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"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"
)
type MilestoneView struct {
ID string
Selected bool
WorkUnits int
CompletedWorkUnits int
}
// 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 parseMilestoneSlug(slug string) (string, error) {
if slug == "" {
return "", errors.New("taskloop: milestone slug cannot be empty")
}
for i := 0; i < len(slug); i++ {
c := slug[i]
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
continue
}
if c == '-' && i > 0 {
continue
}
return "", fmt.Errorf("taskloop: milestone slug %q contains invalid character %q", slug, string(c))
}
return slug, nil
}
func parseActiveGroupDir(dirName string) (string, error) {
if !strings.HasPrefix(dirName, taskGroupPrefix) {
return "", fmt.Errorf("taskloop: task group %q must have %q prefix", dirName, taskGroupPrefix)
}
rawSlug := strings.TrimPrefix(dirName, taskGroupPrefix)
return parseMilestoneSlug(rawSlug)
}
func parseArchiveGroupDir(dirName string) (string, error) {
if !strings.HasPrefix(dirName, taskGroupPrefix) {
return "", fmt.Errorf("taskloop: task group %q must have %q prefix", dirName, taskGroupPrefix)
}
rest := strings.TrimPrefix(dirName, taskGroupPrefix)
if idx := strings.IndexByte(rest, '_'); idx >= 0 {
slugPart := rest[:idx]
suffixPart := rest[idx+1:]
if strings.IndexByte(suffixPart, '_') >= 0 || suffixPart == "" {
return "", fmt.Errorf("taskloop: archive group directory %q has invalid collision suffix", dirName)
}
val, err := strconv.Atoi(suffixPart)
if err != nil || val <= 0 || strconv.Itoa(val) != suffixPart {
return "", fmt.Errorf("taskloop: archive group directory %q has invalid collision suffix %q", dirName, suffixPart)
}
return parseMilestoneSlug(slugPart)
}
return parseMilestoneSlug(rest)
}
func scanWorkflow(root, milestone string) ([]agenttask.WorkUnit, string, error) {
slug := milestone
if strings.HasPrefix(milestone, taskGroupPrefix) {
slug = strings.TrimPrefix(milestone, taskGroupPrefix)
}
canonicalSlug, err := parseMilestoneSlug(slug)
if err != nil {
return nil, "", fmt.Errorf("taskloop: selected milestone %q: %w", milestone, err)
}
activeRoot := filepath.Join(root, "agent-task", taskGroupPrefix+canonicalSlug)
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, canonicalSlug)
if err != nil {
return nil, "", err
}
if singleActive {
seen[single.ID] = taskGroupPrefix + canonicalSlug
units = append(units, single)
evidence = append(evidence, singleEvidence)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
unit, unitEvidence, include, err := readActiveUnit(root, activeRoot, canonicalSlug, 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+canonicalSlug,
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, canonicalSlug, 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",
canonicalSlug,
)
}
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 discoverMilestoneCandidates(root string) ([]string, error) {
candidates := make(map[string]struct{})
activeRoot := filepath.Join(root, "agent-task")
activeEntries, err := readOptionalTaskGroup(activeRoot)
if err != nil {
return nil, fmt.Errorf("taskloop: read active task groups: %w", err)
}
for _, entry := range activeEntries {
if !entry.IsDir() {
continue
}
name := entry.Name()
if name == "archive" {
continue
}
if strings.HasPrefix(name, taskGroupPrefix) {
slug, err := parseActiveGroupDir(name)
if err != nil {
return nil, err
}
candidates[slug] = struct{}{}
}
}
archiveRoot := filepath.Join(root, "agent-task", "archive")
years, err := os.ReadDir(archiveRoot)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("taskloop: read archive root: %w", err)
}
if err == nil {
for _, year := range years {
if !year.IsDir() {
continue
}
yearPath := filepath.Join(archiveRoot, year.Name())
months, err := os.ReadDir(yearPath)
if err != nil {
return nil, fmt.Errorf("taskloop: read archive year %q: %w", year.Name(), err)
}
for _, month := range months {
if !month.IsDir() {
continue
}
monthPath := filepath.Join(yearPath, month.Name())
groupDirs, err := os.ReadDir(monthPath)
if err != nil {
return nil, fmt.Errorf("taskloop: read archive month %q/%q: %w", year.Name(), month.Name(), err)
}
for _, groupDir := range groupDirs {
if !groupDir.IsDir() {
continue
}
name := groupDir.Name()
if strings.HasPrefix(name, taskGroupPrefix) {
slug, err := parseArchiveGroupDir(name)
if err != nil {
return nil, err
}
candidates[slug] = struct{}{}
}
}
}
}
}
slugs := make([]string, 0, len(candidates))
for slug := range candidates {
slugs = append(slugs, slug)
}
sort.Strings(slugs)
return slugs, nil
}
func scanMilestones(root, selected string) ([]MilestoneView, error) {
slugs, err := discoverMilestoneCandidates(root)
if err != nil {
return nil, err
}
views := make([]MilestoneView, 0, len(slugs))
for _, slug := range slugs {
units, _, err := scanWorkflow(root, slug)
if err != nil {
return nil, err
}
completed := 0
for _, u := range units {
if u.Completed {
completed++
}
}
isSelected := slug == selected || taskGroupPrefix+slug == selected
views = append(views, MilestoneView{
ID: slug,
Selected: isSelected,
WorkUnits: len(units),
CompletedWorkUnits: completed,
})
}
if len(views) == 0 {
return nil, errors.New("taskloop: no workflow-backed milestones")
}
return views, 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) {
return nil, fmt.Errorf("taskloop: task group must be an exact m- prefixed identifier")
}
slugCandidate := strings.TrimPrefix(group, taskGroupPrefix)
slug, err := parseMilestoneSlug(slugCandidate)
if err != nil {
return nil, fmt.Errorf("taskloop: task group must be an exact m- prefixed identifier: %w", err)
}
canonicalRoot, err := canonicalDirectory(root)
if err != nil {
return nil, err
}
units, _, err := scanWorkflow(canonicalRoot, slug)
return units, err
}
func readArchivedUnits(
root, milestone string,
seen map[agenttask.WorkUnitID]string,
) ([]agenttask.WorkUnit, []string, error) {
archiveRoot := filepath.Join(root, "agent-task", "archive")
years, err := os.ReadDir(archiveRoot)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil, nil
}
if err != nil {
return nil, nil, fmt.Errorf("taskloop: read archive root: %w", err)
}
type archiveMatch struct {
completionPath string
workID agenttask.WorkUnitID
aliases []string
taskDirectory string
}
var matches []archiveMatch
for _, year := range years {
if !year.IsDir() {
continue
}
yearPath := filepath.Join(archiveRoot, year.Name())
months, err := os.ReadDir(yearPath)
if err != nil {
return nil, nil, fmt.Errorf("taskloop: read archive year %q: %w", year.Name(), err)
}
for _, month := range months {
if !month.IsDir() {
continue
}
monthPath := filepath.Join(yearPath, month.Name())
groupDirs, err := os.ReadDir(monthPath)
if err != nil {
return nil, nil, fmt.Errorf("taskloop: read archive month %q/%q: %w", year.Name(), month.Name(), err)
}
for _, groupDir := range groupDirs {
if !groupDir.IsDir() {
continue
}
groupName := groupDir.Name()
if !strings.HasPrefix(groupName, taskGroupPrefix) {
continue
}
slug, err := parseArchiveGroupDir(groupName)
if err != nil {
return nil, nil, fmt.Errorf("taskloop: archived task group %q: %w", groupName, err)
}
if slug != milestone {
continue
}
groupPath := filepath.Join(monthPath, groupName)
directCompletion := filepath.Join(groupPath, completeFileName)
if info, statErr := os.Stat(directCompletion); statErr == nil && info.Mode().IsRegular() {
matches = append(matches, archiveMatch{
completionPath: directCompletion,
workID: singlePlanWorkUnitID(milestone),
aliases: []string{taskGroupPrefix + milestone, milestone},
taskDirectory: taskGroupPrefix + milestone,
})
} else if statErr != nil && !errors.Is(statErr, fs.ErrNotExist) {
return nil, nil, fmt.Errorf("taskloop: inspect archive completion %q: %w", directCompletion, statErr)
}
subEntries, err := os.ReadDir(groupPath)
if err != nil {
return nil, nil, fmt.Errorf("taskloop: read archive task group %q: %w", groupName, err)
}
for _, sub := range subEntries {
if !sub.IsDir() {
continue
}
taskDirName := sub.Name()
subCompletion := filepath.Join(groupPath, taskDirName, completeFileName)
info, statErr := os.Stat(subCompletion)
if statErr == nil && info.Mode().IsRegular() {
id, _, aliases, parseErr := parseTaskDirectory(taskDirName)
if parseErr != nil {
return nil, nil, fmt.Errorf("taskloop: archived task %q: %w", taskDirName, parseErr)
}
matches = append(matches, archiveMatch{
completionPath: subCompletion,
workID: agenttask.WorkUnitID(id),
aliases: aliases,
taskDirectory: taskDirName,
})
} else if statErr != nil && !errors.Is(statErr, fs.ErrNotExist) {
return nil, nil, fmt.Errorf("taskloop: inspect archive completion %q: %w", subCompletion, statErr)
}
}
}
}
}
sort.Slice(matches, func(i, j int) bool {
return matches[i].completionPath < matches[j].completionPath
})
var units []agenttask.WorkUnit
var evidence []string
for _, m := range matches {
unit, unitEvidence, include, err := readArchivedUnit(
root,
milestone,
m.completionPath,
m.workID,
m.aliases,
m.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 <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
}