diff --git a/docs/implementation-log.md b/docs/implementation-log.md index f4a4220..913421b 100644 --- a/docs/implementation-log.md +++ b/docs/implementation-log.md @@ -253,9 +253,12 @@ _Parent: `feat/figma-phase-d-cli`_ **Tasks:** T2F.10 -**Summary:** _(to be filled by agent)_ +**Summary:** Implemented drift detection and mapping cache reuse for Figma-backed runs. `RunMetadata` and `RunIndexEntry` gained a `FigmaVersion` field, and three new methods were added to `artifacts.Manager`: `LoadPreviousMeta` (scans `runs.jsonl` forward and selects the last matching entry for the most recent successful run with a matching DocID and Figma file key), `LoadMappings` (reads `extraction/mappings.json` from a prior run), and `UpdateRunFigmaVersion` (patches the current run's `metadata.json` after a fresh Figma fetch). In `generateChunksWithFigma`, a `GetMeta` call is now made before any other Figma API calls; if the version is unchanged versus the previous run, the stored mappings are reused and `GetNodes`/screenshot downloads are skipped; if changed, a warning is logged and a full re-fetch proceeds. `Resolver.Build` was hardened with a post-process normalization step that explicitly marks any chunk with `Confidence < 0.5`, `Method == "fallback"`, or `Method == "none"` as `Status: "unresolved"`, preventing silent promotion of low-quality mappings. -**Files changed:** _(to be filled by agent)_ +**Files changed:** +- `internal/artifacts/manager.go`: Added `FigmaVersion` field to `RunMetadata` and `RunIndexEntry`; added `LoadPreviousMeta`, `LoadMappings`, and `UpdateRunFigmaVersion` methods; added `bufio` import for JSONL scanning. +- `internal/orchestrator/orchestrator.go`: Rewrote `generateChunksWithFigma` to call `GetMeta` first for drift detection, consult `LoadPreviousMeta`/`LoadMappings` for cache reuse, log version changes as warnings, and call `UpdateRunFigmaVersion` after each fresh Figma fetch. +- `internal/source/mapping/resolver.go`: Added post-process normalization loop in `Build` that sets `Status: "unresolved"` for any mapping with `Confidence < 0.5`, `Method == "fallback"`, or `Method == "none"`. --- diff --git a/internal/artifacts/manager.go b/internal/artifacts/manager.go index 17070eb..2b0d3e0 100644 --- a/internal/artifacts/manager.go +++ b/internal/artifacts/manager.go @@ -1,6 +1,7 @@ package artifacts import ( + "bufio" "crypto/rand" "encoding/hex" "encoding/json" @@ -15,28 +16,30 @@ import ( // RunMetadata is written to metadata.json inside each run directory. type RunMetadata struct { - RunID string `json:"run_id"` - StartedAt string `json:"started_at"` - CompletedAt string `json:"completed_at,omitempty"` - Status string `json:"status"` // "in_progress", "success", "failed" - DocID string `json:"doc_id"` - FigmaURL string `json:"figma_url,omitempty"` - Mode string `json:"mode"` // "execute", "dry-run", "issue" - ChunkCount int `json:"chunk_count"` - ArtifactDir string `json:"artifact_dir"` + RunID string `json:"run_id"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at,omitempty"` + Status string `json:"status"` // "in_progress", "success", "failed" + DocID string `json:"doc_id"` + FigmaURL string `json:"figma_url,omitempty"` + FigmaVersion string `json:"figma_version,omitempty"` + Mode string `json:"mode"` // "execute", "dry-run", "issue" + ChunkCount int `json:"chunk_count"` + ArtifactDir string `json:"artifact_dir"` } // RunIndexEntry is one line in runs.jsonl. type RunIndexEntry struct { - RunID string `json:"run_id"` - StartedAt string `json:"started_at"` - CompletedAt string `json:"completed_at,omitempty"` - Status string `json:"status"` - DocID string `json:"doc_id"` - FigmaURL string `json:"figma_url,omitempty"` - Mode string `json:"mode"` - ChunkCount int `json:"chunk_count"` - ArtifactDir string `json:"artifact_dir"` + RunID string `json:"run_id"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at,omitempty"` + Status string `json:"status"` + DocID string `json:"doc_id"` + FigmaURL string `json:"figma_url,omitempty"` + FigmaVersion string `json:"figma_version,omitempty"` + Mode string `json:"mode"` + ChunkCount int `json:"chunk_count"` + ArtifactDir string `json:"artifact_dir"` } // Manager handles append-only artifact storage for Bauer runs. @@ -117,15 +120,16 @@ func (m *Manager) CompleteRun(runID string, status string, chunkCount int) error // Append to runs.jsonl entry := RunIndexEntry{ - RunID: meta.RunID, - StartedAt: meta.StartedAt, - CompletedAt: meta.CompletedAt, - Status: status, - DocID: meta.DocID, - FigmaURL: meta.FigmaURL, - Mode: meta.Mode, - ChunkCount: chunkCount, - ArtifactDir: meta.ArtifactDir, + RunID: meta.RunID, + StartedAt: meta.StartedAt, + CompletedAt: meta.CompletedAt, + Status: status, + DocID: meta.DocID, + FigmaURL: meta.FigmaURL, + FigmaVersion: meta.FigmaVersion, + Mode: meta.Mode, + ChunkCount: chunkCount, + ArtifactDir: meta.ArtifactDir, } line, err := json.Marshal(entry) if err != nil { @@ -200,6 +204,99 @@ func (m *Manager) Base() string { return m.base } +// LoadPreviousMeta returns the RunMetadata from the most recent completed run +// that matches the given (docID, figmaFileKey) pair, or nil if none exists. +// It scans runs.jsonl forward and returns metadata from the last matching entry. +func (m *Manager) LoadPreviousMeta(docID, figmaFileKey string) *RunMetadata { + indexPath := filepath.Join(m.base, "runs.jsonl") + f, err := os.Open(indexPath) + if err != nil { + return nil + } + defer f.Close() + + // Collect matching entries (in file order; last = most recent). + var matched []RunIndexEntry + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + var entry RunIndexEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + continue + } + if entry.Status != "success" { + continue + } + if entry.DocID != docID { + continue + } + // Match figmaFileKey against the stored FigmaURL. + if entry.FigmaURL == "" { + continue + } + ref, err := figma.ParseLink(entry.FigmaURL) + if err != nil || ref.FileKey != figmaFileKey { + continue + } + matched = append(matched, entry) + } + if len(matched) == 0 { + return nil + } + + // Iterate backwards to find the most recent run with a non-empty FigmaVersion. + for i := len(matched) - 1; i >= 0; i-- { + metaPath := filepath.Join(m.base, matched[i].RunID, "metadata.json") + data, err := os.ReadFile(metaPath) + if err != nil { + continue + } + var meta RunMetadata + if err := json.Unmarshal(data, &meta); err != nil { + continue + } + if meta.FigmaVersion == "" { + continue + } + return &meta + } + return nil +} + +// LoadMappings returns the resolved chunks from a previous run's mappings.json. +// Returns nil if the artifact does not exist or cannot be parsed. +func (m *Manager) LoadMappings(runID string) []mapping.ResolvedChunk { + path := filepath.Join(m.base, runID, "extraction", "mappings.json") + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var chunks []mapping.ResolvedChunk + if err := json.Unmarshal(data, &chunks); err != nil { + return nil + } + return chunks +} + +// UpdateRunFigmaVersion patches the run's metadata.json with the given figma version string. +// This is called after a successful Figma fetch so future runs can use it for drift detection. +func (m *Manager) UpdateRunFigmaVersion(runID, figmaVersion string) error { + metaPath := filepath.Join(m.base, runID, "metadata.json") + data, err := os.ReadFile(metaPath) + if err != nil { + return fmt.Errorf("read metadata for version update: %w", err) + } + var meta RunMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return fmt.Errorf("parse metadata for version update: %w", err) + } + meta.FigmaVersion = figmaVersion + return m.writeJSON(runID, "metadata.json", meta) +} + func (m *Manager) writeJSON(runID, relPath string, data any) error { b, err := json.MarshalIndent(data, "", " ") if err != nil { diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 726f2d6..c7bf364 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -298,6 +298,55 @@ func (o *DefaultOrchestrator) generateChunksWithFigma( figmaClient := figma.NewClient(cfg.FigmaToken) + // Drift detection: check whether the Figma file version has changed since last run. + currentMeta, err := figmaClient.GetMeta(ctx, figmaRef.FileKey) + if err != nil { + return nil, fmt.Errorf("fetching figma metadata for drift check: %w", err) + } + + suggestedURL := "" + if bundle.Document.Metadata != nil { + suggestedURL = bundle.Document.Metadata.SuggestedUrl + } + + prevRunMeta := o.arts.LoadPreviousMeta(cfg.DocID, figmaRef.FileKey) + if prevRunMeta != nil && prevRunMeta.FigmaVersion == currentMeta.Version { + // Version unchanged: reuse stored mappings and skip re-fetch. + slog.Info("Figma version unchanged; reusing stored mappings", + slog.String("version", currentMeta.Version), + slog.String("prev_run_id", prevRunMeta.RunID), + ) + resolvedChunks := o.arts.LoadMappings(prevRunMeta.RunID) + if resolvedChunks != nil { + // Persist reused mappings into the current run so it is self-contained. + if runID != "" { + if err := o.arts.WriteMappings(runID, resolvedChunks); err != nil { + slog.Warn("Failed to persist reused mappings to current run", + slog.String("error", err.Error())) + } + if err := o.arts.UpdateRunFigmaVersion(runID, currentMeta.Version); err != nil { + slog.Warn("Failed to record figma version on cache-hit run", + slog.String("error", err.Error())) + } + } + return engine.RenderChunksFromResolved( + bundle.Document.DocumentTitle, + suggestedURL, + cfg.FigmaURL, + resolvedChunks, + cfg.ChunkSize, + cfg.OutputDir, + ) + } + slog.Warn("Previous mappings could not be loaded; proceeding with full re-fetch", + slog.String("prev_run_id", prevRunMeta.RunID)) + } else if prevRunMeta != nil { + slog.Warn("Figma version changed; re-fetching all design data", + slog.String("prev_version", prevRunMeta.FigmaVersion), + slog.String("current_version", currentMeta.Version), + ) + } + // Determine screenshot directory (inside the artifact run when available). screenshotDir := "" if runID != "" { @@ -319,6 +368,13 @@ func (o *DefaultOrchestrator) generateChunksWithFigma( slog.Int("comments", len(design.Comments)), ) + // Store the figma version so future runs can use it for drift detection. + if runID != "" { + if verr := o.arts.UpdateRunFigmaVersion(runID, design.Version); verr != nil { + slog.Warn("Failed to update figma version in run metadata", slog.String("error", verr.Error())) + } + } + // Persist figma artifacts. if runID != "" { if werr := o.arts.WriteFigmaExtraction(runID, design); werr != nil { @@ -340,12 +396,6 @@ func (o *DefaultOrchestrator) generateChunksWithFigma( } } - // Generate figma-aware prompt files. - suggestedURL := "" - if bundle.Document.Metadata != nil { - suggestedURL = bundle.Document.Metadata.SuggestedUrl - } - return engine.RenderChunksFromResolved( bundle.Document.DocumentTitle, suggestedURL, diff --git a/internal/source/mapping/resolver.go b/internal/source/mapping/resolver.go index 77a1ed1..d2d4b28 100644 --- a/internal/source/mapping/resolver.go +++ b/internal/source/mapping/resolver.go @@ -22,7 +22,7 @@ func (r *Resolver) Build( for i, group := range groups { chunks[i] = ResolvedChunk{ Locations: []gdocs.LocationGroupedSuggestions{group}, - Mapping: MappingMetadata{Method: "none", Confidence: 0, Status: "none"}, + Mapping: MappingMetadata{Method: "none", Confidence: 0, Status: "unresolved"}, } if design != nil { anchors, meta := r.resolveAnchor(group, design) @@ -32,6 +32,14 @@ func (r *Resolver) Build( chunks[i].Comments = r.commentsForAnchors(anchors, design) } } + // Post-process: low-confidence, fallback, and unmatched ("none") mappings are + // promoted to status "unresolved" so they are never silently treated as healthy. + for i := range chunks { + m := &chunks[i].Mapping + if m.Confidence < 0.5 || m.Method == "fallback" || m.Method == "none" { + m.Status = "unresolved" + } + } return chunks }