fc9aabcda0
The agent's workspace tracker is now split per-repository for multi-repo
task roots. Each repo gets its own tracker that polls git independently
and stamps its repository name onto every emitted event. The Changes
panel aggregates files from every repo and groups them under per-repo
headers using the file-tree machinery from Phase 6.
Backend (agentctl):
- GitStatusUpdate / FileListUpdate gain a RepositoryName field. Empty
for single-repo workspaces (legacy clients see no change).
- WorkspaceTracker carries repositoryName and stamps it onto every
emitted status/file-list. Two constructors: NewWorkspaceTracker for
the legacy single-repo path (with the bare-root fallback) and
NewWorkspaceTrackerForRepo for explicit per-repo trackers.
- Manager scans direct child directories for git repos at construction.
When 2+ repos are found, builds one tracker per repo subdir on top of
the root tracker (which stays bound to the bare task root and emits
nothing useful). Single-repo workspaces are unchanged.
- Manager.SubscribeWorkspaceStream / UnsubscribeWorkspaceStream fan out
one client subscription across the root + all per-repo trackers via
AttachWorkspaceStreamSubscriber / DetachWorkspaceStreamSubscriber on
the tracker. The WS handler switched from the single-tracker subscribe
to the manager fan-out.
- Tests cover: per-repo trackers spawn for multi-repo task roots,
single-repo doesn't, both repos emit GitStatusUpdates tagged with
RepositoryName.
Frontend:
- GitStatusEntry / GitStatusData carry repository_name (optional).
- session-runtime gitStatus.byEnvironmentRepo new map keyed by env id
then repository name; setGitStatus routes per-repo updates into it
while still updating the legacy byEnvironmentId for single-repo
consumers. clearGitStatus drops both maps.
- migrateEnvKeyedData migrates the new per-repo map alongside the
existing keys when sessionId resolves to an environmentId.
- WS git-status handler forwards repository_name from event.status.
- New useSessionGitStatusByRepo hook returns sorted [{repo, status}]
pairs for the active session.
- task-changes-panel mergeReviewFiles iterates statusByRepo when
available, stamping repository_name onto every uncommitted file so
the Changes panel groups them under per-repo headers (using the
ReviewFile.repository_name plumbing from Phase 6).
- Tests: per-repo routing, no overwrite on sibling repo update,
legacy single-repo fallback, clear-on-cleanup.
Verified: pnpm exec vitest run 623/623, eslint --max-warnings 0 clean,
tsc --noEmit clean, make test (backend) clean, make lint 0 issues.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
210 lines
6.8 KiB
Go
210 lines
6.8 KiB
Go
package process
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/agentctl/types"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// SubscribeWorkspaceStream creates a new unified workspace stream subscriber
|
|
// and sends current git status and file list immediately
|
|
func (wt *WorkspaceTracker) SubscribeWorkspaceStream() types.WorkspaceStreamSubscriber {
|
|
sub := make(types.WorkspaceStreamSubscriber, 100)
|
|
wt.AttachWorkspaceStreamSubscriber(sub)
|
|
return sub
|
|
}
|
|
|
|
// AttachWorkspaceStreamSubscriber registers an existing subscriber channel with
|
|
// this tracker and immediately replays the current git status. Used by the
|
|
// process Manager to fan out a single client subscription across multiple
|
|
// per-repo trackers (multi-repo task roots) without giving the client a
|
|
// channel per tracker.
|
|
func (wt *WorkspaceTracker) AttachWorkspaceStreamSubscriber(sub types.WorkspaceStreamSubscriber) {
|
|
wt.workspaceSubMu.Lock()
|
|
wt.workspaceStreamSubscribers[sub] = struct{}{}
|
|
count := len(wt.workspaceStreamSubscribers)
|
|
wt.workspaceSubMu.Unlock()
|
|
wt.logger.Info("workspace stream subscriber added", zap.Int("subscribers", count))
|
|
|
|
// Replay current git status so the new subscriber doesn't have to wait for
|
|
// the next poll tick.
|
|
wt.mu.RLock()
|
|
currentStatus := wt.currentStatus
|
|
wt.mu.RUnlock()
|
|
|
|
if currentStatus.Timestamp.IsZero() {
|
|
currentStatus.Timestamp = time.Now()
|
|
}
|
|
if currentStatus.RepositoryName == "" {
|
|
currentStatus.RepositoryName = wt.repositoryName
|
|
}
|
|
|
|
select {
|
|
case sub <- types.NewWorkspaceGitStatus(¤tStatus):
|
|
default:
|
|
}
|
|
}
|
|
|
|
// UnsubscribeWorkspaceStream removes a workspace stream subscriber and closes
|
|
// the channel. Use DetachWorkspaceStreamSubscriber when the same channel is
|
|
// shared across multiple trackers — only the final detach should close it.
|
|
func (wt *WorkspaceTracker) UnsubscribeWorkspaceStream(sub types.WorkspaceStreamSubscriber) {
|
|
wt.DetachWorkspaceStreamSubscriber(sub)
|
|
close(sub)
|
|
}
|
|
|
|
// DetachWorkspaceStreamSubscriber removes the subscriber from this tracker
|
|
// without closing its channel. Used by Manager.UnsubscribeWorkspaceStream
|
|
// which detaches from each tracker first and then closes once.
|
|
func (wt *WorkspaceTracker) DetachWorkspaceStreamSubscriber(sub types.WorkspaceStreamSubscriber) {
|
|
wt.workspaceSubMu.Lock()
|
|
delete(wt.workspaceStreamSubscribers, sub)
|
|
count := len(wt.workspaceStreamSubscribers)
|
|
wt.workspaceSubMu.Unlock()
|
|
wt.logger.Info("workspace stream subscriber removed", zap.Int("subscribers", count))
|
|
}
|
|
|
|
// notifyWorkspaceStreamGitStatus sends git status to all workspace stream subscribers
|
|
func (wt *WorkspaceTracker) notifyWorkspaceStreamGitStatus(update types.GitStatusUpdate) {
|
|
wt.workspaceSubMu.RLock()
|
|
defer wt.workspaceSubMu.RUnlock()
|
|
|
|
msg := types.NewWorkspaceGitStatus(&update)
|
|
for sub := range wt.workspaceStreamSubscribers {
|
|
select {
|
|
case sub <- msg:
|
|
default:
|
|
// Subscriber is slow, skip
|
|
}
|
|
}
|
|
}
|
|
|
|
// notifyWorkspaceStreamGitCommit sends git commit notification to all workspace stream subscribers
|
|
func (wt *WorkspaceTracker) notifyWorkspaceStreamGitCommit(commit *types.GitCommitNotification) {
|
|
wt.workspaceSubMu.RLock()
|
|
defer wt.workspaceSubMu.RUnlock()
|
|
|
|
msg := types.NewWorkspaceGitCommit(commit)
|
|
for sub := range wt.workspaceStreamSubscribers {
|
|
select {
|
|
case sub <- msg:
|
|
default:
|
|
// Subscriber is slow, skip
|
|
}
|
|
}
|
|
}
|
|
|
|
// NotifyGitCommit notifies all subscribers about a new git commit.
|
|
// It also updates the cached HEAD SHA to prevent polling from re-detecting the same commit.
|
|
func (wt *WorkspaceTracker) NotifyGitCommit(commit *types.GitCommitNotification) {
|
|
// Update cached HEAD to the new commit SHA so polling doesn't re-detect it
|
|
if commit.CommitSHA != "" {
|
|
wt.gitStateMu.Lock()
|
|
wt.cachedHeadSHA = commit.CommitSHA
|
|
wt.gitStateMu.Unlock()
|
|
}
|
|
|
|
wt.notifyWorkspaceStreamGitCommit(commit)
|
|
}
|
|
|
|
// NotifyGitReset notifies all subscribers about a git reset (HEAD moved backward).
|
|
// It also updates the cached HEAD SHA to the new position.
|
|
func (wt *WorkspaceTracker) NotifyGitReset(reset *types.GitResetNotification) {
|
|
// Update cached HEAD to the new position
|
|
if reset.CurrentHead != "" {
|
|
wt.gitStateMu.Lock()
|
|
wt.cachedHeadSHA = reset.CurrentHead
|
|
wt.gitStateMu.Unlock()
|
|
}
|
|
|
|
wt.notifyWorkspaceStreamGitReset(reset)
|
|
}
|
|
|
|
// notifyWorkspaceStreamGitReset sends git reset notification to all workspace stream subscribers
|
|
func (wt *WorkspaceTracker) notifyWorkspaceStreamGitReset(reset *types.GitResetNotification) {
|
|
wt.workspaceSubMu.RLock()
|
|
defer wt.workspaceSubMu.RUnlock()
|
|
|
|
msg := types.NewWorkspaceGitReset(reset)
|
|
for sub := range wt.workspaceStreamSubscribers {
|
|
select {
|
|
case sub <- msg:
|
|
default:
|
|
// Subscriber is slow, skip
|
|
}
|
|
}
|
|
}
|
|
|
|
// notifyWorkspaceStreamBranchSwitch sends branch switch notification to all workspace stream subscribers
|
|
func (wt *WorkspaceTracker) notifyWorkspaceStreamBranchSwitch(branchSwitch *types.GitBranchSwitchNotification) {
|
|
wt.workspaceSubMu.RLock()
|
|
defer wt.workspaceSubMu.RUnlock()
|
|
|
|
msg := types.NewWorkspaceBranchSwitch(branchSwitch)
|
|
for sub := range wt.workspaceStreamSubscribers {
|
|
select {
|
|
case sub <- msg:
|
|
default:
|
|
// Subscriber is slow, skip
|
|
}
|
|
}
|
|
}
|
|
|
|
// notifyWorkspaceStreamFileChange sends file change notification to all workspace stream subscribers
|
|
func (wt *WorkspaceTracker) notifyWorkspaceStreamFileChange(notification types.FileChangeNotification) {
|
|
wt.workspaceSubMu.RLock()
|
|
defer wt.workspaceSubMu.RUnlock()
|
|
|
|
msg := types.NewWorkspaceFileChange(¬ification)
|
|
for sub := range wt.workspaceStreamSubscribers {
|
|
select {
|
|
case sub <- msg:
|
|
default:
|
|
// Subscriber is slow, skip
|
|
}
|
|
}
|
|
}
|
|
|
|
// notifyWorkspaceStreamProcessOutput sends process output to all workspace stream subscribers
|
|
func (wt *WorkspaceTracker) notifyWorkspaceStreamProcessOutput(output *types.ProcessOutput) {
|
|
wt.workspaceSubMu.RLock()
|
|
defer wt.workspaceSubMu.RUnlock()
|
|
|
|
msg := types.NewWorkspaceProcessOutput(output)
|
|
wt.logger.Debug("broadcast process output",
|
|
zap.String("session_id", output.SessionID),
|
|
zap.String("process_id", output.ProcessID),
|
|
zap.String("kind", string(output.Kind)),
|
|
zap.Int("subscribers", len(wt.workspaceStreamSubscribers)),
|
|
)
|
|
for sub := range wt.workspaceStreamSubscribers {
|
|
select {
|
|
case sub <- msg:
|
|
default:
|
|
// Subscriber is slow, skip
|
|
}
|
|
}
|
|
}
|
|
|
|
// notifyWorkspaceStreamProcessStatus sends process status updates to all workspace stream subscribers
|
|
func (wt *WorkspaceTracker) notifyWorkspaceStreamProcessStatus(status *types.ProcessStatusUpdate) {
|
|
wt.workspaceSubMu.RLock()
|
|
defer wt.workspaceSubMu.RUnlock()
|
|
|
|
msg := types.NewWorkspaceProcessStatus(status)
|
|
wt.logger.Debug("broadcast process status",
|
|
zap.String("session_id", status.SessionID),
|
|
zap.String("process_id", status.ProcessID),
|
|
zap.String("status", string(status.Status)),
|
|
zap.Int("subscribers", len(wt.workspaceStreamSubscribers)),
|
|
)
|
|
for sub := range wt.workspaceStreamSubscribers {
|
|
select {
|
|
case sub <- msg:
|
|
default:
|
|
// Subscriber is slow, skip
|
|
}
|
|
}
|
|
}
|