Skip to content
Open
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
7 changes: 5 additions & 2 deletions docs/implementation-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.

---

Expand Down
151 changes: 124 additions & 27 deletions internal/artifacts/manager.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package artifacts

import (
"bufio"
"crypto/rand"
"encoding/hex"
"encoding/json"
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Comment on lines +207 to +209
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()

Comment on lines +210 to +217

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed these need coverage. Will add unit tests for LoadPreviousMeta, LoadMappings, and UpdateRunFigmaVersion in a follow-up.

// Collect matching entries (in file order; last = most recent).
var matched []RunIndexEntry
scanner := bufio.NewScanner(f)
for scanner.Scan() {
Comment on lines +207 to +221
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
}
Comment on lines +228 to +235
// Match figmaFileKey against the stored FigmaURL.
if entry.FigmaURL == "" {
continue
}
ref, err := figma.ParseLink(entry.FigmaURL)
if err != nil || ref.FileKey != figmaFileKey {
Comment on lines +236 to +241

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — this is a known limitation. LoadPreviousMeta currently matches by file key only, so running against a different node within the same file will incorrectly reuse stale mappings. Fixing this properly requires adding a node_id column to RunIndexEntry and updating the JSONL schema. Tracking as a follow-up for multi-node workflow support.

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
Comment on lines +250 to +255
}
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 {
Expand Down
62 changes: 56 additions & 6 deletions internal/orchestrator/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment on lines +301 to +305

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — the meta fetched for drift detection could be threaded into FetchFigma to avoid the redundant call. This requires changing FetchFigma's signature and plumbing the response through, so tracking as a follow-up to keep this PR focused on drift detection correctness.


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 {
Comment on lines +319 to +323
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,
)
Comment on lines +332 to +339
}
slog.Warn("Previous mappings could not be loaded; proceeding with full re-fetch",
slog.String("prev_run_id", prevRunMeta.RunID))
Comment on lines +314 to +342

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional. The drift check gates on Figma version only — it answers 'has the design changed?' If the Google Doc content changed, the user would simply re-run (the doc is always freshly fetched at the start of Execute). Coupling doc-change detection into the Figma drift path would conflate two independent concerns.

} 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 != "" {
Expand All @@ -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 {
Expand All @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion internal/source/mapping/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"
}
Comment on lines +35 to +41
}
return chunks
}

Expand Down