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..346d4ad 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 { + clone := *cfg + f := false + clone.DryRun = &f + return &clone +} 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 +``` --- 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..7dc11c1 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,8 +31,9 @@ type OrchestrationResult struct { PlanDuration time.Duration // Only populated if not dry run - CopilotOutputs []copilotcli.ChunkOutput - CopilotDuration time.Duration + AgentOutputs []ChunkOutput + Summary string + AgentDuration time.Duration SummaryDuration time.Duration // Metadata @@ -39,55 +47,58 @@ 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) { + if cfg == nil { + return nil, fmt.Errorf("orchestrator: cfg must not be nil") + } + if o.sources == nil { + return nil, fmt.Errorf("orchestrator: sources must not be nil") + } + 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 +106,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,70 +137,63 @@ 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{}, - CopilotDuration: 0, + AgentOutputs: []ChunkOutput{}, + AgentDuration: 0, SummaryDuration: 0, TotalDuration: totalDuration, DryRun: true, }, 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,28 +206,29 @@ 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, - CopilotDuration: copilotDuration, + AgentOutputs: chunkOutputs, + Summary: summary, + AgentDuration: copilotDuration, SummaryDuration: summaryDuration, TotalDuration: totalDuration, DryRun: false, }, 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 +239,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..1d03416 --- /dev/null +++ b/internal/source/manager.go @@ -0,0 +1,38 @@ +package source + +import ( + "context" + "fmt" + + "bauer/internal/gdocs" +) + +// Manager coordinates fetching from source systems (Google Docs, and later Figma). +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..abc44e8 --- /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. Nil when DocID is empty or extraction fails. + 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..757a2be 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, @@ -185,11 +185,11 @@ func ExecuteWorkflow(ctx context.Context, input WorkflowInput, orch orchestrator if bauerResult != nil { output.BauerResult.ExtractionDuration = bauerResult.ExtractionDuration output.BauerResult.PlanDuration = bauerResult.PlanDuration - output.BauerResult.CopilotDuration = bauerResult.CopilotDuration + output.BauerResult.CopilotDuration = bauerResult.AgentDuration 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 } @@ -201,8 +201,8 @@ func ExecuteWorkflow(ctx context.Context, input WorkflowInput, orch orchestrator "copilot_duration", output.BauerResult.CopilotDuration, "chunk_count", output.BauerResult.ChunkCount, "total_suggestions", output.BauerResult.TotalSuggestions, + "total_duration", time.Since(bauerStartTime), ) - output.BauerResult.CopilotDuration = time.Since(bauerStartTime) logger.Info("workflow success: Bauer processing finished") // GitHub finalization