Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 183 additions & 52 deletions cmd/hooks.go

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions cmd/hooks_more_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cmd

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -749,6 +751,85 @@ func TestHookSessionStopSummaryBranches(t *testing.T) {
})
}

func TestHookSessionStopContextReturnsWithoutPostDeadlineOutput(t *testing.T) {
root := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
cancel()
var out bytes.Buffer
if err := hookSessionStopContext(ctx, root, &out); err == nil {
t.Fatal("hookSessionStopContext succeeded after cancellation")
}
before := out.String()
time.Sleep(20 * time.Millisecond)
if got := out.String(); got != before {
t.Fatalf("output changed after return: before %q after %q", before, got)
}
}

func TestFinishSessionDaemonContextPropagatesStopFailure(t *testing.T) {
root := t.TempDir()
withHookRuntimeStubs(t,
func() (string, error) { return "codemap", nil },
func(string, ...string) *exec.Cmd { return exec.Command(filepath.Join(root, "missing-codemap")) },
func(string) bool { return true },
nil,
)
if err := finishSessionDaemonContext(context.Background(), root, "session-a"); err == nil {
t.Fatal("finishSessionDaemonContext discarded stop failure")
}
}

func TestHookSessionStopContextPropagatesHandoffFailure(t *testing.T) {
root := makeRepoOnBranch(t, "feature/handoff-failure")
writeStateOnly(t, root, watch.State{UpdatedAt: time.Now(), RecentEvents: []watch.Event{{Time: time.Now(), Op: "WRITE", Path: "main.go"}}})
if err := os.MkdirAll(handoff.LatestPath(root), 0o755); err != nil {
t.Fatal(err)
}
if err := hookSessionStopContext(context.Background(), root, io.Discard); err == nil {
t.Fatal("hookSessionStopContext discarded handoff failure")
}
}

type cancelOnWrite struct {
cancel context.CancelFunc
match string
}

func (w cancelOnWrite) Write(p []byte) (int, error) {
if strings.Contains(string(p), w.match) {
w.cancel()
}
return len(p), nil
}

func TestHookSessionStopContextChecksDeadlineAfterHandoff(t *testing.T) {
root := makeRepoOnBranch(t, "feature/handoff-deadline")
writeStateOnly(t, root, watch.State{UpdatedAt: time.Now(), RecentEvents: []watch.Event{{Time: time.Now(), Op: "WRITE", Path: "main.go"}}})
ctx, cancel := context.WithCancel(context.Background())
err := hookSessionStopContext(ctx, root, cancelOnWrite{cancel: cancel, match: "Saved handoff"})
if !errors.Is(err, context.Canceled) {
t.Fatalf("hookSessionStopContext error = %v, want context.Canceled", err)
}
}

func TestHookSessionIDFromStdinContextBoundsOpenPipe(t *testing.T) {
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer reader.Close()
defer writer.Close()
original := os.Stdin
os.Stdin = reader
defer func() { os.Stdin = original }()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
_, err = hookSessionIDFromStdinContext(ctx)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("error = %v, want deadline exceeded", err)
}
}

