You've already forked agentic-coding-workflow
feat(app): wire jobs.Module + WebhookNotifier + integration test for webhook closed loop
This commit is contained in:
+319
-1
@@ -32,6 +32,7 @@ import (
|
|||||||
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/chat"
|
"github.com/yan1h/agent-coding-workflow/internal/chat"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/config"
|
"github.com/yan1h/agent-coding-workflow/internal/config"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/clock"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/db"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/db"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
|
||||||
@@ -39,6 +40,9 @@ import (
|
|||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/logger"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/logger"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/storage"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/storage"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/jobs"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/jobs/handlers"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/jobs/runners"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||||
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
||||||
@@ -53,6 +57,8 @@ type App struct {
|
|||||||
pool *db.Pool
|
pool *db.Pool
|
||||||
server *http.Server
|
server *http.Server
|
||||||
dispatcher *notify.Dispatcher
|
dispatcher *notify.Dispatcher
|
||||||
|
jobs *jobs.Module
|
||||||
|
jobsCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 把所有模块按启动顺序拼装好。任何阶段失败都会返回错误,不留半成品资源。
|
// New 把所有模块按启动顺序拼装好。任何阶段失败都会返回错误,不留半成品资源。
|
||||||
@@ -231,6 +237,139 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
|||||||
chat.NewHandler(chatConvSvc, chatMsgSvc, chatTplSvc, chatEpSvc, chatUsageSvc,
|
chat.NewHandler(chatConvSvc, chatMsgSvc, chatTplSvc, chatEpSvc, chatUsageSvc,
|
||||||
chatUploader, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
|
chatUploader, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
|
||||||
|
|
||||||
|
// ===== jobs module wiring =====
|
||||||
|
jobsRepo := jobs.NewPostgresRepository(pool)
|
||||||
|
|
||||||
|
webhookBackoff := []time.Duration{
|
||||||
|
30 * time.Second, 2 * time.Minute, 8 * time.Minute, 30 * time.Minute, 2 * time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookHandler := handlers.NewWebhookHandler(handlers.WebhookConfig{
|
||||||
|
URL: cfg.Notify.Webhook.URL,
|
||||||
|
Secret: cfg.Notify.Webhook.Secret,
|
||||||
|
Timeout: cfg.Notify.Webhook.Timeout,
|
||||||
|
}, clock.Real())
|
||||||
|
|
||||||
|
webhookNotifier := jobs.NewWebhookNotifier(jobsRepo, jobs.WebhookNotifierConfig{
|
||||||
|
Enabled: cfg.Notify.Webhook.Enabled,
|
||||||
|
Topics: cfg.Notify.Webhook.Topics,
|
||||||
|
Backoff: webhookBackoff,
|
||||||
|
}, clock.Real())
|
||||||
|
notifyDispatcher.Register(webhookNotifier)
|
||||||
|
|
||||||
|
dispAdapter := dispatcherAdapter{d: notifyDispatcher}
|
||||||
|
wsFetchRunner := runners.NewWorkspaceFetch(
|
||||||
|
workspaceFetchAdapter{repo: wsRepo},
|
||||||
|
workspaceFetcherAdapter{gitr: gitr, credLoader: credLoader},
|
||||||
|
dispAdapter, log)
|
||||||
|
wtPruneRunner := runners.NewWorktreePrune(
|
||||||
|
worktreePruneAdapter{wsRepo: wsRepo, pLookup: projectRepo, log: log},
|
||||||
|
gitr,
|
||||||
|
dispAdapter,
|
||||||
|
runners.WorktreePruneConfig{
|
||||||
|
IdleThreshold: cfg.Jobs.WorktreePrune.IdleThreshold,
|
||||||
|
WarningLead: cfg.Jobs.WorktreePrune.WarningLead,
|
||||||
|
}, log)
|
||||||
|
attCleanupRunner := runners.NewAttachmentCleanup(
|
||||||
|
chatAttachmentAdapter{repo: chatRepo},
|
||||||
|
attachStorage,
|
||||||
|
runners.AttachmentCleanupConfig{Retention: cfg.Jobs.AttachmentCleanup.Retention},
|
||||||
|
log)
|
||||||
|
jobReaperRunner := runners.NewJobReaper(jobsRepo, cfg.Jobs.JobReaper.LeaseTimeout, log)
|
||||||
|
jobPurgeRunner := runners.NewJobPurge(jobsRepo, cfg.Jobs.JobPurge.CompletedRetention, log)
|
||||||
|
|
||||||
|
deadLetterFn := func(ctx context.Context, job jobs.Job, herr error) {
|
||||||
|
errMsg := herr.Error()
|
||||||
|
if len(errMsg) > 500 {
|
||||||
|
errMsg = errMsg[:500]
|
||||||
|
}
|
||||||
|
if rerr := auditRec.Record(ctx, audit.Entry{
|
||||||
|
Action: "job.dead_letter",
|
||||||
|
TargetType: "job",
|
||||||
|
TargetID: job.ID.String(),
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"type": string(job.Type),
|
||||||
|
"attempts": job.Attempts,
|
||||||
|
"err": errMsg,
|
||||||
|
},
|
||||||
|
}); rerr != nil {
|
||||||
|
log.Warn("dead_letter.audit_failed", "err", rerr.Error())
|
||||||
|
}
|
||||||
|
admins, err := userSvc.ListAdmins(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("dead_letter.list_admins_failed", "err", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, a := range admins {
|
||||||
|
if derr := notifyDispatcher.Dispatch(ctx, notify.Message{
|
||||||
|
UserID: a.ID,
|
||||||
|
Topic: "webhook.dead_letter",
|
||||||
|
Severity: notify.SeverityError,
|
||||||
|
Title: "Webhook delivery failed permanently",
|
||||||
|
Body: errMsg,
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"job_id": job.ID.String(),
|
||||||
|
"job_type": string(job.Type),
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}); derr != nil {
|
||||||
|
log.Warn("dead_letter.dispatch_failed", "admin_id", a.ID, "err", derr.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jobsModule := jobs.NewModule(jobs.Config{
|
||||||
|
Enabled: cfg.Jobs.Enabled,
|
||||||
|
Workers: cfg.Jobs.Workers,
|
||||||
|
ShutdownGrace: cfg.Jobs.ShutdownGrace,
|
||||||
|
WorkspaceFetch: jobs.RunnerConfig{
|
||||||
|
Enabled: cfg.Jobs.WorkspaceFetch.Enabled,
|
||||||
|
Interval: cfg.Jobs.WorkspaceFetch.Interval,
|
||||||
|
},
|
||||||
|
WorktreePrune: jobs.WorktreePruneConfig{
|
||||||
|
Enabled: cfg.Jobs.WorktreePrune.Enabled,
|
||||||
|
Interval: cfg.Jobs.WorktreePrune.Interval,
|
||||||
|
IdleThreshold: cfg.Jobs.WorktreePrune.IdleThreshold,
|
||||||
|
WarningLead: cfg.Jobs.WorktreePrune.WarningLead,
|
||||||
|
},
|
||||||
|
AttachmentCleanup: jobs.AttachmentCleanupConfig{
|
||||||
|
Enabled: cfg.Jobs.AttachmentCleanup.Enabled,
|
||||||
|
Interval: cfg.Jobs.AttachmentCleanup.Interval,
|
||||||
|
Retention: cfg.Jobs.AttachmentCleanup.Retention,
|
||||||
|
},
|
||||||
|
JobReaper: jobs.JobReaperConfig{
|
||||||
|
Enabled: cfg.Jobs.JobReaper.Enabled,
|
||||||
|
Interval: cfg.Jobs.JobReaper.Interval,
|
||||||
|
LeaseTimeout: cfg.Jobs.JobReaper.LeaseTimeout,
|
||||||
|
},
|
||||||
|
JobPurge: jobs.JobPurgeConfig{
|
||||||
|
Enabled: cfg.Jobs.JobPurge.Enabled,
|
||||||
|
Interval: cfg.Jobs.JobPurge.Interval,
|
||||||
|
CompletedRetention: cfg.Jobs.JobPurge.CompletedRetention,
|
||||||
|
},
|
||||||
|
}, jobs.ModuleDeps{
|
||||||
|
Repo: jobsRepo,
|
||||||
|
Handlers: map[jobs.JobType]jobs.Handler{
|
||||||
|
jobs.TypeWebhookDeliver: webhookHandler,
|
||||||
|
},
|
||||||
|
RunnerFns: map[string]jobs.RunnerFunc{
|
||||||
|
"workspace_fetch": wsFetchRunner.Run,
|
||||||
|
"worktree_prune": wtPruneRunner.Run,
|
||||||
|
"attachment_cleanup": attCleanupRunner.Run,
|
||||||
|
"job_reaper": jobReaperRunner.Run,
|
||||||
|
"job_purge": jobPurgeRunner.Run,
|
||||||
|
},
|
||||||
|
Clock: clock.Real(),
|
||||||
|
Log: log,
|
||||||
|
WorkerBackoff: webhookBackoff,
|
||||||
|
OnDeadLetter: deadLetterFn,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start the module with a background context; cancellation is wired into
|
||||||
|
// App.Run's shutdown path via jobsCancel.
|
||||||
|
jobsCtx, jobsCancel := context.WithCancel(context.Background())
|
||||||
|
jobsModule.Start(jobsCtx)
|
||||||
|
|
||||||
if cfg.Bootstrap.AdminEmail != "" && cfg.Bootstrap.AdminPassword != "" {
|
if cfg.Bootstrap.AdminEmail != "" && cfg.Bootstrap.AdminPassword != "" {
|
||||||
if u, err := userSvc.Bootstrap(ctx, cfg.Bootstrap.AdminEmail, cfg.Bootstrap.AdminPassword); err != nil {
|
if u, err := userSvc.Bootstrap(ctx, cfg.Bootstrap.AdminEmail, cfg.Bootstrap.AdminPassword); err != nil {
|
||||||
log.Warn("bootstrap admin failed", "err", err.Error())
|
log.Warn("bootstrap admin failed", "err", err.Error())
|
||||||
@@ -247,7 +386,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
|||||||
WriteTimeout: 60 * time.Second,
|
WriteTimeout: 60 * time.Second,
|
||||||
IdleTimeout: 120 * time.Second,
|
IdleTimeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
return &App{cfg: cfg, log: log, pool: pool, server: srv, dispatcher: notifyDispatcher}, nil
|
return &App{cfg: cfg, log: log, pool: pool, server: srv, dispatcher: notifyDispatcher, jobs: jobsModule, jobsCancel: jobsCancel}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run 启动 HTTP server 并阻塞直到 ctx 取消(Shutdown)或服务器报错。
|
// Run 启动 HTTP server 并阻塞直到 ctx 取消(Shutdown)或服务器报错。
|
||||||
@@ -271,6 +410,14 @@ func (a *App) Run(ctx context.Context) error {
|
|||||||
if shutErr != nil {
|
if shutErr != nil {
|
||||||
a.log.Error("server shutdown", "err", shutErr.Error())
|
a.log.Error("server shutdown", "err", shutErr.Error())
|
||||||
}
|
}
|
||||||
|
// Stop jobs module first so no in-flight worker can dispatch new
|
||||||
|
// notifications after we begin to drain the dispatcher.
|
||||||
|
if a.jobs != nil {
|
||||||
|
a.jobsCancel()
|
||||||
|
stopCtx, stopCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
a.jobs.Stop(stopCtx)
|
||||||
|
stopCancel()
|
||||||
|
}
|
||||||
// server 已停止接收新请求;等异步通知投递结束再关 pool
|
// server 已停止接收新请求;等异步通知投递结束再关 pool
|
||||||
if err := a.dispatcher.Shutdown(shutCtx); err != nil {
|
if err := a.dispatcher.Shutdown(shutCtx); err != nil {
|
||||||
a.log.Warn("notify dispatcher drain", "err", err.Error())
|
a.log.Warn("notify dispatcher drain", "err", err.Error())
|
||||||
@@ -280,6 +427,12 @@ func (a *App) Run(ctx context.Context) error {
|
|||||||
case err := <-errCh:
|
case err := <-errCh:
|
||||||
drainCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
drainCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
if a.jobs != nil {
|
||||||
|
a.jobsCancel()
|
||||||
|
stopCtx, stopCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
a.jobs.Stop(stopCtx)
|
||||||
|
stopCancel()
|
||||||
|
}
|
||||||
_ = a.dispatcher.Shutdown(drainCtx)
|
_ = a.dispatcher.Shutdown(drainCtx)
|
||||||
a.pool.Close()
|
a.pool.Close()
|
||||||
return err
|
return err
|
||||||
@@ -347,3 +500,168 @@ func projectACL(pj *project.Project, callerID uuid.UUID, isAdmin bool) (canRead,
|
|||||||
canRead = canWrite || pj.Visibility == project.VisibilityInternal
|
canRead = canWrite || pj.Visibility == project.VisibilityInternal
|
||||||
return canRead, canWrite
|
return canRead, canWrite
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Adapters: bridge cross-module types into the narrow interfaces the
|
||||||
|
// jobs/runners package expects, so the runners package never imports
|
||||||
|
// workspace/chat/notify.
|
||||||
|
|
||||||
|
// workspaceFetchAdapter implements runners.WorkspaceFetchRepo backed by
|
||||||
|
// workspace.Repository. Translates workspace.FetchTarget into
|
||||||
|
// runners.WorkspaceFetchTarget (same shape).
|
||||||
|
type workspaceFetchAdapter struct{ repo workspace.Repository }
|
||||||
|
|
||||||
|
func (a workspaceFetchAdapter) ListFetchTargets(ctx context.Context) ([]runners.WorkspaceFetchTarget, error) {
|
||||||
|
targets, err := a.repo.ListFetchTargets(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]runners.WorkspaceFetchTarget, 0, len(targets))
|
||||||
|
for _, t := range targets {
|
||||||
|
out = append(out, runners.WorkspaceFetchTarget{
|
||||||
|
ID: t.ID, OwnerID: t.OwnerID, Name: t.Name, MainPath: t.MainPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a workspaceFetchAdapter) MarkSyncing(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||||
|
return a.repo.MarkSyncing(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a workspaceFetchAdapter) MarkFetchResult(ctx context.Context, id uuid.UUID, success bool, errMsg string) error {
|
||||||
|
return a.repo.MarkFetchResult(ctx, id, success, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// workspaceFetcherAdapter implements runners.WorkspaceFetcher by loading the
|
||||||
|
// workspace credential and calling git.Runner.Fetch directly.
|
||||||
|
type workspaceFetcherAdapter struct {
|
||||||
|
gitr git.Runner
|
||||||
|
credLoader func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a workspaceFetcherAdapter) Fetch(ctx context.Context, mainPath string, wsID uuid.UUID) error {
|
||||||
|
cred, err := a.credLoader(ctx, wsID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.gitr.Fetch(ctx, mainPath, cred)
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectLookup is the minimal interface needed by worktreePruneAdapter to
|
||||||
|
// look up a project by ID without ACL enforcement (background system job path).
|
||||||
|
type projectLookup interface {
|
||||||
|
GetProjectByID(ctx context.Context, id uuid.UUID) (*project.Project, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// worktreePruneAdapter implements runners.WorktreePruneRepo by composing the
|
||||||
|
// workspace.Repository's worktree-prune methods with per-worktree workspace+
|
||||||
|
// project lookups (to populate OwnerID and MainPath that aren't on the
|
||||||
|
// workspace_worktrees row). N+1 acceptable: prune lists are small per tick.
|
||||||
|
type worktreePruneAdapter struct {
|
||||||
|
wsRepo workspace.Repository
|
||||||
|
pLookup projectLookup
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a worktreePruneAdapter) ListWorktreesNeedingPruneWarning(ctx context.Context, lastUsedBefore time.Time) ([]runners.WorktreeRow, error) {
|
||||||
|
rows, err := a.wsRepo.ListWorktreesNeedingPruneWarning(ctx, lastUsedBefore)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return a.enrichWorktrees(ctx, rows), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a worktreePruneAdapter) ListWorktreesNeedingPrune(ctx context.Context, lastUsedBefore time.Time) ([]runners.WorktreeRow, error) {
|
||||||
|
rows, err := a.wsRepo.ListWorktreesNeedingPrune(ctx, lastUsedBefore)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return a.enrichWorktrees(ctx, rows), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a worktreePruneAdapter) MarkPruneWarning(ctx context.Context, id uuid.UUID) error {
|
||||||
|
return a.wsRepo.MarkPruneWarning(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a worktreePruneAdapter) TryStartPrune(ctx context.Context, id uuid.UUID) (runners.WorktreeRow, error) {
|
||||||
|
wt, err := a.wsRepo.TryStartPrune(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return runners.WorktreeRow{}, err
|
||||||
|
}
|
||||||
|
return a.enrichOne(ctx, wt), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a worktreePruneAdapter) FinishPruneSuccess(ctx context.Context, id uuid.UUID) error {
|
||||||
|
return a.wsRepo.FinishPruneSuccess(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a worktreePruneAdapter) FinishPruneRollback(ctx context.Context, id uuid.UUID) error {
|
||||||
|
return a.wsRepo.FinishPruneRollback(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrichWorktrees converts []*workspace.Worktree to []runners.WorktreeRow.
|
||||||
|
func (a worktreePruneAdapter) enrichWorktrees(ctx context.Context, rows []*workspace.Worktree) []runners.WorktreeRow {
|
||||||
|
out := make([]runners.WorktreeRow, 0, len(rows))
|
||||||
|
for _, w := range rows {
|
||||||
|
out = append(out, a.enrichOne(ctx, w))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a worktreePruneAdapter) enrichOne(ctx context.Context, w *workspace.Worktree) runners.WorktreeRow {
|
||||||
|
row := runners.WorktreeRow{ID: w.ID, Branch: w.Branch, Path: w.Path}
|
||||||
|
ws, err := a.wsRepo.GetWorkspaceByID(ctx, w.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
a.log.Warn("worktree_prune.adapter.lookup_workspace_failed",
|
||||||
|
"worktree_id", w.ID, "workspace_id", w.WorkspaceID, "err", err.Error())
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
row.MainPath = ws.MainPath
|
||||||
|
pj, err := a.pLookup.GetProjectByID(ctx, ws.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
a.log.Warn("worktree_prune.adapter.lookup_project_failed",
|
||||||
|
"worktree_id", w.ID, "project_id", ws.ProjectID, "err", err.Error())
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
row.OwnerID = pj.OwnerID
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatAttachmentAdapter implements runners.AttachmentCleanupRepo backed by
|
||||||
|
// chat.Repository. Translates chat.AttachmentRef → runners.AttachmentRow.
|
||||||
|
type chatAttachmentAdapter struct{ repo chat.Repository }
|
||||||
|
|
||||||
|
func (a chatAttachmentAdapter) ListAttachmentsForSoftDeletedConversations(ctx context.Context, olderThan time.Duration) ([]runners.AttachmentRow, error) {
|
||||||
|
refs, err := a.repo.ListAttachmentsForSoftDeletedConversations(ctx, olderThan)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return refsToRows(refs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a chatAttachmentAdapter) ListExpiredOrphans(ctx context.Context, olderThan time.Duration) ([]runners.AttachmentRow, error) {
|
||||||
|
refs, err := a.repo.ListExpiredOrphans(ctx, olderThan)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return refsToRows(refs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a chatAttachmentAdapter) DeleteAttachments(ctx context.Context, ids []uuid.UUID) error {
|
||||||
|
return a.repo.DeleteAttachments(ctx, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refsToRows(refs []chat.AttachmentRef) []runners.AttachmentRow {
|
||||||
|
out := make([]runners.AttachmentRow, 0, len(refs))
|
||||||
|
for _, r := range refs {
|
||||||
|
out = append(out, runners.AttachmentRow{ID: r.ID, StoragePath: r.StoragePath})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatcherAdapter implements runners.Dispatcher backed by *notify.Dispatcher.
|
||||||
|
type dispatcherAdapter struct{ d *notify.Dispatcher }
|
||||||
|
|
||||||
|
func (a dispatcherAdapter) Dispatch(ctx context.Context, msg notify.Message) error {
|
||||||
|
return a.d.Dispatch(ctx, msg)
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,11 +27,14 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/clock"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/db"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/db"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/logger"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/logger"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/jobs"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/jobs/handlers"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/user"
|
"github.com/yan1h/agent-coding-workflow/internal/user"
|
||||||
@@ -499,3 +502,92 @@ func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusNoContent, deleteJSON("/api/v1/worktrees/"+wtResp.ID.String()).StatusCode)
|
require.Equal(t, http.StatusNoContent, deleteJSON("/api/v1/worktrees/"+wtResp.ID.String()).StatusCode)
|
||||||
require.Equal(t, http.StatusNoContent, deleteJSON("/api/v1/workspaces/"+wsResp.ID.String()).StatusCode)
|
require.Equal(t, http.StatusNoContent, deleteJSON("/api/v1/workspaces/"+wsResp.ID.String()).StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEndToEnd_WebhookClosedLoop(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
container, err := tcpg.Run(ctx, "postgres:16-alpine",
|
||||||
|
tcpg.WithDatabase("acw"), tcpg.WithUsername("acw"), tcpg.WithPassword("acw"),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = container.Terminate(ctx) })
|
||||||
|
|
||||||
|
dsn, err := container.ConnectionString(ctx, "sslmode=disable")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, db.Migrate(ctx, dsn, "file://../../migrations"))
|
||||||
|
|
||||||
|
pool, err := db.NewPool(ctx, dsn)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
|
||||||
|
log := logger.New(logger.Options{Format: logger.FormatJSON, Level: logger.LevelInfo})
|
||||||
|
|
||||||
|
// Webhook receiver
|
||||||
|
type recv struct {
|
||||||
|
body map[string]any
|
||||||
|
topic string
|
||||||
|
eventID string
|
||||||
|
sig string
|
||||||
|
}
|
||||||
|
received := make(chan recv, 4)
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]any
|
||||||
|
rawBody, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(rawBody, &body)
|
||||||
|
received <- recv{
|
||||||
|
body: body,
|
||||||
|
topic: r.Header.Get("X-ACW-Topic"),
|
||||||
|
eventID: r.Header.Get("X-ACW-Event-Id"),
|
||||||
|
sig: r.Header.Get("X-ACW-Signature"),
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
|
||||||
|
auditRec := audit.NewPostgresRecorder(pool)
|
||||||
|
notifyRepo := notify.NewPostgresRepository(pool)
|
||||||
|
disp := notify.NewDispatcher(notifyRepo, log)
|
||||||
|
|
||||||
|
jobsRepo := jobs.NewPostgresRepository(pool)
|
||||||
|
h := handlers.NewWebhookHandler(handlers.WebhookConfig{
|
||||||
|
URL: ts.URL, Secret: "test-secret", Timeout: 2 * time.Second,
|
||||||
|
}, clock.Real())
|
||||||
|
n := jobs.NewWebhookNotifier(jobsRepo, jobs.WebhookNotifierConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Topics: []string{"workspace.sync_failed"},
|
||||||
|
Backoff: []time.Duration{50 * time.Millisecond, 200 * time.Millisecond},
|
||||||
|
}, clock.Real())
|
||||||
|
disp.Register(n)
|
||||||
|
|
||||||
|
// Run a single Worker (no full Module needed for this test).
|
||||||
|
w := jobs.NewWorker(jobsRepo, map[jobs.JobType]jobs.Handler{jobs.TypeWebhookDeliver: h},
|
||||||
|
jobs.WorkerOptions{
|
||||||
|
ID: "itest", PollInterval: 20 * time.Millisecond,
|
||||||
|
Backoff: []time.Duration{50 * time.Millisecond}, Clock: clock.Real(), Log: log,
|
||||||
|
})
|
||||||
|
wctx, wcancel := context.WithCancel(ctx)
|
||||||
|
t.Cleanup(func() { wcancel(); w.Wait() })
|
||||||
|
go w.Run(wctx)
|
||||||
|
|
||||||
|
// Suppress unused-variable vet warning.
|
||||||
|
_ = auditRec
|
||||||
|
|
||||||
|
eventTime := time.Now()
|
||||||
|
require.NoError(t, disp.Dispatch(ctx, notify.Message{
|
||||||
|
ID: uuid.New(), UserID: uuid.New(),
|
||||||
|
Topic: "workspace.sync_failed", Severity: notify.SeverityError,
|
||||||
|
Title: "fetch failed", Body: "remote unreachable",
|
||||||
|
CreatedAt: eventTime,
|
||||||
|
}))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-received:
|
||||||
|
require.Equal(t, "workspace.sync_failed", got.topic)
|
||||||
|
require.NotEmpty(t, got.eventID)
|
||||||
|
require.Contains(t, got.sig, "sha256=")
|
||||||
|
require.Equal(t, "workspace.sync_failed", got.body["topic"])
|
||||||
|
require.Equal(t, "fetch failed", got.body["title"])
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("webhook never fired")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user