From 643f23247e261a8e75d824d9e4ed55d7b58b5d42 Mon Sep 17 00:00:00 2001 From: Bauer Agent Date: Wed, 20 May 2026 12:30:09 +0300 Subject: [PATCH 1/5] feat(phase-0a): add agent interface, source layer, decouple orchestrator - T0.1: internal/agent package with Agent interface and MockAgent - T0.2: copilotcli.Client implements agent.Agent (updated Start/GenerateSummary sigs) - T0.2a: internal/source package with SourceBundle and Manager - T0.2b: orchestrator refactored to use source.Manager and agent.Agent - Fix: Config.DryRun changed to *bool with BoolPtr/BoolVal helpers - Fix: config.CLIFlags added; resolveCLIConfig/openPRExecutionConfig added to cmd/bauer - Fix: config_test.go updated to use valid credentials JSON fixture --- cmd/app/main.go | 23 +++- cmd/bauer/main.go | 80 +++++++++++- internal/agent/agent.go | 24 ++++ internal/agent/agent_test.go | 50 ++++++++ internal/agent/mock.go | 16 +++ internal/config/cli.go | 16 ++- internal/config/config.go | 14 ++- internal/config/config_test.go | 11 +- internal/copilotcli/client.go | 36 +++--- internal/orchestrator/orchestrator.go | 171 +++++++++++++------------- internal/source/manager.go | 38 ++++++ internal/source/manager_test.go | 33 +++++ internal/source/source.go | 17 +++ internal/source/types.go | 14 +++ internal/workflow/workflow.go | 4 +- 15 files changed, 435 insertions(+), 112 deletions(-) create mode 100644 internal/agent/agent.go create mode 100644 internal/agent/agent_test.go create mode 100644 internal/agent/mock.go create mode 100644 internal/source/manager.go create mode 100644 internal/source/manager_test.go create mode 100644 internal/source/source.go create mode 100644 internal/source/types.go diff --git a/cmd/app/main.go b/cmd/app/main.go index 83745ae..1c3ac7a 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -4,7 +4,9 @@ import ( "bauer/cmd/app/core/middleware" "bauer/cmd/app/types" v1 "bauer/cmd/app/v1" + "bauer/internal/copilotcli" "bauer/internal/orchestrator" + "bauer/internal/source" "bauer/internal/workflow" "fmt" "log/slog" @@ -20,22 +22,36 @@ func run() error { slog.Info("startup", "status", "initializing API") defer slog.Info("shutdown complete") - orchestrator := orchestrator.NewOrchestrator() cfg, err := types.LoadConfig() if err != nil { slog.Error("failed to load config", "error", err.Error()) return err } + cwd, err := os.Getwd() + if err != nil { + slog.Error("failed to get working directory", "error", err.Error()) + return err + } + + copilotAgent, err := copilotcli.NewClient(cwd) + if err != nil { + slog.Error("failed to create Copilot client", "error", err.Error()) + return err + } + + sources := source.NewManager(cfg.CredentialsPath) + orch := orchestrator.New(copilotAgent, sources) + rc := types.RouteConfig{ APIConfig: *cfg, - Orchestrator: orchestrator, + Orchestrator: orch, } mux := http.NewServeMux() mux.HandleFunc("/api/v1/job", v1.JobPost(rc)) mux.HandleFunc("/api/v1/health", v1.GetHealth) - mux.HandleFunc("/api/v1/workflow", workflow.ExecuteWorkflowHandler(orchestrator)) + mux.HandleFunc("/api/v1/workflow", workflow.ExecuteWorkflowHandler(orch)) slog.Info("starting server", "address", ":8090") err = http.ListenAndServe(":8090", middleware.RequestTrace(mux)) @@ -53,3 +69,4 @@ func main() { os.Exit(1) } } + diff --git a/cmd/bauer/main.go b/cmd/bauer/main.go index 761724b..f67a9a9 100644 --- a/cmd/bauer/main.go +++ b/cmd/bauer/main.go @@ -1,13 +1,17 @@ package main import ( + "bauer/internal/config" + "bauer/internal/copilotcli" "bauer/internal/github" "bauer/internal/orchestrator" + "bauer/internal/source" "bauer/internal/workflow" "context" "flag" "fmt" "os" + "path/filepath" "strings" ) @@ -56,7 +60,27 @@ func main() { OutputDir: *outputDir, } - orch := orchestrator.NewOrchestrator() + // Resolve credentials path to absolute so it remains valid after directory changes. + absCredentials, err := filepath.Abs(*credentialsPath) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: failed to resolve credentials path: %v\n", err) + os.Exit(1) + } + + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: failed to get working directory: %v\n", err) + os.Exit(1) + } + + copilotAgent, err := copilotcli.NewClient(cwd) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: failed to create Copilot client: %v\n", err) + os.Exit(1) + } + + sources := source.NewManager(absCredentials) + orch := orchestrator.New(copilotAgent, sources) // Execute the complete workflow result, err := workflow.ExecuteWorkflow(context.Background(), workflowInput, orch) @@ -70,3 +94,57 @@ func main() { fmt.Printf("Branch: %s\n", result.RepositoryInfo.BranchName) fmt.Printf("PR: %s\n", result.FinalizationInfo.PullRequest.URL) } + +// resolveCLIConfig builds a Config from CLI flags, falling back to environment variables +// when flag values are empty. It does NOT call Validate — callers must do that separately. +func resolveCLIConfig(flags config.CLIFlags) (*config.Config, error) { + docID := flags.DocID + if docID == "" { + docID = os.Getenv("BAUER_DOC_ID") + } + + credentialsPath := flags.CredentialsPath + if credentialsPath == "" { + credentialsPath = os.Getenv("BAUER_CREDENTIALS") + } + + outputDir := flags.OutputDir + if outputDir == "" { + outputDir = os.Getenv("BAUER_OUTPUT_DIR") + } + + model := flags.Model + if model == "" { + model = os.Getenv("BAUER_MODEL") + } + + summaryModel := flags.SummaryModel + if summaryModel == "" { + summaryModel = os.Getenv("BAUER_SUMMARY_MODEL") + } + + targetRepo := flags.TargetRepo + if targetRepo == "" { + targetRepo = os.Getenv("BAUER_TARGET_REPO") + } + + return &config.Config{ + DocID: docID, + CredentialsPath: credentialsPath, + DryRun: flags.DryRun, + ChunkSize: flags.ChunkSize, + OutputDir: outputDir, + Model: model, + SummaryModel: summaryModel, + TargetRepo: targetRepo, + }, nil +} + +// openPRExecutionConfig returns a copy of cfg with DryRun forced to false, +// so that the Copilot agent runs even when the overall --dry-run flag was set. +func openPRExecutionConfig(cfg *config.Config) *config.Config { + copy := *cfg + f := false + copy.DryRun = &f + return © +} diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..be2ff62 --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,24 @@ +// internal/agent/agent.go +package agent + +import "context" + +// Agent is the interface any AI execution backend must implement. +// copilotcli.Client implements this; future backends (REST-based agents, +// test mocks, etc.) can implement it without touching the orchestrator. +type Agent interface { + // Start boots the agent (e.g. starts the Copilot SDK server process). + // Must be called before any other method. Callers should defer Stop(). + Start(ctx context.Context) error + + // ExecuteChunk sends a single chunk prompt file to the agent and returns + // the full text output. chunkNum is for logging/display only. + ExecuteChunk(ctx context.Context, chunkPath string, chunkNum int, model string) (string, error) + + // GenerateSummary produces a summary of all chunk outputs. + // Only called when there are multiple chunks. + GenerateSummary(ctx context.Context, outputs []string, model string) (string, error) + + // Stop shuts the agent down cleanly. Safe to call after a failed Start. + Stop() error +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go new file mode 100644 index 0000000..24ea161 --- /dev/null +++ b/internal/agent/agent_test.go @@ -0,0 +1,50 @@ +package agent_test + +import ( + "context" + "testing" + + "bauer/internal/agent" +) + +// Compile-time check that MockAgent implements Agent. +var _ agent.Agent = agent.MockAgent{} + +func TestMockAgent_Start(t *testing.T) { + a := agent.MockAgent{} + if err := a.Start(context.Background()); err != nil { + t.Fatalf("Start() = %v, want nil", err) + } +} + +func TestMockAgent_Stop(t *testing.T) { + a := agent.MockAgent{} + if err := a.Stop(); err != nil { + t.Fatalf("Stop() = %v, want nil", err) + } +} + +func TestMockAgent_ExecuteChunk(t *testing.T) { + a := agent.MockAgent{} + out, err := a.ExecuteChunk(context.Background(), "chunk-1.md", 1, "gpt-4") + if err != nil { + t.Fatalf("ExecuteChunk() error = %v", err) + } + if out == "" { + t.Fatal("ExecuteChunk() returned empty output") + } + if want := "mock output for chunk chunk-1.md"; out != want { + t.Fatalf("ExecuteChunk() = %q, want %q", out, want) + } +} + +func TestMockAgent_GenerateSummary(t *testing.T) { + a := agent.MockAgent{} + summary, err := a.GenerateSummary(context.Background(), []string{"output1", "output2"}, "gpt-4") + if err != nil { + t.Fatalf("GenerateSummary() error = %v", err) + } + if want := "mock summary"; summary != want { + t.Fatalf("GenerateSummary() = %q, want %q", summary, want) + } +} diff --git a/internal/agent/mock.go b/internal/agent/mock.go new file mode 100644 index 0000000..56c6da5 --- /dev/null +++ b/internal/agent/mock.go @@ -0,0 +1,16 @@ +// internal/agent/mock.go +package agent + +import "context" + +// MockAgent is a no-op Agent implementation for use in tests. +type MockAgent struct{} + +func (m MockAgent) Start(_ context.Context) error { return nil } +func (m MockAgent) ExecuteChunk(_ context.Context, chunkPath string, _ int, _ string) (string, error) { + return "mock output for chunk " + chunkPath, nil +} +func (m MockAgent) GenerateSummary(_ context.Context, _ []string, _ string) (string, error) { + return "mock summary", nil +} +func (m MockAgent) Stop() error { return nil } diff --git a/internal/config/cli.go b/internal/config/cli.go index dc4f846..570e604 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -6,6 +6,20 @@ import ( "os" ) +// CLIFlags holds the raw command-line flag values before environment-variable resolution. +type CLIFlags struct { + DocID string + CredentialsPath string + ConfigFile string + DryRun *bool + ChunkSize int + PageRefresh *bool + OutputDir string + Model string + SummaryModel string + TargetRepo string +} + // Load parses command-line flags and returns a validated Config. func Load() (*Config, error) { // Define flags @@ -76,7 +90,7 @@ func Load() (*Config, error) { cfg := &Config{ DocID: *docID, CredentialsPath: *credentialsPath, - DryRun: *dryRun, + DryRun: dryRun, ChunkSize: *chunkSize, PageRefresh: *pageRefresh, OutputDir: *outputDir, diff --git a/internal/config/config.go b/internal/config/config.go index 715fefa..468638d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,7 +16,8 @@ type Config struct { CredentialsPath string `json:"credentials"` // DryRun indicates if the tool should skip side-effect operations (Copilot CLI, PR creation). - DryRun bool `json:"dry_run"` + // Uses *bool so that an explicit false from CLI flags can override a true from a config file. + DryRun *bool `json:"dry_run,omitempty"` // ChunkSize is the total number of chunks to create from all locations. // Default is 1 if not specified, or 5 if PageRefresh is true. @@ -100,3 +101,14 @@ func ValidateCredentialsPath(path string) error { } return nil } + +// BoolPtr returns a pointer to the given bool value. +func BoolPtr(v bool) *bool { return &v } + +// BoolVal dereferences a *bool pointer. Returns def if v is nil. +func BoolVal(v *bool, def bool) bool { + if v == nil { + return def + } + return *v +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 22e95c7..dfe31ed 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -7,10 +7,12 @@ import ( ) func TestConfig_Validate(t *testing.T) { - // Create a temporary file to act as a valid credentials file + // Create a temporary file to act as a valid credentials file. + // The file must satisfy gdocs.ValidateCredentialsFile requirements. + validCredsJSON := `{"type":"service_account","project_id":"test-project","private_key":"fake-key","client_email":"test@test-project.iam.gserviceaccount.com","token_uri":"https://oauth2.googleapis.com/token"}` tmpDir := t.TempDir() validCredsFile := filepath.Join(tmpDir, "creds.json") - if err := os.WriteFile(validCredsFile, []byte("{}"), 0644); err != nil { + if err := os.WriteFile(validCredsFile, []byte(validCredsJSON), 0644); err != nil { t.Fatalf("Failed to create temp creds file: %v", err) } @@ -123,10 +125,11 @@ func TestConfig_Validate(t *testing.T) { } func TestChunkSizeDefaults(t *testing.T) { - // Create a temporary file to act as a valid credentials file + // Create a temporary file to act as a valid credentials file. + validCredsJSON := `{"type":"service_account","project_id":"test-project","private_key":"fake-key","client_email":"test@test-project.iam.gserviceaccount.com","token_uri":"https://oauth2.googleapis.com/token"}` tmpDir := t.TempDir() validCredsFile := filepath.Join(tmpDir, "creds.json") - if err := os.WriteFile(validCredsFile, []byte("{}"), 0644); err != nil { + if err := os.WriteFile(validCredsFile, []byte(validCredsJSON), 0644); err != nil { t.Fatalf("Failed to create temp creds file: %v", err) } diff --git a/internal/copilotcli/client.go b/internal/copilotcli/client.go index beb30b5..56b253e 100644 --- a/internal/copilotcli/client.go +++ b/internal/copilotcli/client.go @@ -9,9 +9,13 @@ import ( "strings" "time" + "bauer/internal/agent" copilot "github.com/github/copilot-sdk/go" ) +// Compile-time check: Client implements agent.Agent. +var _ agent.Agent = (*Client)(nil) + // ANSI color codes for terminal output const ( colorReset = "\033[0m" @@ -80,7 +84,7 @@ func NewClient(cwd string) (*Client, error) { } // Start starts the Copilot CLI server -func (c *Client) Start() error { +func (c *Client) Start(_ context.Context) error { slog.Info("Starting Copilot client...") if err := c.client.Start(); err != nil { return fmt.Errorf("failed to start Copilot client: %w", err) @@ -258,8 +262,8 @@ type ChunkOutput struct { Duration time.Duration } -// GenerateSummary creates a summary session with all chunk outputs -func (c *Client) GenerateSummary(ctx context.Context, outputs []ChunkOutput, model string) error { +// GenerateSummary creates a summary session with all chunk outputs and returns the summary text. +func (c *Client) GenerateSummary(ctx context.Context, outputs []string, model string) (string, error) { slog.Info("Creating summary session", slog.String("model", model)) // Create a session with streaming enabled @@ -268,7 +272,7 @@ func (c *Client) GenerateSummary(ctx context.Context, outputs []ChunkOutput, mod Streaming: true, }) if err != nil { - return fmt.Errorf("failed to create summary session: %w", err) + return "", fmt.Errorf("failed to create summary session: %w", err) } defer func() { if err := session.Destroy(); err != nil { @@ -278,22 +282,26 @@ func (c *Client) GenerateSummary(ctx context.Context, outputs []ChunkOutput, mod // Set up event handler done := make(chan error, 1) + var fullOutput string session.On(func(event copilot.SessionEvent) { switch event.Type { case "assistant.message_delta": if event.Data.DeltaContent != nil { fmt.Print(formatSummaryOutput(*event.Data.DeltaContent)) + fullOutput += *event.Data.DeltaContent } case "assistant.reasoning_delta": if event.Data.DeltaContent != nil { fmt.Print(formatCopilotDim(*event.Data.DeltaContent)) + fullOutput += *event.Data.DeltaContent } case "assistant.message": // Print final message in yellow for summary if event.Data.Content != nil { + fullOutput += *event.Data.Content fmt.Println(formatSummaryOutput(*event.Data.Content)) slog.Debug("Summary response", slog.String("content", *event.Data.Content)) } @@ -301,6 +309,7 @@ func (c *Client) GenerateSummary(ctx context.Context, outputs []ChunkOutput, mod case "assistant.reasoning": // Print reasoning in dimmed style if event.Data.Content != nil { + fullOutput += *event.Data.Content fmt.Println(formatCopilotDim(*event.Data.Content)) slog.Debug("Summary reasoning", slog.String("content", *event.Data.Content)) } @@ -328,28 +337,28 @@ func (c *Client) GenerateSummary(ctx context.Context, outputs []ChunkOutput, mod Prompt: summaryPrompt, }) if err != nil { - return fmt.Errorf("failed to send summary message: %w", err) + return "", fmt.Errorf("failed to send summary message: %w", err) } // Wait for completion select { case err := <-done: if err != nil { - return err + return "", err } fmt.Println() // Add newline after streaming output - return nil + return fullOutput, nil case <-time.After(10 * time.Minute): - return fmt.Errorf("summary session timed out after 10 minutes") + return "", fmt.Errorf("summary session timed out after 10 minutes") case <-ctx.Done(): - return fmt.Errorf("summary session cancelled: %w", ctx.Err()) + return "", fmt.Errorf("summary session cancelled: %w", ctx.Err()) } } // buildSummaryPrompt creates the prompt for the summary session -func buildSummaryPrompt(outputs []ChunkOutput) string { +func buildSummaryPrompt(outputs []string) string { var prompt strings.Builder prompt.WriteString("# Summary Task\n\n") @@ -368,11 +377,10 @@ func buildSummaryPrompt(outputs []ChunkOutput) string { prompt.WriteString("## Chunks Processed\n\n") - for _, output := range outputs { - fmt.Fprintf(&prompt, "### Chunk %d\n\n", output.ChunkNumber) - fmt.Fprintf(&prompt, "**Duration**: %s\n\n", output.Duration.Round(time.Millisecond)) + for i, output := range outputs { + fmt.Fprintf(&prompt, "### Chunk %d\n\n", i+1) prompt.WriteString("**Output**:\n```\n") - prompt.WriteString(output.Output) + prompt.WriteString(output) prompt.WriteString("\n```\n\n") } diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 90d4e73..1f85ccd 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -1,10 +1,10 @@ package orchestrator import ( + "bauer/internal/agent" "bauer/internal/config" - "bauer/internal/copilotcli" - "bauer/internal/gdocs" "bauer/internal/prompt" + "bauer/internal/source" "context" "encoding/json" "fmt" @@ -13,10 +13,17 @@ import ( "time" ) +// ChunkOutput represents the output from a single agent chunk execution. +type ChunkOutput struct { + ChunkNumber int + Output string + Duration time.Duration +} + // OrchestrationResult contains all outputs from the orchestration flow. type OrchestrationResult struct { // Extraction - ExtractionResult *gdocs.ProcessingResult + ExtractionBundle *source.SourceBundle ExtractionDuration time.Duration // Prompt generation @@ -24,7 +31,8 @@ type OrchestrationResult struct { PlanDuration time.Duration // Only populated if not dry run - CopilotOutputs []copilotcli.ChunkOutput + CopilotOutputs []ChunkOutput + Summary string CopilotDuration time.Duration SummaryDuration time.Duration @@ -39,55 +47,51 @@ type Orchestrator interface { } // DefaultOrchestrator is the standard implementation of the Orchestrator interface. -type DefaultOrchestrator struct{} +type DefaultOrchestrator struct { + agent agent.Agent + sources *source.Manager +} -// NewOrchestrator creates a new DefaultOrchestrator instance. -func NewOrchestrator() *DefaultOrchestrator { - return &DefaultOrchestrator{} +// New creates a new DefaultOrchestrator with the given agent and source manager. +func New(a agent.Agent, sources *source.Manager) *DefaultOrchestrator { + return &DefaultOrchestrator{agent: a, sources: sources} } -// Execute runs the full pipeline: extraction, prompt generation, and optional Copilot execution. -// Accepts: Config and Context -// Returns: OrchestrationResult and error +// Execute runs the full pipeline: extraction, prompt generation, and optional agent execution. func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) (*OrchestrationResult, error) { startTime := time.Now() - // 1. Initialize GDocs Client and extract from doc + // 1. Fetch from source (Google Docs) extractionStart := time.Now() - gdocsClient, err := gdocs.NewClient(ctx, cfg.CredentialsPath) + bundle, err := o.sources.Fetch(ctx, source.Request{DocID: cfg.DocID}) if err != nil { - slog.Error("Failed to initialize Google Docs client", + slog.Error("Failed to fetch from source", slog.String("error", err.Error()), - slog.String("credentials_path", cfg.CredentialsPath), + slog.String("doc_id", cfg.DocID), ) - return nil, fmt.Errorf("failed to initialize Google Docs client: %w", err) - } - - // 2. Process Document - result, err := gdocsClient.ProcessDocument(ctx, cfg.DocID) - if err != nil { - return nil, fmt.Errorf("failed to process document: %w", err) + return nil, fmt.Errorf("failed to fetch from source: %w", err) } extractionDuration := time.Since(extractionStart) - // 3. Write extraction result to file - outputJSON, err := json.MarshalIndent(result, "", " ") - if err != nil { - slog.Error("Failed to marshal output", slog.String("error", err.Error())) - return nil, fmt.Errorf("failed to generate output JSON: %w", err) - } - outputFile := "bauer-doc-suggestions.json" - err = os.WriteFile(outputFile, outputJSON, 0644) - if err != nil { - slog.Error("Failed to write output file", slog.String("error", err.Error())) - return nil, fmt.Errorf("failed to write output file: %w", err) + // 2. Write extraction result to file + if bundle.Document != nil { + outputJSON, err := json.MarshalIndent(bundle.Document, "", " ") + if err != nil { + slog.Error("Failed to marshal output", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to generate output JSON: %w", err) + } + outputFile := "bauer-doc-suggestions.json" + if err = os.WriteFile(outputFile, outputJSON, 0644); err != nil { + slog.Error("Failed to write output file", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to write output file: %w", err) + } + slog.Info("Extraction complete", + slog.String("output_file", outputFile), + slog.Duration("extraction_duration", extractionDuration), + ) } - slog.Info("Extraction complete", - slog.String("output_file", outputFile), - slog.Duration("extraction_duration", extractionDuration), - ) - // 4. Initialize Prompt Engine + // 3. Initialize Prompt Engine planStart := time.Now() engine, err := prompt.NewEngine(cfg.PageRefresh) if err != nil { @@ -95,14 +99,18 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( return nil, fmt.Errorf("failed to initialize prompt engine: %w", err) } - // 5. Generate Prompts from Chunks - totalLocations := len(result.GroupedSuggestions) + // 4. Generate Prompts from Chunks + if bundle.Document == nil { + return nil, fmt.Errorf("no document available: DocID may be empty") + } + + totalLocations := len(bundle.Document.GroupedSuggestions) slog.Info("Generating prompts", slog.Int("total_locations", totalLocations), slog.Int("chunk_size", cfg.ChunkSize), ) chunks, err := engine.GenerateAllChunks( - result, + bundle.Document, cfg.ChunkSize, cfg.OutputDir, ) @@ -122,15 +130,14 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( } // If dry run, return early - if cfg.DryRun { + if config.BoolVal(cfg.DryRun, false) { totalDuration := time.Since(startTime) - return &OrchestrationResult{ - ExtractionResult: result, + ExtractionBundle: bundle, ExtractionDuration: extractionDuration, Chunks: chunks, PlanDuration: planDuration, - CopilotOutputs: []copilotcli.ChunkOutput{}, + CopilotOutputs: []ChunkOutput{}, CopilotDuration: 0, SummaryDuration: 0, TotalDuration: totalDuration, @@ -138,54 +145,48 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( }, nil } - // 6. Execute via Copilot SDK - cwd, err := os.Getwd() - if err != nil { - slog.Error("Failed to get working directory", slog.String("error", err.Error())) - return nil, fmt.Errorf("failed to get working directory: %w", err) - } - - slog.Info("Initializing Copilot client", slog.String("cwd", cwd)) - copilotClient, err := copilotcli.NewClient(cwd) - if err != nil { - slog.Error("Failed to create Copilot client", slog.String("error", err.Error())) - return nil, fmt.Errorf("failed to create Copilot client: %w", err) - } - - // Start the Copilot CLI server once - if err := copilotClient.Start(); err != nil { - // Attempt to stop the client if Start failed - if stopErr := copilotClient.Stop(); stopErr != nil { - slog.Error("Failed to stop Copilot client after start failure", slog.String("error", stopErr.Error())) + // 5. Start agent + slog.Info("Starting agent") + if err := o.agent.Start(ctx); err != nil { + if stopErr := o.agent.Stop(); stopErr != nil { + slog.Error("Failed to stop agent after start failure", slog.String("error", stopErr.Error())) } - slog.Error("Failed to start Copilot", slog.String("error", err.Error())) - return nil, fmt.Errorf("failed to start Copilot: %w", err) + slog.Error("Failed to start agent", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to start agent: %w", err) } defer func() { - if err := copilotClient.Stop(); err != nil { - slog.Error("Failed to stop Copilot client", slog.String("error", err.Error())) + if err := o.agent.Stop(); err != nil { + slog.Error("Failed to stop agent", slog.String("error", err.Error())) } }() - // Execute chunks via Copilot SDK - chunkOutputs, copilotDuration, err := executeCopilotChunks(ctx, chunks, cfg, copilotClient) + // 6. Execute chunks via agent + chunkOutputs, copilotDuration, err := executeAgentChunks(ctx, chunks, cfg, o.agent) if err != nil { - slog.Error("Copilot execution failed", slog.String("error", err.Error())) - return nil, fmt.Errorf("copilot execution failed: %w", err) + slog.Error("Agent execution failed", slog.String("error", err.Error())) + return nil, fmt.Errorf("agent execution failed: %w", err) } - slog.Info("Copilot chunks executed", + slog.Info("Agent chunks executed", slog.Int("chunk_count", len(chunks)), slog.Duration("total_duration", copilotDuration), ) // 7. Generate summary if multiple chunks summaryDuration := time.Duration(0) + var summary string if len(chunks) > 1 { summaryStart := time.Now() - if err := copilotClient.GenerateSummary(ctx, chunkOutputs, cfg.SummaryModel); err != nil { - slog.Error("Summary generation failed", slog.String("error", err.Error())) + outputs := make([]string, len(chunkOutputs)) + for i, co := range chunkOutputs { + outputs[i] = co.Output + } + + var summaryErr error + summary, summaryErr = o.agent.GenerateSummary(ctx, outputs, cfg.SummaryModel) + if summaryErr != nil { + slog.Error("Summary generation failed", slog.String("error", summaryErr.Error())) // Summary failure is not fatal; continue with results } else { summaryDuration = time.Since(summaryStart) @@ -198,11 +199,12 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( totalDuration := time.Since(startTime) return &OrchestrationResult{ - ExtractionResult: result, + ExtractionBundle: bundle, ExtractionDuration: extractionDuration, Chunks: chunks, PlanDuration: planDuration, CopilotOutputs: chunkOutputs, + Summary: summary, CopilotDuration: copilotDuration, SummaryDuration: summaryDuration, TotalDuration: totalDuration, @@ -210,16 +212,16 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( }, nil } -// executeCopilotChunks executes each chunk via the Copilot SDK and returns outputs -func executeCopilotChunks( +// executeAgentChunks executes each chunk via the agent and returns outputs. +func executeAgentChunks( ctx context.Context, chunks []prompt.ChunkResult, cfg *config.Config, - client *copilotcli.Client, -) ([]copilotcli.ChunkOutput, time.Duration, error) { + a agent.Agent, +) ([]ChunkOutput, time.Duration, error) { executionStart := time.Now() - var outputs []copilotcli.ChunkOutput + var outputs []ChunkOutput totalChunks := len(chunks) for i, chunk := range chunks { @@ -230,16 +232,13 @@ func executeCopilotChunks( slog.Int("chunk_count", totalChunks), ) - // Execute the chunk - output, err := client.ExecuteChunk(ctx, chunk.Filename, chunk.ChunkNumber, cfg.Model) + output, err := a.ExecuteChunk(ctx, chunk.Filename, chunk.ChunkNumber, cfg.Model) if err != nil { return nil, 0, fmt.Errorf("failed to execute chunk %d: %w", chunk.ChunkNumber, err) } chunkDuration := time.Since(chunkStart) - - // Collect output - outputs = append(outputs, copilotcli.ChunkOutput{ + outputs = append(outputs, ChunkOutput{ ChunkNumber: chunk.ChunkNumber, Output: output, Duration: chunkDuration, diff --git a/internal/source/manager.go b/internal/source/manager.go new file mode 100644 index 0000000..db85662 --- /dev/null +++ b/internal/source/manager.go @@ -0,0 +1,38 @@ +package source + +import ( + "context" + "fmt" + + "bauer/internal/gdocs" +) + +// Manager holds all registered source adapters and orchestrates fetching. +type Manager struct { + credentialsPath string +} + +// NewManager creates a Manager. credentialsPath is for Google Docs auth. +func NewManager(credentialsPath string) *Manager { + return &Manager{credentialsPath: credentialsPath} +} + +// Fetch runs the Google Docs adapter (and later, the Figma adapter when it exists). +// Returns a SourceBundle combining all source outputs. +func (m *Manager) Fetch(ctx context.Context, req Request) (*SourceBundle, error) { + bundle := &SourceBundle{} + + if req.DocID != "" { + gdocsClient, err := gdocs.NewClient(ctx, m.credentialsPath) + if err != nil { + return nil, fmt.Errorf("gdocs client init: %w", err) + } + result, err := gdocsClient.ProcessDocument(ctx, req.DocID) + if err != nil { + return nil, fmt.Errorf("gdocs fetch: %w", err) + } + bundle.Document = result + } + + return bundle, nil +} diff --git a/internal/source/manager_test.go b/internal/source/manager_test.go new file mode 100644 index 0000000..20e0738 --- /dev/null +++ b/internal/source/manager_test.go @@ -0,0 +1,33 @@ +package source_test + +import ( + "context" + "testing" + + "bauer/internal/source" +) + +func TestNewManager(t *testing.T) { + mgr := source.NewManager("credentials.json") + if mgr == nil { + t.Fatal("NewManager() returned nil") + } +} + +func TestManager_Fetch_EmptyDocID(t *testing.T) { + mgr := source.NewManager("credentials.json") + // When DocID is empty, no fetch is performed and an empty bundle is returned. + bundle, err := mgr.Fetch(context.Background(), source.Request{}) + if err != nil { + t.Fatalf("Fetch() with empty request error = %v, want nil", err) + } + if bundle == nil { + t.Fatal("Fetch() returned nil bundle") + } + if bundle.Document != nil { + t.Fatal("Fetch() expected nil Document when DocID is empty") + } + if bundle.Design != nil { + t.Fatal("Fetch() expected nil Design when FigmaURL is empty") + } +} diff --git a/internal/source/source.go b/internal/source/source.go new file mode 100644 index 0000000..a280010 --- /dev/null +++ b/internal/source/source.go @@ -0,0 +1,17 @@ +package source + +import "context" + +// Adapter is the interface any upstream data source must implement. +type Adapter interface { + // Name returns the adapter's identifier (e.g. "gdocs", "figma"). + Name() string + // Fetch retrieves data from the source. Returns the raw source-specific payload. + Fetch(ctx context.Context, req Request) (any, error) +} + +// Request contains the parameters for fetching from sources. +type Request struct { + DocID string // Google Doc ID + FigmaURL string // Optional Figma file/node URL +} diff --git a/internal/source/types.go b/internal/source/types.go new file mode 100644 index 0000000..633b371 --- /dev/null +++ b/internal/source/types.go @@ -0,0 +1,14 @@ +package source + +import "bauer/internal/gdocs" + +// SourceBundle is the normalized combined output from all source adapters. +// It is what the orchestrator operates on — not raw gdocs or figma types directly. +type SourceBundle struct { + // Document is the Google Docs extraction result. Always present when a DocID is set. + Document *gdocs.ProcessingResult `json:"document,omitempty"` + // Design holds the optional Figma normalized design output. + // It is nil when no --figma-url was supplied. Will be *figma.NormalizedDesign + // once the figma package lands in a later branch. + Design any `json:"design,omitempty"` +} diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index c0a283e..c555349 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -162,7 +162,7 @@ func ExecuteWorkflow(ctx context.Context, input WorkflowInput, orch orchestrator bauerCfg := &config.Config{ DocID: input.DocID, CredentialsPath: credentialsPath, // Use absolute path - DryRun: input.DryRun, + DryRun: config.BoolPtr(input.DryRun), ChunkSize: input.ChunkSize, PageRefresh: input.PageRefresh, OutputDir: input.OutputDir, @@ -189,7 +189,7 @@ func ExecuteWorkflow(ctx context.Context, input WorkflowInput, orch orchestrator if len(bauerResult.Chunks) > 0 { output.BauerResult.ChunkCount = len(bauerResult.Chunks) } - if bauerResult.ExtractionResult != nil { + if bauerResult.ExtractionBundle != nil && bauerResult.ExtractionBundle.Document != nil { // Count total suggestions from extraction result output.BauerResult.TotalSuggestions = 0 // TODO: adjust based on actual field } From 00d6881aad6e5db546d1bd09fe24f1854bdd98d1 Mon Sep 17 00:00:00 2001 From: Bauer Agent Date: Wed, 20 May 2026 12:30:44 +0300 Subject: [PATCH 2/5] docs: update implementation log for branch 1 (phase-0a) --- docs/implementation-log.md | 67 +++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/docs/implementation-log.md b/docs/implementation-log.md index 499b31b..4502dbb 100644 --- a/docs/implementation-log.md +++ b/docs/implementation-log.md @@ -20,7 +20,7 @@ Each sub-agent appends its entry to the **Branch Log** section below. You (the r | Order | Branch | Parent | Phase / Tasks | Status | |-------|--------|--------|---------------|--------| | 0 | `feature/bauer-v2` | `main` | Base branch — no code changes | ✅ created | -| 1 | `feat/phase-0a-agent-source` | `feature/bauer-v2` | 001 Phase 0: T0.1, T0.2, T0.2a, T0.2b | ⏳ pending | +| 1 | `feat/phase-0a-agent-source` | `feature/bauer-v2` | 001 Phase 0: T0.1, T0.2, T0.2a, T0.2b | ✅ done | | 2 | `feat/phase-0b-artifacts-config` | `feat/phase-0a-agent-source` | 001 Phase 0: T0.2c, T0.3, T0.4, T0.5 | ⏳ pending | | 3 | `feat/phase-1-cli-restore` | `feat/phase-0b-artifacts-config` | 001 Phase 1: T1.1, T1.2, T1.3 | ⏳ pending | | 4 | `feat/phase-2-cli-features` | `feat/phase-1-cli-restore` | 001 Phase 2: T2.1, T2.2, T2.3 | ⏳ pending | @@ -45,13 +45,64 @@ _Parent: `feature/bauer-v2`_ **Tasks:** T0.1, T0.2, T0.2a, T0.2b -**Summary:** _(to be filled by agent)_ - -**Files changed:** _(to be filled by agent)_ - -**Docs / references:** _(to be filled by agent)_ - -**Diagram:** _(optional mermaid, to be filled by agent)_ +**Summary:** Introduced the `agent.Agent` interface to decouple the orchestrator from `copilotcli.Client`. Created the `source` layer (`source.Manager`) so the orchestrator no longer imports `gdocs` directly. `copilotcli.Client` now satisfies `agent.Agent` via a compile-time check. All call sites in `cmd/bauer` and `cmd/app` updated to use `orchestrator.New(agent, sources)`. Also fixed pre-existing test failures: `Config.DryRun` promoted to `*bool` with `BoolPtr`/`BoolVal` helpers; `config.CLIFlags` added; `cmd/bauer` now has `resolveCLIConfig` and `openPRExecutionConfig`; `config_test.go` updated with valid credentials JSON fixture. + +**Files changed:** +- `internal/agent/agent.go` — new: `Agent` interface with `Start`, `ExecuteChunk`, `GenerateSummary`, `Stop` +- `internal/agent/mock.go` — new: `MockAgent` no-op implementation for tests +- `internal/agent/agent_test.go` — new: tests for `MockAgent` and compile-time interface check +- `internal/source/source.go` — new: `Adapter` interface and `Request` type +- `internal/source/types.go` — new: `SourceBundle` wrapping `*gdocs.ProcessingResult` and design placeholder +- `internal/source/manager.go` — new: `Manager` with `NewManager` and `Fetch` (calls gdocs) +- `internal/source/manager_test.go` — new: tests for `NewManager` and empty-request `Fetch` +- `internal/copilotcli/client.go` — updated: `Start` accepts `context.Context`; `GenerateSummary` signature changed to `(ctx, []string, model) (string, error)`; `buildSummaryPrompt` simplified to `[]string`; compile-time check `var _ agent.Agent = (*Client)(nil)` added +- `internal/orchestrator/orchestrator.go` — major refactor: no longer imports `copilotcli` or `gdocs`; `DefaultOrchestrator` holds `agent.Agent` and `*source.Manager`; `New(agent, sources)` constructor; `OrchestrationResult.ExtractionResult` renamed to `ExtractionBundle *source.SourceBundle`; local `ChunkOutput` type; `executeAgentChunks` uses `agent.Agent` +- `internal/config/config.go` — `DryRun` changed from `bool` to `*bool`; `BoolPtr` and `BoolVal` helpers added +- `internal/config/cli.go` — `CLIFlags` struct added; `DryRun` assignment fixed for `*bool` +- `internal/config/config_test.go` — credentials fixture updated to valid service-account JSON +- `internal/workflow/workflow.go` — `DryRun: config.BoolPtr(input.DryRun)` and `ExtractionBundle` reference +- `cmd/bauer/main.go` — uses `orchestrator.New(copilotAgent, sources)`; adds `resolveCLIConfig` and `openPRExecutionConfig` +- `cmd/app/main.go` — uses `orchestrator.New(copilotAgent, sources)` + +**Diagram:** + +```mermaid +graph LR + subgraph cmd + A[cmd/bauer/main.go] + B[cmd/app/main.go] + end + subgraph orchestrator + C[DefaultOrchestrator] + end + subgraph agent + D[Agent interface] + E[MockAgent] + end + subgraph copilotcli + F[Client] + end + subgraph source + G[Manager] + H[SourceBundle] + end + subgraph gdocs + I[ProcessingResult] + end + + A --> C + B --> C + A --> F + B --> F + A --> G + B --> G + C --> D + C --> G + F -->|implements| D + E -->|implements| D + G --> I + H --> I +``` --- From 3094b482221a733a94bfae2b36b9aa535286ae7e Mon Sep 17 00:00:00 2001 From: Bauer Agent Date: Wed, 20 May 2026 12:42:43 +0300 Subject: [PATCH 3/5] feat(phase-0b): artifacts manager, config resolver, env var support, remove JSON config - T0.2c: internal/artifacts package with append-only run directories - T0.3: internal/config/manager.go with Resolver, EnvVarSource, DefaultsSource - T0.4: BAUER_GITHUB_TOKEN priority in github/auth.go - T0.5: remove json.go + --config flag, add .env.example, update .gitignore --- .env.example | 43 ++++++ .gitignore | 2 + Taskfile.yml | 15 +- cmd/app/main.go | 4 +- cmd/app/types/config.go | 28 ++-- cmd/app/v1/api.go | 2 +- cmd/bauer/main.go | 4 +- config.json | 11 -- docs/implementation-log.md | 34 ++++- internal/artifacts/manager.go | 199 ++++++++++++++++++++++++++ internal/artifacts/manager_test.go | 162 +++++++++++++++++++++ internal/config/cli.go | 14 +- internal/config/config.go | 30 +++- internal/config/config_test.go | 2 +- internal/config/json.go | 26 ---- internal/config/manager.go | 140 ++++++++++++++++++ internal/config/manager_test.go | 166 +++++++++++++++++++++ internal/github/auth.go | 9 +- internal/orchestrator/orchestrator.go | 87 ++++++++--- internal/workflow/workflow.go | 2 +- 20 files changed, 886 insertions(+), 94 deletions(-) create mode 100644 .env.example delete mode 100644 config.json create mode 100644 internal/artifacts/manager.go create mode 100644 internal/artifacts/manager_test.go delete mode 100644 internal/config/json.go create mode 100644 internal/config/manager.go create mode 100644 internal/config/manager_test.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..086faab --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# .env.example — copy relevant sections to .env.local and fill in real values. +# .env.local is gitignored. Never commit secrets. +# For the CLI: these BAUER_* vars work as flag fallbacks. + +# --- Secrets (go in .env.local, never committed) --- +BAUER_GITHUB_TOKEN=ghp_... +BAUER_CREDENTIALS_PATH=/path/to/service-account.json + +# --- Google (alternative to BAUER_CREDENTIALS_PATH) --- +# GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json + +# --- GitHub App (alternative to PAT, recommended for org repos) --- +# GITHUB_APP_ID=12345 +# GITHUB_APP_PRIVATE_KEY_PATH=/path/to/private-key.pem +# GITHUB_APP_INSTALLATION_ID=67890 + +# --- OIDC (optional — for API deployments protected by your IdP) --- +# BAUER_OIDC_ISSUER=https://auth.example.com +# BAUER_OIDC_AUDIENCE=bauer-api + +# --- Jira webhook --- +# BAUER_JIRA_WEBHOOK_SECRET=your-shared-secret +# BAUER_JIRA_DOC_FIELD=customfield_10100 + +# --- Figma integration (optional; required when --figma-url is supplied) --- +BAUER_FIGMA_TOKEN=figd_xxxxxxxxxxxxx +# FIGMA_TOKEN=figd_xxxxxxxxxxxxx (fallback if BAUER_FIGMA_TOKEN not set) +BAUER_FIGMA_URL= # e.g. https://www.figma.com/file/AbCdEfGhIjKl/Design?node-id=1:42 + +# --- API Server --- +BAUER_API_PORT=8090 + +# --- Copilot / model --- +BAUER_MODEL=gpt-5-mini-high +BAUER_SUMMARY_MODEL=gpt-5-mini-high + +# --- Bauer behaviour --- +BAUER_DOC_ID= +BAUER_CHUNK_SIZE=1 +BAUER_PAGE_REFRESH=false +BAUER_ARTIFACTS_DIR=./bauer-artifacts +BAUER_BRANCH_PREFIX=bauer +BAUER_DRY_RUN=false diff --git a/.gitignore b/.gitignore index 7f9f318..97c8183 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ bauer-output/ bauer # Added by goreleaser init: dist/ +config.json +*.pem diff --git a/Taskfile.yml b/Taskfile.yml index 0ddaf80..50bb40c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,5 +1,8 @@ version: "3" +# Copy .env.example to .env.local and fill in your values before running. +# .env.local is gitignored — never commit secrets. + tasks: build: desc: Build the Bauer and Bauer API binaries @@ -20,7 +23,17 @@ tasks: run-server: desc: Run the API server locally cmds: - - ./bauer-api --config config.json + - ./bauer-api --credentials ${BAUER_CREDENTIALS_PATH} + + verify-figma: + desc: Verify Figma token is set and reachable + cmds: + - | + if [ -z "$BAUER_FIGMA_TOKEN" ] && [ -z "$FIGMA_TOKEN" ]; then + echo "ERROR: BAUER_FIGMA_TOKEN (or FIGMA_TOKEN) must be set" >&2 + exit 1 + fi + echo "Figma token is set." clean: desc: Clean up generated files diff --git a/cmd/app/main.go b/cmd/app/main.go index 1c3ac7a..b8cf8bd 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -4,6 +4,7 @@ import ( "bauer/cmd/app/core/middleware" "bauer/cmd/app/types" v1 "bauer/cmd/app/v1" + "bauer/internal/artifacts" "bauer/internal/copilotcli" "bauer/internal/orchestrator" "bauer/internal/source" @@ -41,7 +42,8 @@ func run() error { } sources := source.NewManager(cfg.CredentialsPath) - orch := orchestrator.New(copilotAgent, sources) + arts := artifacts.NewManager(cfg.ArtifactsDir) + orch := orchestrator.New(copilotAgent, sources, arts) rc := types.RouteConfig{ APIConfig: *cfg, diff --git a/cmd/app/types/config.go b/cmd/app/types/config.go index bd988c7..508528f 100644 --- a/cmd/app/types/config.go +++ b/cmd/app/types/config.go @@ -14,6 +14,9 @@ type APIConfig struct { // Default is "bauer-output" if not specified. BaseOutputDir string + // ArtifactsDir is the directory for run artifacts. + ArtifactsDir string + // Model is the Copilot model to use for sessions. // Default is "gpt-5-mini-high" if not specified. Model string @@ -24,30 +27,24 @@ type APIConfig struct { // TargetRepo is the path (relative or absolute) to the target repository // where tasks should be executed. If not specified, uses the current directory. - TargetRepo string `json:"target_repo"`} + TargetRepo string `json:"target_repo"` +} func LoadConfig() (*APIConfig, error) { credentialsPath := flag.String("credentials", "", "Path to service account JSON (required)") baseOutputDir := flag.String("base-output-dir", "bauer-output", "Base path of directory for generated prompt files (default: bauer-output)") + artifactsDir := flag.String("artifacts-dir", "./bauer-artifacts", "Directory for run artifacts (default: ./bauer-artifacts)") model := flag.String("model", "gpt-5-mini-high", "Copilot model to use for sessions (default: gpt-5-mini-high)") summaryModel := flag.String("summary-model", "gpt-5-mini-high", "Copilot model to use for summary session (default: gpt-5-mini-high)") - configFile := flag.String("config", "", "Path to JSON config file") targetRepo := flag.String("target-repo", "", "Path to target repository where tasks should be executed (default: current directory)") flag.Parse() - if *configFile != "" { - cfg, err := config.LoadFromJSONFile(*configFile) - if err != nil { - return nil, err - } - return &APIConfig{ - CredentialsPath: cfg.CredentialsPath, - BaseOutputDir: cfg.OutputDir, - Model: cfg.Model, - SummaryModel: cfg.SummaryModel, - TargetRepo: cfg.TargetRepo, - }, nil + if *credentialsPath == "" { + *credentialsPath = os.Getenv("BAUER_CREDENTIALS_PATH") + } + if *credentialsPath == "" { + *credentialsPath = os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") } if *credentialsPath == "" { @@ -58,9 +55,10 @@ func LoadConfig() (*APIConfig, error) { cfg := &APIConfig{ CredentialsPath: *credentialsPath, BaseOutputDir: *baseOutputDir, + ArtifactsDir: *artifactsDir, Model: *model, SummaryModel: *summaryModel, - TargetRepo: *targetRepo, + TargetRepo: *targetRepo, } if err := cfg.Validate(); err != nil { diff --git a/cmd/app/v1/api.go b/cmd/app/v1/api.go index 40fc99f..6f1f725 100644 --- a/cmd/app/v1/api.go +++ b/cmd/app/v1/api.go @@ -35,7 +35,7 @@ func JobPost(rc types.RouteConfig) func(w http.ResponseWriter, r *http.Request) cfg := config.Config{ DocID: payload.DocID, ChunkSize: payload.ChunkSize, - PageRefresh: payload.PageRefresh, + PageRefresh: config.BoolPtr(payload.PageRefresh), CredentialsPath: rc.APIConfig.CredentialsPath, OutputDir: fmt.Sprintf("%s/%s", rc.APIConfig.BaseOutputDir, requestID), Model: rc.APIConfig.Model, diff --git a/cmd/bauer/main.go b/cmd/bauer/main.go index f67a9a9..4c0e103 100644 --- a/cmd/bauer/main.go +++ b/cmd/bauer/main.go @@ -1,6 +1,7 @@ package main import ( + "bauer/internal/artifacts" "bauer/internal/config" "bauer/internal/copilotcli" "bauer/internal/github" @@ -80,7 +81,8 @@ func main() { } sources := source.NewManager(absCredentials) - orch := orchestrator.New(copilotAgent, sources) + arts := artifacts.NewManager("") + orch := orchestrator.New(copilotAgent, sources, arts) // Execute the complete workflow result, err := workflow.ExecuteWorkflow(context.Background(), workflowInput, orch) diff --git a/config.json b/config.json deleted file mode 100644 index 62081ca..0000000 --- a/config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "doc_id": "1WJ-N_Xkkx4r_6knxW7h200oIDyi4mVMzgh1xYt5xaU0", - "credentials": "bau-test-creds.json", - "chunk_size": 1, - "page_refresh": false, - "output_dir": "bauer-output", - "model": "gpt-5-mini-high", - "summary_model": "gpt-5-mini-high", - "target_repo": "../canonical.com", - "dry_run": false -} \ No newline at end of file diff --git a/docs/implementation-log.md b/docs/implementation-log.md index 4502dbb..78f5eac 100644 --- a/docs/implementation-log.md +++ b/docs/implementation-log.md @@ -21,7 +21,7 @@ Each sub-agent appends its entry to the **Branch Log** section below. You (the r |-------|--------|--------|---------------|--------| | 0 | `feature/bauer-v2` | `main` | Base branch — no code changes | ✅ created | | 1 | `feat/phase-0a-agent-source` | `feature/bauer-v2` | 001 Phase 0: T0.1, T0.2, T0.2a, T0.2b | ✅ done | -| 2 | `feat/phase-0b-artifacts-config` | `feat/phase-0a-agent-source` | 001 Phase 0: T0.2c, T0.3, T0.4, T0.5 | ⏳ pending | +| 2 | `feat/phase-0b-artifacts-config` | `feat/phase-0a-agent-source` | 001 Phase 0: T0.2c, T0.3, T0.4, T0.5 | ✅ done | | 3 | `feat/phase-1-cli-restore` | `feat/phase-0b-artifacts-config` | 001 Phase 1: T1.1, T1.2, T1.3 | ⏳ pending | | 4 | `feat/phase-2-cli-features` | `feat/phase-1-cli-restore` | 001 Phase 2: T2.1, T2.2, T2.3 | ⏳ pending | | 5 | `feat/figma-phase-b-client` | `feat/phase-2-cli-features` | 002 Phase B: T2F.0, T2F.1, T2F.2, T2F.3, T2F.4 | ⏳ pending | @@ -112,11 +112,37 @@ _Parent: `feat/phase-0a-agent-source`_ **Tasks:** T0.2c, T0.3, T0.4, T0.5 -**Summary:** _(to be filled by agent)_ +**Summary:** Added append-only artifact storage (`internal/artifacts`) that writes per-run directories with extraction results, prompts, outputs, and a `runs.jsonl` index. Introduced a layered config resolver (`internal/config/manager.go`) with `DefaultsSource`, `EnvVarSource`, and `FlagsSource`; `Config.PageRefresh` promoted to `*bool` to enable explicit-false override. Removed `json.go` and the `--config` flag; credentials are now supplied exclusively via flags or `BAUER_*` env vars. `BAUER_GITHUB_TOKEN` is now checked first in `GetGitHubToken`. Added `.env.example`, updated `.gitignore` (adds `config.json`, `*.pem`), and refreshed `Taskfile.yml` (removes `--config config.json` reference, adds `verify-figma` task). -**Files changed:** _(to be filled by agent)_ +**Files changed:** +- `internal/artifacts/manager.go` — new: `Manager`, `RunMetadata`, `RunIndexEntry`; `NewManager`, `NewRunID`, `StartRun`, `CompleteRun`, `WriteGDocsExtraction`, `WritePrompt`, `WriteOutput`, `WriteSummary`, `WriteIssueBody`, `EnsureScreenshotsDir` +- `internal/artifacts/manager_test.go` — new: tests for `NewRunID` format, `StartRun` directory structure, `CompleteRun` JSONL append +- `internal/config/config.go` — `PageRefresh bool→*bool`; new fields: `ArtifactsDir`, `BranchPrefix`, `FigmaURL`, `FigmaToken`, `GitHubRepo`, `OpenPR *bool`, `OpenIssue *bool`; `ApplyDefaults` uses `BoolVal(PageRefresh)` and sets `ArtifactsDir` default +- `internal/config/cli.go` — removed `ConfigFile` field and `--config` flag; added `ArtifactsDir` field and `--artifacts-dir` flag; `PageRefresh` now assigned as `*bool` pointer +- `internal/config/json.go` — deleted +- `internal/config/manager.go` — new: `Source` interface, `Resolver`, `mergeConfig`, `EnvVarSource`, `DefaultsSource`, `FlagsSource` +- `internal/config/manager_test.go` — new: tests for env override, zero-value non-override, bool pointer behaviour, credentials fallback chain +- `internal/config/config_test.go` — `PageRefresh: tt.pageRefreshFlag` → `BoolPtr(tt.pageRefreshFlag)` +- `internal/orchestrator/orchestrator.go` — accepts `*artifacts.Manager` in `New`; `Execute` calls `StartRun`/`WriteGDocsExtraction`/`WritePrompt`/`WriteOutput`/`WriteSummary`/`CompleteRun`; `cfg.PageRefresh` uses `BoolVal` +- `internal/github/auth.go` — `GetGitHubToken` checks `BAUER_GITHUB_TOKEN` before `GITHUB_TOKEN`/`GH_TOKEN` +- `cmd/bauer/main.go` — passes `artifacts.NewManager("")` to `orchestrator.New` +- `cmd/app/main.go` — passes `artifacts.NewManager(cfg.ArtifactsDir)` to `orchestrator.New` +- `cmd/app/types/config.go` — added `ArtifactsDir` field; removed `--config` flag and `LoadFromJSONFile` call; credentials env fallback added +- `cmd/app/v1/api.go` — `PageRefresh: config.BoolPtr(payload.PageRefresh)` +- `internal/workflow/workflow.go` — `PageRefresh: config.BoolPtr(input.PageRefresh)` +- `.gitignore` — added `config.json`, `*.pem` +- `.env.example` — new: full reference for all `BAUER_*` env vars +- `Taskfile.yml` — `run-server` uses `--credentials` flag; added `verify-figma` task; added `.env.example` comment + +**Config resolution priority (highest → lowest):** -**Docs / references:** _(to be filled by agent)_ +```mermaid +graph TD + A[FlagsSource
--flag values] -->|highest priority| M[Resolver.Resolve] + B[EnvVarSource
BAUER_* env vars] --> M + C[DefaultsSource
hardcoded fallbacks] -->|lowest priority| M + M --> D[Final Config] +``` --- diff --git a/internal/artifacts/manager.go b/internal/artifacts/manager.go new file mode 100644 index 0000000..b5810f5 --- /dev/null +++ b/internal/artifacts/manager.go @@ -0,0 +1,199 @@ +package artifacts + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// 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"` +} + +// 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"` +} + +// Manager handles append-only artifact storage for Bauer runs. +type Manager struct { + base string // root artifacts directory, e.g. "./bauer-artifacts" +} + +// NewManager creates a Manager for the given artifacts directory. +func NewManager(base string) *Manager { + if base == "" { + base = "./bauer-artifacts" + } + return &Manager{base: base} +} + +// NewRunID generates a unique run ID in the format YYYY-MM-DDTHH-MM-SSZ-{8hex}. +func NewRunID() string { + now := time.Now().UTC() + ts := now.Format("2006-01-02T15-04-05Z") + b := make([]byte, 4) + rand.Read(b) + return fmt.Sprintf("%s-%s", ts, hex.EncodeToString(b)) +} + +// StartRun creates the run directory structure and writes metadata.json with status "in_progress". +// Returns the runID for use in all subsequent calls. +func (m *Manager) StartRun(meta RunMetadata) (string, error) { + if meta.RunID == "" { + meta.RunID = NewRunID() + } + meta.Status = "in_progress" + if meta.StartedAt == "" { + meta.StartedAt = time.Now().UTC().Format(time.RFC3339) + } + if meta.ArtifactDir == "" { + meta.ArtifactDir = filepath.Join(m.base, meta.RunID) + } + + dirs := []string{ + filepath.Join(m.base, meta.RunID, "extraction"), + filepath.Join(m.base, meta.RunID, "prompts"), + filepath.Join(m.base, meta.RunID, "outputs"), + filepath.Join(m.base, meta.RunID, "logs"), + filepath.Join(m.base, meta.RunID, "screenshots"), + } + for _, d := range dirs { + if err := os.MkdirAll(d, 0o755); err != nil { + return "", fmt.Errorf("create run dir %s: %w", d, err) + } + } + + if err := m.writeJSON(meta.RunID, "metadata.json", meta); err != nil { + return "", err + } + + return meta.RunID, nil +} + +// CompleteRun updates metadata.json with the final status and appends to runs.jsonl. +func (m *Manager) CompleteRun(runID string, status string, chunkCount int) error { + // Read existing metadata + metaPath := filepath.Join(m.base, runID, "metadata.json") + data, err := os.ReadFile(metaPath) + if err != nil { + return fmt.Errorf("read metadata: %w", err) + } + var meta RunMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return fmt.Errorf("parse metadata: %w", err) + } + meta.Status = status + meta.CompletedAt = time.Now().UTC().Format(time.RFC3339) + meta.ChunkCount = chunkCount + + if err := m.writeJSON(runID, "metadata.json", meta); err != nil { + return err + } + + // 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, + } + line, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("marshal run index entry: %w", err) + } + + indexPath := filepath.Join(m.base, "runs.jsonl") + f, err := os.OpenFile(indexPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("open runs.jsonl: %w", err) + } + defer f.Close() + _, err = fmt.Fprintf(f, "%s\n", line) + return err +} + +// WriteGDocsExtraction writes the gdocs extraction result to extraction/gdocs.json. +func (m *Manager) WriteGDocsExtraction(runID string, data any) error { + return m.writeJSON(runID, "extraction/gdocs.json", data) +} + +// WritePrompt writes a chunk prompt to prompts/chunk-N-of-M.md. +func (m *Manager) WritePrompt(runID string, chunkNum, totalChunks int, content string) error { + filename := fmt.Sprintf("prompts/chunk-%d-of-%d.md", chunkNum, totalChunks) + return m.writeFile(runID, filename, []byte(content)) +} + +// WriteOutput writes a chunk output to outputs/chunk-N-output.md. +func (m *Manager) WriteOutput(runID string, chunkNum int, content string) error { + filename := fmt.Sprintf("outputs/chunk-%d-output.md", chunkNum) + return m.writeFile(runID, filename, []byte(content)) +} + +// WriteSummary writes the summary output to outputs/summary.md. +func (m *Manager) WriteSummary(runID string, content string) error { + return m.writeFile(runID, "outputs/summary.md", []byte(content)) +} + +// WriteIssueBody writes the formatted issue body to outputs/issue-body.md. +func (m *Manager) WriteIssueBody(runID string, content string) error { + return m.writeFile(runID, "outputs/issue-body.md", []byte(content)) +} + +// EnsureScreenshotsDir ensures the screenshots directory exists and returns its path. +func (m *Manager) EnsureScreenshotsDir(runID string) (string, error) { + dir := filepath.Join(m.base, runID, "screenshots") + return dir, os.MkdirAll(dir, 0o755) +} + +// RunDir returns the path to the run's directory. +func (m *Manager) RunDir(runID string) string { + return filepath.Join(m.base, runID) +} + +// Base returns the root artifacts directory. +func (m *Manager) Base() string { + return m.base +} + +func (m *Manager) writeJSON(runID, relPath string, data any) error { + b, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("marshal %s: %w", relPath, err) + } + return m.writeFile(runID, relPath, b) +} + +func (m *Manager) writeFile(runID, relPath string, data []byte) error { + fullPath := filepath.Join(m.base, runID, relPath) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + return fmt.Errorf("mkdir for %s: %w", relPath, err) + } + return os.WriteFile(fullPath, data, 0o644) +} diff --git a/internal/artifacts/manager_test.go b/internal/artifacts/manager_test.go new file mode 100644 index 0000000..0aab2d9 --- /dev/null +++ b/internal/artifacts/manager_test.go @@ -0,0 +1,162 @@ +package artifacts_test + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "testing" + + "bauer/internal/artifacts" +) + +func TestNewRunID_Format(t *testing.T) { + id := artifacts.NewRunID() + // Format: YYYY-MM-DDTHH-MM-SSZ-{8hex} + re := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z-[0-9a-f]{8}$`) + if !re.MatchString(id) { + t.Errorf("NewRunID() = %q, does not match expected format YYYY-MM-DDTHH-MM-SSZ-{8hex}", id) + } +} + +func TestNewRunID_Unique(t *testing.T) { + ids := make(map[string]bool) + for i := 0; i < 10; i++ { + id := artifacts.NewRunID() + if ids[id] { + t.Errorf("NewRunID() produced duplicate ID: %q", id) + } + ids[id] = true + } +} + +func TestStartRun_DirectoryStructure(t *testing.T) { + tmpDir := t.TempDir() + mgr := artifacts.NewManager(tmpDir) + + runID, err := mgr.StartRun(artifacts.RunMetadata{ + DocID: "test-doc-123", + Mode: "dry-run", + }) + if err != nil { + t.Fatalf("StartRun() error = %v", err) + } + if runID == "" { + t.Fatal("StartRun() returned empty runID") + } + + // Check expected subdirectories + for _, subdir := range []string{"extraction", "prompts", "outputs", "logs", "screenshots"} { + dir := filepath.Join(tmpDir, runID, subdir) + info, err := os.Stat(dir) + if err != nil { + t.Errorf("expected directory %s to exist: %v", dir, err) + continue + } + if !info.IsDir() { + t.Errorf("expected %s to be a directory", dir) + } + } + + // Check metadata.json exists with correct status + metaPath := filepath.Join(tmpDir, runID, "metadata.json") + data, err := os.ReadFile(metaPath) + if err != nil { + t.Fatalf("metadata.json not found: %v", err) + } + var meta artifacts.RunMetadata + if err := json.Unmarshal(data, &meta); err != nil { + t.Fatalf("failed to parse metadata.json: %v", err) + } + if meta.Status != "in_progress" { + t.Errorf("metadata.Status = %q, want %q", meta.Status, "in_progress") + } + if meta.DocID != "test-doc-123" { + t.Errorf("metadata.DocID = %q, want %q", meta.DocID, "test-doc-123") + } + if meta.RunID != runID { + t.Errorf("metadata.RunID = %q, want %q", meta.RunID, runID) + } +} + +func TestCompleteRun_AppendsToJSONL(t *testing.T) { + tmpDir := t.TempDir() + mgr := artifacts.NewManager(tmpDir) + + // Start two runs + runID1, err := mgr.StartRun(artifacts.RunMetadata{DocID: "doc-1", Mode: "execute"}) + if err != nil { + t.Fatalf("StartRun #1 error = %v", err) + } + runID2, err := mgr.StartRun(artifacts.RunMetadata{DocID: "doc-2", Mode: "dry-run"}) + if err != nil { + t.Fatalf("StartRun #2 error = %v", err) + } + + // Complete both + if err := mgr.CompleteRun(runID1, "success", 3); err != nil { + t.Fatalf("CompleteRun #1 error = %v", err) + } + if err := mgr.CompleteRun(runID2, "failed", 1); err != nil { + t.Fatalf("CompleteRun #2 error = %v", err) + } + + // Read runs.jsonl + indexPath := filepath.Join(tmpDir, "runs.jsonl") + data, err := os.ReadFile(indexPath) + if err != nil { + t.Fatalf("runs.jsonl not found: %v", err) + } + + lines := splitLines(data) + if len(lines) != 2 { + t.Fatalf("runs.jsonl has %d lines, want 2", len(lines)) + } + + var entry1 artifacts.RunIndexEntry + if err := json.Unmarshal([]byte(lines[0]), &entry1); err != nil { + t.Fatalf("failed to parse line 1: %v", err) + } + if entry1.RunID != runID1 { + t.Errorf("entry1.RunID = %q, want %q", entry1.RunID, runID1) + } + if entry1.Status != "success" { + t.Errorf("entry1.Status = %q, want %q", entry1.Status, "success") + } + if entry1.ChunkCount != 3 { + t.Errorf("entry1.ChunkCount = %d, want 3", entry1.ChunkCount) + } + + var entry2 artifacts.RunIndexEntry + if err := json.Unmarshal([]byte(lines[1]), &entry2); err != nil { + t.Fatalf("failed to parse line 2: %v", err) + } + if entry2.RunID != runID2 { + t.Errorf("entry2.RunID = %q, want %q", entry2.RunID, runID2) + } + if entry2.Status != "failed" { + t.Errorf("entry2.Status = %q, want %q", entry2.Status, "failed") + } +} + +// splitLines splits byte data into non-empty lines. +func splitLines(data []byte) []string { + var lines []string + start := 0 + for i, b := range data { + if b == '\n' { + line := string(data[start:i]) + if line != "" { + lines = append(lines, line) + } + start = i + 1 + } + } + if start < len(data) { + line := string(data[start:]) + if line != "" { + lines = append(lines, line) + } + } + return lines +} diff --git a/internal/config/cli.go b/internal/config/cli.go index 570e604..de46235 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -10,7 +10,6 @@ import ( type CLIFlags struct { DocID string CredentialsPath string - ConfigFile string DryRun *bool ChunkSize int PageRefresh *bool @@ -18,6 +17,7 @@ type CLIFlags struct { Model string SummaryModel string TargetRepo string + ArtifactsDir string } // Load parses command-line flags and returns a validated Config. @@ -30,7 +30,6 @@ func Load() (*Config, error) { docID := flag.String("doc-id", "", "Google Doc ID to extract feedback from (required)") credentialsPath := flag.String("credentials", "", "Path to service account JSON (required)") - configFile := flag.String("config", "", "Path to JSON config file") dryRun := flag.Bool("dry-run", false, "Run extraction and planning only; skip Copilot and PR creation") chunkSize := flag.Int("chunk-size", 0, "Total number of chunks to create (default: 1, or 5 if --page-refresh is set)") pageRefresh := flag.Bool("page-refresh", false, "Use page refresh mode with page-refresh-instructions template (default chunk size: 5)") @@ -38,6 +37,7 @@ func Load() (*Config, error) { model := flag.String("model", "gpt-5-mini-high", "Copilot model to use for sessions (default: gpt-5-mini-high)") summaryModel := flag.String("summary-model", "gpt-5-mini-high", "Copilot model to use for summary session (default: gpt-5-mini-high)") targetRepo := flag.String("target-repo", "", "Path to target repository where tasks should be executed (default: current directory)") + artifactsDir := flag.String("artifacts-dir", "", "Directory for run artifacts (default: ./bauer-artifacts)") // Custom usage message flag.Usage = func() { @@ -51,13 +51,13 @@ func Load() (*Config, error) { typ string desc string }{ - {"--config", "", "Path to JSON config file"}, {"--doc-id", "", "Google Doc ID to extract feedback from (required)"}, {"--credentials", "", "Path to service account JSON (required)"}, {"--dry-run", "", "Run extraction and planning only; skip Copilot and PR creation"}, {"--page-refresh", "", "Use page refresh mode with page-refresh-instructions template"}, {"--chunk-size", "", "Total number of chunks to create (default: 1, or 5 if --page-refresh is set)"}, {"--output-dir", "", "Directory for generated prompt files (default: bauer-output)"}, + {"--artifacts-dir", "", "Directory for run artifacts (default: ./bauer-artifacts)"}, {"--model", "", "Copilot model to use for sessions (default: gpt-5-mini-high)"}, {"--summary-model", "", "Copilot model to use for summary session (default: gpt-5-mini-high)"}, {"--target-repo", "", "Path to target repository where tasks should be executed (default: current directory)"}, @@ -76,11 +76,6 @@ func Load() (*Config, error) { flag.Parse() - // If --config is provided, load from JSON file - if *configFile != "" { - return LoadFromJSONFile(*configFile) - } - // If no required flags are provided, show usage and exit if *docID == "" && *credentialsPath == "" { flag.Usage() @@ -92,11 +87,12 @@ func Load() (*Config, error) { CredentialsPath: *credentialsPath, DryRun: dryRun, ChunkSize: *chunkSize, - PageRefresh: *pageRefresh, + PageRefresh: pageRefresh, OutputDir: *outputDir, Model: *model, SummaryModel: *summaryModel, TargetRepo: *targetRepo, + ArtifactsDir: *artifactsDir, } if err := cfg.Validate(); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 468638d..9c8ec4d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,7 +25,8 @@ type Config struct { // PageRefresh indicates if the page refresh mode should be used. // When true, uses page-refresh-instructions.md template and defaults ChunkSize to 5. - PageRefresh bool `json:"page_refresh"` + // Uses *bool so that an explicit false from CLI flags can override a true from defaults. + PageRefresh *bool `json:"page_refresh,omitempty"` // OutputDir is the directory where generated prompt files will be saved. // Default is "bauer-output" if not specified. @@ -42,12 +43,34 @@ type Config struct { // TargetRepo is the path (relative or absolute) to the target repository // where tasks should be executed. If not specified, uses the current directory. TargetRepo string `json:"target_repo"` + + // ArtifactsDir is the directory for run artifacts. Defaults to "./bauer-artifacts". + // Overridden by --artifacts-dir flag or BAUER_ARTIFACTS_DIR env var. + ArtifactsDir string `json:"artifacts_dir,omitempty"` + + // BranchPrefix is the prefix used when creating branches. Defaults to "bauer". + BranchPrefix string `json:"branch_prefix,omitempty"` + + // FigmaURL is the Figma file URL for the design reference. + FigmaURL string `json:"figma_url,omitempty"` + + // FigmaToken is the Figma API token. Overridden by BAUER_FIGMA_TOKEN env var. + FigmaToken string `json:"figma_token,omitempty"` + + // GitHubRepo is the GitHub repository in owner/repo format. + GitHubRepo string `json:"github_repo,omitempty"` + + // OpenPR controls whether a pull request is opened after applying changes. + OpenPR *bool `json:"open_pr,omitempty"` + + // OpenIssue controls whether a GitHub issue is opened instead of a PR. + OpenIssue *bool `json:"open_issue,omitempty"` } // Apply default config values func (c *Config) ApplyDefaults() { if c.ChunkSize == 0 { - if c.PageRefresh { + if BoolVal(c.PageRefresh, false) { c.ChunkSize = 5 } else { c.ChunkSize = 1 @@ -62,6 +85,9 @@ func (c *Config) ApplyDefaults() { if c.SummaryModel == "" { c.SummaryModel = "gpt-5-mini-high" } + if c.ArtifactsDir == "" { + c.ArtifactsDir = "./bauer-artifacts" + } } // Validate checks if the configuration is valid. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dfe31ed..ba02264 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -186,7 +186,7 @@ func TestChunkSizeDefaults(t *testing.T) { DocID: "test-doc-id", CredentialsPath: validCredsFile, ChunkSize: effectiveChunkSize, - PageRefresh: tt.pageRefreshFlag, + PageRefresh: BoolPtr(tt.pageRefreshFlag), OutputDir: "bauer-output", Model: "gpt-5-mini-high", SummaryModel: "gpt-5-mini-high", diff --git a/internal/config/json.go b/internal/config/json.go deleted file mode 100644 index acfdb2f..0000000 --- a/internal/config/json.go +++ /dev/null @@ -1,26 +0,0 @@ -package config - -import ( - "encoding/json" - "os" -) - -func LoadFromJSON(data []byte) (*Config, error) { - var cfg Config - if err := json.Unmarshal(data, &cfg); err != nil { - return nil, err - } - if err := cfg.Validate(); err != nil { - return nil, err - } - - return &cfg, nil -} - -func LoadFromJSONFile(filePath string) (*Config, error) { - data, err := os.ReadFile(filePath) - if err != nil { - return nil, err - } - return LoadFromJSON(data) -} diff --git a/internal/config/manager.go b/internal/config/manager.go new file mode 100644 index 0000000..bcebb43 --- /dev/null +++ b/internal/config/manager.go @@ -0,0 +1,140 @@ +package config + +import ( + "fmt" + "os" + "strconv" +) + +// Source provides a partial Config from a single input. +// Fields not provided by this source should be zero-valued. +type Source interface { + Load() (*Config, error) +} + +// Resolver merges multiple Sources in priority order. +// First source listed = highest priority. +type Resolver struct { + sources []Source +} + +// NewResolver creates a Resolver. List sources highest-priority first. +func NewResolver(sources ...Source) *Resolver { + return &Resolver{sources: sources} +} + +// Resolve merges all sources and returns the final Config. +func (r *Resolver) Resolve() (*Config, error) { + result := &Config{} + // Apply sources lowest-priority first, higher priority overwrites + for i := len(r.sources) - 1; i >= 0; i-- { + partial, err := r.sources[i].Load() + if err != nil { + return nil, fmt.Errorf("config source %d: %w", i, err) + } + mergeConfig(result, partial) + } + return result, nil +} + +func mergeConfig(base, override *Config) { + if override.DocID != "" { base.DocID = override.DocID } + if override.CredentialsPath != "" { base.CredentialsPath = override.CredentialsPath } + if override.Model != "" { base.Model = override.Model } + if override.SummaryModel != "" { base.SummaryModel = override.SummaryModel } + if override.ArtifactsDir != "" { base.ArtifactsDir = override.ArtifactsDir } + if override.BranchPrefix != "" { base.BranchPrefix = override.BranchPrefix } + if override.ChunkSize != 0 { base.ChunkSize = override.ChunkSize } + if override.GitHubRepo != "" { base.GitHubRepo = override.GitHubRepo } + if override.FigmaURL != "" { base.FigmaURL = override.FigmaURL } + if override.FigmaToken != "" { base.FigmaToken = override.FigmaToken } + if override.OutputDir != "" { base.OutputDir = override.OutputDir } + if override.TargetRepo != "" { base.TargetRepo = override.TargetRepo } + if override.PageRefresh != nil { base.PageRefresh = override.PageRefresh } + if override.DryRun != nil { base.DryRun = override.DryRun } + if override.OpenPR != nil { base.OpenPR = override.OpenPR } + if override.OpenIssue != nil { base.OpenIssue = override.OpenIssue } +} + +// EnvVarSource reads BAUER_* env vars. +type EnvVarSource struct{} + +func NewEnvVarSource() *EnvVarSource { return &EnvVarSource{} } + +func (e *EnvVarSource) Load() (*Config, error) { + cfg := &Config{} + // Credentials — check BAUER_CREDENTIALS_PATH, then GOOGLE_APPLICATION_CREDENTIALS + if v := os.Getenv("BAUER_CREDENTIALS_PATH"); v != "" { + cfg.CredentialsPath = v + } else if v := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"); v != "" { + cfg.CredentialsPath = v + } + cfg.DocID = os.Getenv("BAUER_DOC_ID") + cfg.Model = os.Getenv("BAUER_MODEL") + cfg.SummaryModel = os.Getenv("BAUER_SUMMARY_MODEL") + cfg.ArtifactsDir = os.Getenv("BAUER_ARTIFACTS_DIR") + cfg.BranchPrefix = os.Getenv("BAUER_BRANCH_PREFIX") + cfg.GitHubRepo = os.Getenv("BAUER_GITHUB_REPO") + cfg.FigmaURL = os.Getenv("BAUER_FIGMA_URL") + cfg.FigmaToken = os.Getenv("BAUER_FIGMA_TOKEN") + if cfg.FigmaToken == "" { + cfg.FigmaToken = os.Getenv("FIGMA_TOKEN") + } + if v := os.Getenv("BAUER_CHUNK_SIZE"); v != "" { + cfg.ChunkSize, _ = strconv.Atoi(v) + } + if v := os.Getenv("BAUER_PAGE_REFRESH"); v != "" { + b := v == "true" + cfg.PageRefresh = &b + } + if v := os.Getenv("BAUER_DRY_RUN"); v != "" { + b := v == "true" + cfg.DryRun = &b + } + return cfg, nil +} + +// DefaultsSource provides hardcoded fallback values. +type DefaultsSource struct{} + +func NewDefaultsSource() *DefaultsSource { return &DefaultsSource{} } + +func (d *DefaultsSource) Load() (*Config, error) { + return &Config{ + Model: "gpt-5-mini-high", + SummaryModel: "gpt-5-mini-high", + ChunkSize: 1, + ArtifactsDir: "./bauer-artifacts", + BranchPrefix: "bauer", + PageRefresh: BoolPtr(false), + DryRun: BoolPtr(false), + OpenPR: BoolPtr(false), + OpenIssue: BoolPtr(false), + }, nil +} + +// FlagsSource converts a CLIFlags struct into a Config. +// Only non-zero values are included so they properly override lower-priority sources. +type FlagsSource struct { + flags CLIFlags +} + +// NewFlagsSource creates a FlagsSource from the given CLI flags. +func NewFlagsSource(flags CLIFlags) *FlagsSource { + return &FlagsSource{flags: flags} +} + +func (f *FlagsSource) Load() (*Config, error) { + return &Config{ + DocID: f.flags.DocID, + CredentialsPath: f.flags.CredentialsPath, + DryRun: f.flags.DryRun, + ChunkSize: f.flags.ChunkSize, + PageRefresh: f.flags.PageRefresh, + OutputDir: f.flags.OutputDir, + Model: f.flags.Model, + SummaryModel: f.flags.SummaryModel, + TargetRepo: f.flags.TargetRepo, + ArtifactsDir: f.flags.ArtifactsDir, + }, nil +} diff --git a/internal/config/manager_test.go b/internal/config/manager_test.go new file mode 100644 index 0000000..2bbc0ab --- /dev/null +++ b/internal/config/manager_test.go @@ -0,0 +1,166 @@ +package config_test + +import ( + "os" + "testing" + + "bauer/internal/config" +) + +func TestEnvVarSource_OverridesDefault(t *testing.T) { + t.Setenv("BAUER_DOC_ID", "env-doc-id") + t.Setenv("BAUER_MODEL", "env-model") + t.Setenv("BAUER_ARTIFACTS_DIR", "/tmp/env-artifacts") + + resolver := config.NewResolver( + config.NewEnvVarSource(), + config.NewDefaultsSource(), + ) + cfg, err := resolver.Resolve() + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + + if cfg.DocID != "env-doc-id" { + t.Errorf("DocID = %q, want %q", cfg.DocID, "env-doc-id") + } + if cfg.Model != "env-model" { + t.Errorf("Model = %q, want %q", cfg.Model, "env-model") + } + if cfg.ArtifactsDir != "/tmp/env-artifacts" { + t.Errorf("ArtifactsDir = %q, want %q", cfg.ArtifactsDir, "/tmp/env-artifacts") + } + // Default should still apply for unset fields + if cfg.SummaryModel != "gpt-5-mini-high" { + t.Errorf("SummaryModel = %q, want default %q", cfg.SummaryModel, "gpt-5-mini-high") + } +} + +func TestEnvVarSource_ZeroValueDoesNotOverride(t *testing.T) { + // Clear any env vars that might interfere + os.Unsetenv("BAUER_DOC_ID") + os.Unsetenv("BAUER_MODEL") + + resolver := config.NewResolver( + config.NewEnvVarSource(), + config.NewDefaultsSource(), + ) + cfg, err := resolver.Resolve() + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + + // Default model should not be overridden by empty env var + if cfg.Model != "gpt-5-mini-high" { + t.Errorf("Model = %q, want %q", cfg.Model, "gpt-5-mini-high") + } + if cfg.ChunkSize != 1 { + t.Errorf("ChunkSize = %d, want 1", cfg.ChunkSize) + } +} + +func TestEnvVarSource_BooleanFields(t *testing.T) { + t.Setenv("BAUER_DRY_RUN", "true") + t.Setenv("BAUER_PAGE_REFRESH", "false") + + src := config.NewEnvVarSource() + cfg, err := src.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.DryRun == nil { + t.Fatal("DryRun should not be nil when BAUER_DRY_RUN is set") + } + if !*cfg.DryRun { + t.Errorf("DryRun = %v, want true", *cfg.DryRun) + } + + if cfg.PageRefresh == nil { + t.Fatal("PageRefresh should not be nil when BAUER_PAGE_REFRESH is set") + } + if *cfg.PageRefresh { + t.Errorf("PageRefresh = %v, want false", *cfg.PageRefresh) + } +} + +func TestDefaultsSource_ProvidesSaneDefaults(t *testing.T) { + src := config.NewDefaultsSource() + cfg, err := src.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Model != "gpt-5-mini-high" { + t.Errorf("Model = %q, want %q", cfg.Model, "gpt-5-mini-high") + } + if cfg.ChunkSize != 1 { + t.Errorf("ChunkSize = %d, want 1", cfg.ChunkSize) + } + if cfg.ArtifactsDir != "./bauer-artifacts" { + t.Errorf("ArtifactsDir = %q, want %q", cfg.ArtifactsDir, "./bauer-artifacts") + } + if cfg.DryRun == nil || *cfg.DryRun { + t.Errorf("DryRun should be *false, got %v", cfg.DryRun) + } + if cfg.PageRefresh == nil || *cfg.PageRefresh { + t.Errorf("PageRefresh should be *false, got %v", cfg.PageRefresh) + } +} + +func TestFlagsSource_OverridesEnvAndDefaults(t *testing.T) { + t.Setenv("BAUER_DOC_ID", "env-doc") + t.Setenv("BAUER_MODEL", "env-model") + + flags := config.CLIFlags{ + DocID: "flag-doc", + Model: "flag-model", + } + + resolver := config.NewResolver( + config.NewFlagsSource(flags), + config.NewEnvVarSource(), + config.NewDefaultsSource(), + ) + cfg, err := resolver.Resolve() + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + + if cfg.DocID != "flag-doc" { + t.Errorf("DocID = %q, want %q", cfg.DocID, "flag-doc") + } + if cfg.Model != "flag-model" { + t.Errorf("Model = %q, want %q", cfg.Model, "flag-model") + } +} + +func TestResolver_CredentialsFallback(t *testing.T) { + os.Unsetenv("BAUER_CREDENTIALS_PATH") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/gac/creds.json") + + src := config.NewEnvVarSource() + cfg, err := src.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.CredentialsPath != "/gac/creds.json" { + t.Errorf("CredentialsPath = %q, want %q", cfg.CredentialsPath, "/gac/creds.json") + } +} + +func TestResolver_BAUERCredentialsPathTakesPriority(t *testing.T) { + t.Setenv("BAUER_CREDENTIALS_PATH", "/bauer/creds.json") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/gac/creds.json") + + src := config.NewEnvVarSource() + cfg, err := src.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.CredentialsPath != "/bauer/creds.json" { + t.Errorf("CredentialsPath = %q, want BAUER_CREDENTIALS_PATH to win", cfg.CredentialsPath) + } +} diff --git a/internal/github/auth.go b/internal/github/auth.go index 1cb6320..57258bc 100644 --- a/internal/github/auth.go +++ b/internal/github/auth.go @@ -9,11 +9,10 @@ import ( // GetGitHubToken retrieves a GitHub token from environment variables or gh CLI func GetGitHubToken() (string, error) { - if token := os.Getenv("GITHUB_TOKEN"); token != "" { - return token, nil - } - if token := os.Getenv("GH_TOKEN"); token != "" { - return token, nil + for _, env := range []string{"BAUER_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"} { + if v := os.Getenv(env); v != "" { + return v, nil + } } // Get token from gh CLI config diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 1f85ccd..66b119a 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -2,11 +2,11 @@ package orchestrator import ( "bauer/internal/agent" + "bauer/internal/artifacts" "bauer/internal/config" "bauer/internal/prompt" "bauer/internal/source" "context" - "encoding/json" "fmt" "log/slog" "os" @@ -50,17 +50,45 @@ type Orchestrator interface { type DefaultOrchestrator struct { agent agent.Agent sources *source.Manager + arts *artifacts.Manager } -// New creates a new DefaultOrchestrator with the given agent and source manager. -func New(a agent.Agent, sources *source.Manager) *DefaultOrchestrator { - return &DefaultOrchestrator{agent: a, sources: sources} +// New creates a new DefaultOrchestrator with the given agent, source manager, and artifact manager. +func New(a agent.Agent, sources *source.Manager, arts *artifacts.Manager) *DefaultOrchestrator { + return &DefaultOrchestrator{agent: a, sources: sources, arts: arts} } // Execute runs the full pipeline: extraction, prompt generation, and optional agent execution. func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) (*OrchestrationResult, error) { startTime := time.Now() + // Determine run mode + mode := "execute" + if config.BoolVal(cfg.DryRun, false) { + mode = "dry-run" + } + + // Start artifact run + runID, err := o.arts.StartRun(artifacts.RunMetadata{ + DocID: cfg.DocID, + FigmaURL: cfg.FigmaURL, + Mode: mode, + }) + if err != nil { + slog.Warn("Failed to start artifact run", slog.String("error", err.Error())) + // Non-fatal: continue without artifacts + runID = "" + } + + completeRun := func(status string, chunkCount int) { + if runID == "" { + return + } + if err := o.arts.CompleteRun(runID, status, chunkCount); err != nil { + slog.Warn("Failed to complete artifact run", slog.String("error", err.Error())) + } + } + // 1. Fetch from source (Google Docs) extractionStart := time.Now() bundle, err := o.sources.Fetch(ctx, source.Request{DocID: cfg.DocID}) @@ -69,38 +97,35 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( slog.String("error", err.Error()), slog.String("doc_id", cfg.DocID), ) + completeRun("failed", 0) return nil, fmt.Errorf("failed to fetch from source: %w", err) } extractionDuration := time.Since(extractionStart) - // 2. Write extraction result to file + // 2. Write extraction result to artifact store if bundle.Document != nil { - outputJSON, err := json.MarshalIndent(bundle.Document, "", " ") - if err != nil { - slog.Error("Failed to marshal output", slog.String("error", err.Error())) - return nil, fmt.Errorf("failed to generate output JSON: %w", err) - } - outputFile := "bauer-doc-suggestions.json" - if err = os.WriteFile(outputFile, outputJSON, 0644); err != nil { - slog.Error("Failed to write output file", slog.String("error", err.Error())) - return nil, fmt.Errorf("failed to write output file: %w", err) + if runID != "" { + if err := o.arts.WriteGDocsExtraction(runID, bundle.Document); err != nil { + slog.Warn("Failed to write gdocs extraction artifact", slog.String("error", err.Error())) + } } slog.Info("Extraction complete", - slog.String("output_file", outputFile), slog.Duration("extraction_duration", extractionDuration), ) } // 3. Initialize Prompt Engine planStart := time.Now() - engine, err := prompt.NewEngine(cfg.PageRefresh) + engine, err := prompt.NewEngine(config.BoolVal(cfg.PageRefresh, false)) if err != nil { slog.Error("Failed to initialize prompt engine", slog.String("error", err.Error())) + completeRun("failed", 0) return nil, fmt.Errorf("failed to initialize prompt engine: %w", err) } // 4. Generate Prompts from Chunks if bundle.Document == nil { + completeRun("failed", 0) return nil, fmt.Errorf("no document available: DocID may be empty") } @@ -116,11 +141,24 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( ) if err != nil { slog.Error("Failed to generate prompts", slog.String("error", err.Error())) + completeRun("failed", 0) return nil, fmt.Errorf("failed to generate prompts: %w", err) } planDuration := time.Since(planStart) + // Write prompts to artifact store + if runID != "" { + for _, chunk := range chunks { + // Read the generated prompt file and archive it + if data, readErr := os.ReadFile(chunk.Filename); readErr == nil { + if writeErr := o.arts.WritePrompt(runID, chunk.ChunkNumber, len(chunks), string(data)); writeErr != nil { + slog.Warn("Failed to write prompt artifact", slog.String("error", writeErr.Error())) + } + } + } + } + for _, chunk := range chunks { slog.Info("Generated chunk", slog.Int("chunk_number", chunk.ChunkNumber), @@ -132,6 +170,7 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( // If dry run, return early if config.BoolVal(cfg.DryRun, false) { totalDuration := time.Since(startTime) + completeRun("success", len(chunks)) return &OrchestrationResult{ ExtractionBundle: bundle, ExtractionDuration: extractionDuration, @@ -164,9 +203,19 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( chunkOutputs, copilotDuration, err := executeAgentChunks(ctx, chunks, cfg, o.agent) if err != nil { slog.Error("Agent execution failed", slog.String("error", err.Error())) + completeRun("failed", len(chunks)) return nil, fmt.Errorf("agent execution failed: %w", err) } + // Write chunk outputs to artifact store + if runID != "" { + for _, co := range chunkOutputs { + if writeErr := o.arts.WriteOutput(runID, co.ChunkNumber, co.Output); writeErr != nil { + slog.Warn("Failed to write chunk output artifact", slog.String("error", writeErr.Error())) + } + } + } + slog.Info("Agent chunks executed", slog.Int("chunk_count", len(chunks)), slog.Duration("total_duration", copilotDuration), @@ -193,10 +242,16 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( slog.Info("Summary generated successfully", slog.Duration("duration", summaryDuration), ) + if runID != "" { + if writeErr := o.arts.WriteSummary(runID, summary); writeErr != nil { + slog.Warn("Failed to write summary artifact", slog.String("error", writeErr.Error())) + } + } } } totalDuration := time.Since(startTime) + completeRun("success", len(chunks)) return &OrchestrationResult{ ExtractionBundle: bundle, diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index c555349..3ae2b0b 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -164,7 +164,7 @@ func ExecuteWorkflow(ctx context.Context, input WorkflowInput, orch orchestrator CredentialsPath: credentialsPath, // Use absolute path DryRun: config.BoolPtr(input.DryRun), ChunkSize: input.ChunkSize, - PageRefresh: input.PageRefresh, + PageRefresh: config.BoolPtr(input.PageRefresh), OutputDir: input.OutputDir, Model: input.Model, TargetRepo: ".", // Current directory is the cloned repo From 9530e950fe6f457e41c8ecc594f68f5db17f22e9 Mon Sep 17 00:00:00 2001 From: Bauer Agent Date: Tue, 26 May 2026 11:02:50 +0300 Subject: [PATCH 4/5] fix: address PR review comments (config parsing, artifacts robustness, test hygiene) --- Taskfile.yml | 2 +- cmd/app/main.go | 1 - cmd/app/v1/api.go | 3 +- internal/artifacts/manager.go | 23 ++++++-- internal/artifacts/manager_test.go | 10 +++- internal/config/manager.go | 90 ++++++++++++++++++++++-------- internal/config/manager_test.go | 9 ++- internal/github/pr.go | 4 +- prompt.md | 20 +++++++ 9 files changed, 119 insertions(+), 43 deletions(-) create mode 100644 prompt.md diff --git a/Taskfile.yml b/Taskfile.yml index 50bb40c..62c5efd 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -26,7 +26,7 @@ tasks: - ./bauer-api --credentials ${BAUER_CREDENTIALS_PATH} verify-figma: - desc: Verify Figma token is set and reachable + desc: Verify Figma token is set cmds: - | if [ -z "$BAUER_FIGMA_TOKEN" ] && [ -z "$FIGMA_TOKEN" ]; then diff --git a/cmd/app/main.go b/cmd/app/main.go index b8cf8bd..003f169 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -71,4 +71,3 @@ func main() { os.Exit(1) } } - diff --git a/cmd/app/v1/api.go b/cmd/app/v1/api.go index 6f1f725..cd8a1e2 100644 --- a/cmd/app/v1/api.go +++ b/cmd/app/v1/api.go @@ -83,7 +83,6 @@ func executeJob(requestID string, cfg config.Config, rc types.RouteConfig) { ) } - func GetHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -91,4 +90,4 @@ func GetHealth(w http.ResponseWriter, r *http.Request) { if err != nil { slog.Error("error writing response", "error", err.Error()) } -} \ No newline at end of file +} diff --git a/internal/artifacts/manager.go b/internal/artifacts/manager.go index b5810f5..cca5b83 100644 --- a/internal/artifacts/manager.go +++ b/internal/artifacts/manager.go @@ -46,23 +46,32 @@ func NewManager(base string) *Manager { if base == "" { base = "./bauer-artifacts" } + if abs, err := filepath.Abs(base); err == nil { + base = abs + } return &Manager{base: base} } // NewRunID generates a unique run ID in the format YYYY-MM-DDTHH-MM-SSZ-{8hex}. -func NewRunID() string { +func NewRunID() (string, error) { now := time.Now().UTC() ts := now.Format("2006-01-02T15-04-05Z") b := make([]byte, 4) - rand.Read(b) - return fmt.Sprintf("%s-%s", ts, hex.EncodeToString(b)) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate run ID: %w", err) + } + return fmt.Sprintf("%s-%s", ts, hex.EncodeToString(b)), nil } // StartRun creates the run directory structure and writes metadata.json with status "in_progress". // Returns the runID for use in all subsequent calls. func (m *Manager) StartRun(meta RunMetadata) (string, error) { if meta.RunID == "" { - meta.RunID = NewRunID() + id, err := NewRunID() + if err != nil { + return "", err + } + meta.RunID = id } meta.Status = "in_progress" if meta.StartedAt == "" { @@ -135,8 +144,10 @@ func (m *Manager) CompleteRun(runID string, status string, chunkCount int) error return fmt.Errorf("open runs.jsonl: %w", err) } defer f.Close() - _, err = fmt.Fprintf(f, "%s\n", line) - return err + if _, err = fmt.Fprintf(f, "%s\n", line); err != nil { + return fmt.Errorf("write run index: %w", err) + } + return nil } // WriteGDocsExtraction writes the gdocs extraction result to extraction/gdocs.json. diff --git a/internal/artifacts/manager_test.go b/internal/artifacts/manager_test.go index 0aab2d9..db9e54a 100644 --- a/internal/artifacts/manager_test.go +++ b/internal/artifacts/manager_test.go @@ -11,7 +11,10 @@ import ( ) func TestNewRunID_Format(t *testing.T) { - id := artifacts.NewRunID() + id, err := artifacts.NewRunID() + if err != nil { + t.Fatalf("NewRunID() error = %v", err) + } // Format: YYYY-MM-DDTHH-MM-SSZ-{8hex} re := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z-[0-9a-f]{8}$`) if !re.MatchString(id) { @@ -22,7 +25,10 @@ func TestNewRunID_Format(t *testing.T) { func TestNewRunID_Unique(t *testing.T) { ids := make(map[string]bool) for i := 0; i < 10; i++ { - id := artifacts.NewRunID() + id, err := artifacts.NewRunID() + if err != nil { + t.Fatalf("NewRunID() error = %v", err) + } if ids[id] { t.Errorf("NewRunID() produced duplicate ID: %q", id) } diff --git a/internal/config/manager.go b/internal/config/manager.go index bcebb43..b3bfea7 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -38,22 +38,54 @@ func (r *Resolver) Resolve() (*Config, error) { } func mergeConfig(base, override *Config) { - if override.DocID != "" { base.DocID = override.DocID } - if override.CredentialsPath != "" { base.CredentialsPath = override.CredentialsPath } - if override.Model != "" { base.Model = override.Model } - if override.SummaryModel != "" { base.SummaryModel = override.SummaryModel } - if override.ArtifactsDir != "" { base.ArtifactsDir = override.ArtifactsDir } - if override.BranchPrefix != "" { base.BranchPrefix = override.BranchPrefix } - if override.ChunkSize != 0 { base.ChunkSize = override.ChunkSize } - if override.GitHubRepo != "" { base.GitHubRepo = override.GitHubRepo } - if override.FigmaURL != "" { base.FigmaURL = override.FigmaURL } - if override.FigmaToken != "" { base.FigmaToken = override.FigmaToken } - if override.OutputDir != "" { base.OutputDir = override.OutputDir } - if override.TargetRepo != "" { base.TargetRepo = override.TargetRepo } - if override.PageRefresh != nil { base.PageRefresh = override.PageRefresh } - if override.DryRun != nil { base.DryRun = override.DryRun } - if override.OpenPR != nil { base.OpenPR = override.OpenPR } - if override.OpenIssue != nil { base.OpenIssue = override.OpenIssue } + if override.DocID != "" { + base.DocID = override.DocID + } + if override.CredentialsPath != "" { + base.CredentialsPath = override.CredentialsPath + } + if override.Model != "" { + base.Model = override.Model + } + if override.SummaryModel != "" { + base.SummaryModel = override.SummaryModel + } + if override.ArtifactsDir != "" { + base.ArtifactsDir = override.ArtifactsDir + } + if override.BranchPrefix != "" { + base.BranchPrefix = override.BranchPrefix + } + if override.ChunkSize != 0 { + base.ChunkSize = override.ChunkSize + } + if override.GitHubRepo != "" { + base.GitHubRepo = override.GitHubRepo + } + if override.FigmaURL != "" { + base.FigmaURL = override.FigmaURL + } + if override.FigmaToken != "" { + base.FigmaToken = override.FigmaToken + } + if override.OutputDir != "" { + base.OutputDir = override.OutputDir + } + if override.TargetRepo != "" { + base.TargetRepo = override.TargetRepo + } + if override.PageRefresh != nil { + base.PageRefresh = override.PageRefresh + } + if override.DryRun != nil { + base.DryRun = override.DryRun + } + if override.OpenPR != nil { + base.OpenPR = override.OpenPR + } + if override.OpenIssue != nil { + base.OpenIssue = override.OpenIssue + } } // EnvVarSource reads BAUER_* env vars. @@ -69,26 +101,36 @@ func (e *EnvVarSource) Load() (*Config, error) { } else if v := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"); v != "" { cfg.CredentialsPath = v } - cfg.DocID = os.Getenv("BAUER_DOC_ID") - cfg.Model = os.Getenv("BAUER_MODEL") + cfg.DocID = os.Getenv("BAUER_DOC_ID") + cfg.Model = os.Getenv("BAUER_MODEL") cfg.SummaryModel = os.Getenv("BAUER_SUMMARY_MODEL") cfg.ArtifactsDir = os.Getenv("BAUER_ARTIFACTS_DIR") cfg.BranchPrefix = os.Getenv("BAUER_BRANCH_PREFIX") - cfg.GitHubRepo = os.Getenv("BAUER_GITHUB_REPO") - cfg.FigmaURL = os.Getenv("BAUER_FIGMA_URL") - cfg.FigmaToken = os.Getenv("BAUER_FIGMA_TOKEN") + cfg.GitHubRepo = os.Getenv("BAUER_GITHUB_REPO") + cfg.FigmaURL = os.Getenv("BAUER_FIGMA_URL") + cfg.FigmaToken = os.Getenv("BAUER_FIGMA_TOKEN") if cfg.FigmaToken == "" { cfg.FigmaToken = os.Getenv("FIGMA_TOKEN") } if v := os.Getenv("BAUER_CHUNK_SIZE"); v != "" { - cfg.ChunkSize, _ = strconv.Atoi(v) + n, err := strconv.Atoi(v) + if err != nil { + return nil, fmt.Errorf("parse BAUER_CHUNK_SIZE=%q: %w", v, err) + } + cfg.ChunkSize = n } if v := os.Getenv("BAUER_PAGE_REFRESH"); v != "" { - b := v == "true" + b, err := strconv.ParseBool(v) + if err != nil { + return nil, fmt.Errorf("parse BAUER_PAGE_REFRESH=%q: %w", v, err) + } cfg.PageRefresh = &b } if v := os.Getenv("BAUER_DRY_RUN"); v != "" { - b := v == "true" + b, err := strconv.ParseBool(v) + if err != nil { + return nil, fmt.Errorf("parse BAUER_DRY_RUN=%q: %w", v, err) + } cfg.DryRun = &b } return cfg, nil diff --git a/internal/config/manager_test.go b/internal/config/manager_test.go index 2bbc0ab..e056e19 100644 --- a/internal/config/manager_test.go +++ b/internal/config/manager_test.go @@ -1,7 +1,6 @@ package config_test import ( - "os" "testing" "bauer/internal/config" @@ -37,9 +36,9 @@ func TestEnvVarSource_OverridesDefault(t *testing.T) { } func TestEnvVarSource_ZeroValueDoesNotOverride(t *testing.T) { - // Clear any env vars that might interfere - os.Unsetenv("BAUER_DOC_ID") - os.Unsetenv("BAUER_MODEL") + // Ensure env vars are empty so they don't override defaults + t.Setenv("BAUER_DOC_ID", "") + t.Setenv("BAUER_MODEL", "") resolver := config.NewResolver( config.NewEnvVarSource(), @@ -136,7 +135,7 @@ func TestFlagsSource_OverridesEnvAndDefaults(t *testing.T) { } func TestResolver_CredentialsFallback(t *testing.T) { - os.Unsetenv("BAUER_CREDENTIALS_PATH") + t.Setenv("BAUER_CREDENTIALS_PATH", "") t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/gac/creds.json") src := config.NewEnvVarSource() diff --git a/internal/github/pr.go b/internal/github/pr.go index e5813a8..5e5fcff 100644 --- a/internal/github/pr.go +++ b/internal/github/pr.go @@ -65,7 +65,7 @@ func CreatePR(owner, repo string, opts CreatePROptions) (string, error) { } cmd := exec.Command("gh", args...) - + // Log token availability for debugging logger := slog.Default() ghToken := os.Getenv("GH_TOKEN") @@ -77,7 +77,7 @@ func CreatePR(owner, repo string, opts CreatePROptions) (string, error) { } else { logger.Debug("GH_TOKEN is set for PR creation", "token_prefix", ghToken[:10]) } - + output, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("failed to create PR: %w, output: %s", err, output) diff --git a/prompt.md b/prompt.md new file mode 100644 index 0000000..ed21569 --- /dev/null +++ b/prompt.md @@ -0,0 +1,20 @@ +You are an agents coordinator, your main task is to take my instructions and make sure they are implemented properly. +Take the specs #file:001_v2_reconciliation.md and #file:002_figma_integration.md into account, along with all other files in #file:docs into account. I had implemented detailed specs to implement a few new features. +There is an #file:implementation-log.md that describes what happened in the previous runs and progress on implementation, however, Im not sure about what is the progress of this plan. I see all mentioned branches created when I run git branch, but I do not see them marked as done in the log. +Your main task is to ensure that this plan has been implemented correctly. This means: + +- review already implemented code +- make sure all requirements were adressed correctly +- implement any improvements +- rebase next branch in the list +- repeat + +as you can see from the log, this plan is a bunch of stacked PRs this is important to make reviewing the code easier on me. As I just mentioned you need to sping up subagents, make each one of them rebase their branch on top of the previous one, review the branch, implement any improvements, then log what it did and mark the branch as done & reviewed in the log. + +Your main task is an agents coordinator, you should NOT attempt to read or write any code yourself, except what is in the docs folder files, if you need to check the state of something simply spin up a subagent and give it the appropriate instruction to complete its task. You should also NOT attempt to merge any branches on top of each other, this will be my job as I review the code. + +You should make sure to tell the agents that all code should be: tested & correct but also and very improtantly simple & readable & easy to digest. Any decisions taken with trade offs need to be recorded in the log, and task progression should also be recorded there correctly. As a task manager and coordinator you may take other actions that you may need to ensure the correct implementations. + +Another thing I noticed is that the implementation log does not cover phases 3-5 in 001, so you need to add those to the implementation logs (after all the current branches), spin up agents to implement them, then other agents to review them. You will find that spec 002 mentions it comes after phase 2 in 001, then the rest of the phases in 001 can come after 002 is fully implemented. So you need to make sure to coordinate that correctly as well. + +As mentioned do not read any coe yourself so as not to get distracted, just coordinate the agents to do the work and report back to you, then you can review the reports and make decisions based on them. From 7a1a494cdc6b61f1d35a2e5dc2d108a3c49de03b Mon Sep 17 00:00:00 2001 From: Bauer Agent Date: Tue, 26 May 2026 11:57:02 +0300 Subject: [PATCH 5/5] fix: address second-round PR review comments --- internal/artifacts/manager.go | 3 ++- internal/orchestrator/orchestrator.go | 37 ++++++++++++--------------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/internal/artifacts/manager.go b/internal/artifacts/manager.go index cca5b83..41df933 100644 --- a/internal/artifacts/manager.go +++ b/internal/artifacts/manager.go @@ -144,7 +144,8 @@ func (m *Manager) CompleteRun(runID string, status string, chunkCount int) error return fmt.Errorf("open runs.jsonl: %w", err) } defer f.Close() - if _, err = fmt.Fprintf(f, "%s\n", line); err != nil { + line = append(line, '\n') + if _, err = f.Write(line); err != nil { return fmt.Errorf("write run index: %w", err) } return nil diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 66b119a..ba8ab4c 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -9,7 +9,6 @@ import ( "context" "fmt" "log/slog" - "os" "time" ) @@ -59,7 +58,7 @@ func New(a agent.Agent, sources *source.Manager, arts *artifacts.Manager) *Defau } // Execute runs the full pipeline: extraction, prompt generation, and optional agent execution. -func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) (*OrchestrationResult, error) { +func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) (result *OrchestrationResult, err error) { startTime := time.Now() // Determine run mode @@ -69,25 +68,30 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( } // Start artifact run - runID, err := o.arts.StartRun(artifacts.RunMetadata{ + runID, startErr := o.arts.StartRun(artifacts.RunMetadata{ DocID: cfg.DocID, FigmaURL: cfg.FigmaURL, Mode: mode, }) - if err != nil { - slog.Warn("Failed to start artifact run", slog.String("error", err.Error())) + if startErr != nil { + slog.Warn("Failed to start artifact run", slog.String("error", startErr.Error())) // Non-fatal: continue without artifacts runID = "" } - completeRun := func(status string, chunkCount int) { + var finalChunkCount int + defer func() { if runID == "" { return } - if err := o.arts.CompleteRun(runID, status, chunkCount); err != nil { - slog.Warn("Failed to complete artifact run", slog.String("error", err.Error())) + status := "success" + if err != nil { + status = "failed" } - } + if completeErr := o.arts.CompleteRun(runID, status, finalChunkCount); completeErr != nil { + slog.Warn("Failed to complete artifact run", slog.String("error", completeErr.Error())) + } + }() // 1. Fetch from source (Google Docs) extractionStart := time.Now() @@ -97,7 +101,6 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( slog.String("error", err.Error()), slog.String("doc_id", cfg.DocID), ) - completeRun("failed", 0) return nil, fmt.Errorf("failed to fetch from source: %w", err) } extractionDuration := time.Since(extractionStart) @@ -119,13 +122,11 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( engine, err := prompt.NewEngine(config.BoolVal(cfg.PageRefresh, false)) if err != nil { slog.Error("Failed to initialize prompt engine", slog.String("error", err.Error())) - completeRun("failed", 0) return nil, fmt.Errorf("failed to initialize prompt engine: %w", err) } // 4. Generate Prompts from Chunks if bundle.Document == nil { - completeRun("failed", 0) return nil, fmt.Errorf("no document available: DocID may be empty") } @@ -141,20 +142,17 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( ) if err != nil { slog.Error("Failed to generate prompts", slog.String("error", err.Error())) - completeRun("failed", 0) return nil, fmt.Errorf("failed to generate prompts: %w", err) } planDuration := time.Since(planStart) + finalChunkCount = len(chunks) // Write prompts to artifact store if runID != "" { for _, chunk := range chunks { - // Read the generated prompt file and archive it - if data, readErr := os.ReadFile(chunk.Filename); readErr == nil { - if writeErr := o.arts.WritePrompt(runID, chunk.ChunkNumber, len(chunks), string(data)); writeErr != nil { - slog.Warn("Failed to write prompt artifact", slog.String("error", writeErr.Error())) - } + if writeErr := o.arts.WritePrompt(runID, chunk.ChunkNumber, len(chunks), chunk.Content); writeErr != nil { + slog.Warn("Failed to write prompt artifact", slog.String("error", writeErr.Error())) } } } @@ -170,7 +168,6 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( // If dry run, return early if config.BoolVal(cfg.DryRun, false) { totalDuration := time.Since(startTime) - completeRun("success", len(chunks)) return &OrchestrationResult{ ExtractionBundle: bundle, ExtractionDuration: extractionDuration, @@ -203,7 +200,6 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( chunkOutputs, copilotDuration, err := executeAgentChunks(ctx, chunks, cfg, o.agent) if err != nil { slog.Error("Agent execution failed", slog.String("error", err.Error())) - completeRun("failed", len(chunks)) return nil, fmt.Errorf("agent execution failed: %w", err) } @@ -251,7 +247,6 @@ func (o *DefaultOrchestrator) Execute(ctx context.Context, cfg *config.Config) ( } totalDuration := time.Since(startTime) - completeRun("success", len(chunks)) return &OrchestrationResult{ ExtractionBundle: bundle,