func TestDaemonCommandHelpersAndMultiRepoShellout(t *testing.T) {
t.Run("start daemon shells out to watch start", func(t *testing.T) {
var gotName string
Expand Down
6 changes: 5 additions & 1 deletion handoff/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ func normalizeOptions(opts BuildOptions, fileCount int) BuildOptions {

// Build creates a multi-agent handoff artifact from git + daemon state.
func Build(root string, opts BuildOptions) (*Artifact, error) {
return BuildContext(context.Background(), root, opts)
ctx := opts.Context
if ctx == nil {
ctx = context.Background()
}
return BuildContext(ctx, root, opts)
}

// BuildContext creates a handoff artifact while honoring caller cancellation
Expand Down
11 changes: 11 additions & 0 deletions handoff/handoff_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package handoff

import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
Expand All @@ -13,6 +15,15 @@ import (
"codemap/watch"
)

func TestBuildHonorsCanceledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := Build(t.TempDir(), BuildOptions{Context: ctx})
if !errors.Is(err, context.Canceled) {
t.Fatalf("Build error = %v, want context canceled", err)
}
}

func runCmd(t *testing.T, dir, name string, args ...string) {
t.Helper()
cmd := exec.Command(name, args...)
Expand Down
2 changes: 2 additions & 0 deletions handoff/types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package handoff

import (
"context"
"time"

"codemap/watch"
Expand Down Expand Up @@ -119,6 +120,7 @@ type FileDetail struct {

// BuildOptions controls handoff generation behavior.
type BuildOptions struct {
Context context.Context
BaseRef string
Since time.Duration
State *watch.State
Expand Down
8 changes: 7 additions & 1 deletion main_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,8 @@ func TestRunWatchModeRunDaemonAndWatchStart(t *testing.T) {

t.Run("watch start shells out to daemon entrypoint", func(t *testing.T) {
root := t.TempDir()
projectpath.ResetSetupRoot()
t.Cleanup(projectpath.ResetSetupRoot)
var gotName string
var gotArgs []string
withMainRuntimeStubs(
Expand All @@ -928,7 +930,11 @@ func TestRunWatchModeRunDaemonAndWatchStart(t *testing.T) {
nil,
)

stdout, _ := captureMainStreams(t, func() { runWatchSubcommand("start", root) })
var startErr error
stdout, stderr := captureMainStreams(t, func() { startErr = runWatchSubcommand("start", root) })
if startErr != nil {
t.Fatalf("watch start failed: %v\nstderr:\n%s", startErr, stderr)
}
if gotName != "/tmp/codemap-test" {
t.Fatalf("watch start executable = %q, want /tmp/codemap-test", gotName)
}
Expand Down
70 changes: 55 additions & 15 deletions watch/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ type Daemon struct {
verbose bool
done chan struct{}

eventLoopWG sync.WaitGroup
eventLoopWG sync.WaitGroup
publisher *statePublisher
closeWatcher func() error
}

func (d *Daemon) runtimeStateDir() (string, error) {
Expand All @@ -46,6 +48,21 @@ func (d *Daemon) runtimeStateDir() (string, error) {
return projectpath.CheckedRuntimeCodemapDir(d.root)
}

func (d *Daemon) ensurePublisher() error {
if d.publisher != nil {
return nil
}
runtimeDir, err := d.runtimeStateDir()
if err != nil {
return err
}
if err := os.MkdirAll(runtimeDir, 0o755); err != nil {
return err
}
d.publisher = newStatePublisher(d, filepath.Join(runtimeDir, "state.json"), "legacy-test-instance")
return nil
}

// NewDaemon creates a new watch daemon for the given root
func NewDaemon(root string, verbose bool) (*Daemon, error) {
absRoot, err := filepath.Abs(root)
Expand Down Expand Up @@ -75,14 +92,15 @@ func NewDaemon(root string, verbose bool) (*Daemon, error) {
}

d := &Daemon{
root: absRoot,
configDir: selection.PolicyDir,
runtimeDir: runtimeDir,
watcher: watcher,
gitCache: gitCache,
verbose: verbose,
done: make(chan struct{}),
eventLog: filepath.Join(runtimeDir, "events.log"),
root: absRoot,
configDir: selection.PolicyDir,
runtimeDir: runtimeDir,
watcher: watcher,
gitCache: gitCache,
verbose: verbose,
done: make(chan struct{}),
closeWatcher: watcher.Close,
eventLog: filepath.Join(runtimeDir, "events.log"),
graph: &Graph{
Root: absRoot,
Files: make(map[string]*scanner.FileInfo),
Expand All @@ -94,15 +112,26 @@ func NewDaemon(root string, verbose bool) (*Daemon, error) {
IsGitRepo: isGitRepo,
},
}
instance, err := newDaemonInstance()
if err != nil {
watcher.Close()
return nil, fmt.Errorf("create daemon identity: %w", err)
}
d.publisher = newStatePublisher(d, filepath.Join(runtimeDir, "state.json"), instance)

return d, nil
}

// Start begins watching and returns immediately
func (d *Daemon) Start() error {
// Keep project configuration in its configured .codemap directory while
// mutable daemon state uses the validated project runtime namespace.
codemapDir := d.runtimeDir
if err := d.ensurePublisher(); err != nil {
return fmt.Errorf("resolve runtime state: %w", err)
}
runtimeDir, err := d.runtimeStateDir()
if err != nil {
return fmt.Errorf("resolve runtime state: %w", err)
}
codemapDir := runtimeDir
if err := os.MkdirAll(codemapDir, 0755); err != nil {
return fmt.Errorf("failed to create .codemap dir: %w", err)
}
Expand Down Expand Up @@ -130,9 +159,17 @@ func (d *Daemon) Start() error {
if err := d.watcher.Add(configDir); err != nil {
return fmt.Errorf("failed to watch .codemap dir: %w", err)
}
if err := ensureControlDirectory(d.publisher.flushDir); err != nil {
return fmt.Errorf("create flush directory: %w", err)
}
if err := d.watcher.Add(d.publisher.flushDir); err != nil {
return fmt.Errorf("watch flush directory: %w", err)
}

// Write initial state for hooks to read immediately
d.writeState()
if err := d.publisher.publish(); err != nil {
return fmt.Errorf("publish initial state: %w", err)
}

// Start event loop
d.eventLoopWG.Add(1)
Expand Down Expand Up @@ -193,8 +230,8 @@ func (d *Daemon) computeTopology() {
// Stop gracefully shuts down the daemon
func (d *Daemon) Stop() {
close(d.done)
d.watcher.Close()
d.eventLoopWG.Wait()
_ = d.closeWatcher()
}

// GetGraph returns the current graph (thread-safe)
Expand Down Expand Up @@ -240,7 +277,10 @@ func shouldComputeDependencyGraph(fileCount int) bool {

// WriteInitialState writes state after initial scan (for hooks)
func (d *Daemon) WriteInitialState() {
d.writeState()
if d.ensurePublisher() != nil {
return
}
_ = d.publisher.publish()
}

// fullScan does a complete scan of the project
Expand Down
Loading
Loading