This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+163
View File
@@ -0,0 +1,163 @@
package crypto
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
"sync"
)
// KeyStore tracks which key versions exist and which one is the current write
// ("active") version. The key material itself is never stored here — only the
// version metadata (see crypto_keys table). It is consulted on Encrypt to pick
// the active version and by the re-encrypt job to discover the target version.
type KeyStore interface {
// ActiveVersion returns the version that new ciphertext must be sealed with.
ActiveVersion(ctx context.Context) (int, error)
}
// staticKeyStore is a fixed-version store, used when no DB is wired (tests,
// degraded boot). It always reports the same active version.
type staticKeyStore struct{ v int }
// NewStaticKeyStore returns a KeyStore that always reports version v as active.
func NewStaticKeyStore(v int) KeyStore { return staticKeyStore{v: v} }
func (s staticKeyStore) ActiveVersion(context.Context) (int, error) { return s.v, nil }
// KeyedEncryptor seals/opens secrets with a versioned key. The on-disk format
// is unchanged from the legacy Encryptor — nonce(12) || ciphertext || tag —
// because the key version travels in a separate DB column (key_version), not in
// the ciphertext. This keeps every existing blob forward-compatible: a v1 blob
// is still openable as long as version 1 is recorded for its row.
type KeyedEncryptor struct {
provider Provider
store KeyStore
mu sync.Mutex
gcms map[int]cipher.AEAD // version -> AEAD, lazily built from provider keys
}
// NewKeyedEncryptor builds a KeyedEncryptor over a key provider and key store.
// store may be nil → a static version-1 store is used (back-compat with the
// single-key world).
func NewKeyedEncryptor(provider Provider, store KeyStore) *KeyedEncryptor {
if store == nil {
store = NewStaticKeyStore(1)
}
return &KeyedEncryptor{provider: provider, store: store, gcms: map[int]cipher.AEAD{}}
}
// gcmFor returns (and caches) the AEAD for a version, resolving the key bytes
// via the provider on first use.
func (e *KeyedEncryptor) gcmFor(ctx context.Context, version int) (cipher.AEAD, error) {
e.mu.Lock()
defer e.mu.Unlock()
if g, ok := e.gcms[version]; ok {
return g, nil
}
key, err := e.provider.KeyMaterial(ctx, version)
if err != nil {
return nil, fmt.Errorf("resolve key v%d: %w", version, err)
}
if len(key) != 32 {
return nil, fmt.Errorf("key v%d must be 32 bytes (got %d)", version, len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes new cipher v%d: %w", version, err)
}
g, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("new gcm v%d: %w", version, err)
}
e.gcms[version] = g
return g, nil
}
// ActiveVersion reports the version new ciphertext is sealed with.
func (e *KeyedEncryptor) ActiveVersion(ctx context.Context) (int, error) {
return e.store.ActiveVersion(ctx)
}
// EncryptVersion seals plaintext with the active version and returns the
// ciphertext together with the version it was sealed under (to persist into the
// row's key_version column).
func (e *KeyedEncryptor) EncryptVersion(ctx context.Context, plaintext []byte) ([]byte, int, error) {
version, err := e.store.ActiveVersion(ctx)
if err != nil {
return nil, 0, fmt.Errorf("active key version: %w", err)
}
ct, err := e.sealWith(ctx, version, plaintext)
if err != nil {
return nil, 0, err
}
return ct, version, nil
}
// SealWithVersion encrypts plaintext under an explicit version. The re-encrypt
// job uses this to re-seal an old-version blob onto the new active version.
func (e *KeyedEncryptor) SealWithVersion(ctx context.Context, version int, plaintext []byte) ([]byte, error) {
return e.sealWith(ctx, version, plaintext)
}
// sealWith encrypts with a specific version (used by EncryptVersion and the
// re-encrypt job which re-seals onto the new active version).
func (e *KeyedEncryptor) sealWith(ctx context.Context, version int, plaintext []byte) ([]byte, error) {
g, err := e.gcmFor(ctx, version)
if err != nil {
return nil, err
}
nonce := make([]byte, g.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("rand nonce: %w", err)
}
return g.Seal(nonce, nonce, plaintext, nil), nil
}
// DecryptVersion opens a ciphertext that was sealed under the given version.
func (e *KeyedEncryptor) DecryptVersion(ctx context.Context, ciphertext []byte, version int) ([]byte, error) {
g, err := e.gcmFor(ctx, version)
if err != nil {
return nil, err
}
if len(ciphertext) < g.NonceSize() {
return nil, errors.New("ciphertext too short")
}
nonce, ct := ciphertext[:g.NonceSize()], ciphertext[g.NonceSize():]
pt, err := g.Open(nil, nonce, ct, nil)
if err != nil {
return nil, fmt.Errorf("gcm open v%d: %w", version, err)
}
return pt, nil
}
// DecryptAny opens a ciphertext without knowing its key version: it tries the
// active version first, then every lower version down to 1, returning the first
// that authenticates. AES-GCM's auth tag guarantees only the correct key opens a
// blob, so trying versions is safe and lets the read path stay version-agnostic
// (callers need not thread key_version through every SELECT). Version count is
// tiny (1-2 in practice), so the loop is cheap. A version whose key material is
// not configured simply fails to build its AEAD and is skipped.
func (e *KeyedEncryptor) DecryptAny(ctx context.Context, ciphertext []byte) ([]byte, error) {
active, err := e.store.ActiveVersion(ctx)
if err != nil || active < 1 {
active = 1
}
var lastErr error
for v := active; v >= 1; v-- {
pt, derr := e.DecryptVersion(ctx, ciphertext, v)
if derr == nil {
return pt, nil
}
lastErr = derr
}
if lastErr == nil {
lastErr = errors.New("decrypt: no key version available")
}
return nil, lastErr
}
+141
View File
@@ -0,0 +1,141 @@
package crypto
import (
"context"
"crypto/rand"
"sync"
"testing"
"github.com/stretchr/testify/require"
)
// mutableStore is a KeyStore whose active version can change at runtime,
// modeling a rotation.
type mutableStore struct {
mu sync.Mutex
v int
}
func (m *mutableStore) ActiveVersion(context.Context) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.v, nil
}
func (m *mutableStore) set(v int) {
m.mu.Lock()
defer m.mu.Unlock()
m.v = v
}
func rawKey(t *testing.T) []byte {
t.Helper()
k := make([]byte, 32)
_, err := rand.Read(k)
require.NoError(t, err)
return k
}
func TestKeyedEncryptor_RoundTripV1AndV2(t *testing.T) {
ctx := context.Background()
prov := NewEnvProvider(nil)
prov.SetKey(1, rawKey(t))
prov.SetKey(2, rawKey(t))
store := &mutableStore{v: 1}
ke := NewKeyedEncryptor(prov, store)
pt := []byte("super secret value")
ctV1, v, err := ke.EncryptVersion(ctx, pt)
require.NoError(t, err)
require.Equal(t, 1, v)
got, err := ke.DecryptVersion(ctx, ctV1, 1)
require.NoError(t, err)
require.Equal(t, pt, got)
store.set(2)
ctV2, v2, err := ke.EncryptVersion(ctx, pt)
require.NoError(t, err)
require.Equal(t, 2, v2)
require.NotEqual(t, ctV1, ctV2)
got2, err := ke.DecryptVersion(ctx, ctV2, 2)
require.NoError(t, err)
require.Equal(t, pt, got2)
}
func TestKeyedEncryptor_V1BlobDecryptsAfterActiveBumpsToV2(t *testing.T) {
ctx := context.Background()
prov := NewEnvProvider(nil)
prov.SetKey(1, rawKey(t))
prov.SetKey(2, rawKey(t))
store := &mutableStore{v: 1}
ke := NewKeyedEncryptor(prov, store)
pt := []byte("encrypted under v1")
ctV1, v, err := ke.EncryptVersion(ctx, pt)
require.NoError(t, err)
require.Equal(t, 1, v)
// Rotate the active write version; the OLD blob must still decrypt under v1.
store.set(2)
got, err := ke.DecryptVersion(ctx, ctV1, 1)
require.NoError(t, err)
require.Equal(t, pt, got)
}
func TestKeyedEncryptor_WrongVersionDecryptFailsCleanly(t *testing.T) {
ctx := context.Background()
prov := NewEnvProvider(nil)
prov.SetKey(1, rawKey(t))
prov.SetKey(2, rawKey(t))
ke := NewKeyedEncryptor(prov, NewStaticKeyStore(1))
ct, _, err := ke.EncryptVersion(ctx, []byte("x"))
require.NoError(t, err)
// Decrypting a v1 blob as v2 must fail authentication, not panic.
_, err = ke.DecryptVersion(ctx, ct, 2)
require.Error(t, err)
}
func TestKeyedEncryptor_UnknownVersionErrors(t *testing.T) {
ctx := context.Background()
prov := NewEnvProvider(nil)
prov.SetKey(1, rawKey(t))
ke := NewKeyedEncryptor(prov, NewStaticKeyStore(1))
_, err := ke.DecryptVersion(ctx, make([]byte, 16), 9)
require.Error(t, err)
}
func TestEncryptorShim_BacksOntoKeyedV1(t *testing.T) {
// The legacy Encryptor shim must remain byte-compatible: a blob it produces
// decrypts via the underlying KeyedEncryptor at version 1, and vice versa.
key := rawKey(t)
enc, err := NewEncryptor(key)
require.NoError(t, err)
pt := []byte("legacy callers see no versions")
ct, err := enc.Encrypt(pt)
require.NoError(t, err)
got, err := enc.Decrypt(ct)
require.NoError(t, err)
require.Equal(t, pt, got)
// Same key via KeyedEncryptor v1 opens the shim's ciphertext.
prov := NewEnvProvider(key)
ke := NewKeyedEncryptor(prov, NewStaticKeyStore(1))
got2, err := ke.DecryptVersion(context.Background(), ct, 1)
require.NoError(t, err)
require.Equal(t, pt, got2)
}
func TestEnvProvider_ReadsVersionedEnv(t *testing.T) {
t.Setenv("APP_MASTER_KEY_V2", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
prov := NewEnvProvider(nil)
k, err := prov.KeyMaterial(context.Background(), 2)
require.NoError(t, err)
require.Len(t, k, 32)
_, err = prov.KeyMaterial(context.Background(), 3)
require.Error(t, err, "unstaged version must error, not silently use a default")
}
+106
View File
@@ -0,0 +1,106 @@
package crypto
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// PostgresKeyStore reads/writes the crypto_keys table that records which key
// versions exist and which is currently active (the write key). The key
// material is NOT stored here — only version metadata.
type PostgresKeyStore struct {
pool poolExec
}
// poolExec is the subset of *pgxpool.Pool used by the store. Declared as an
// interface so tests can inject a fake without a live DB.
type poolExec interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
// poolFullExec additionally allows mutations (used by RotateActive/RetireVersion).
// Exec returns the CONCRETE pgconn.CommandTag so *pgxpool.Pool satisfies this
// interface — an interface-typed return would not match pgxpool's method
// signature, making the type assertion in RotateActive/RetireVersion silently
// fail and key rotation always error out.
type poolFullExec interface {
poolExec
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}
// NewPostgresKeyStore builds the store over a pgx pool (or any compatible
// querier). It satisfies KeyStore.
func NewPostgresKeyStore(pool poolExec) *PostgresKeyStore {
return &PostgresKeyStore{pool: pool}
}
// ActiveVersion returns the single crypto_keys row whose status='active'. If
// none is found it falls back to version 1 (single-key bootstrap before the
// seed row exists).
func (s *PostgresKeyStore) ActiveVersion(ctx context.Context) (int, error) {
var v int
err := s.pool.QueryRow(ctx,
`SELECT version FROM crypto_keys WHERE status = 'active' ORDER BY version DESC LIMIT 1`,
).Scan(&v)
if err != nil {
if err == pgx.ErrNoRows {
return 1, nil
}
return 0, fmt.Errorf("crypto key active version: %w", err)
}
return v, nil
}
// RotateActive bumps the active write version: it retires the current active
// row and inserts/activates the next version (current+1). The new version's key
// material must already be loadable by the provider (e.g. APP_MASTER_KEY_V<n>
// staged) before this is called. Returns the new active version.
//
// pool must support Exec (a *pgxpool.Pool does). Both rows are written in one
// transaction by the caller's pool if it is transactional; here we issue two
// statements and rely on the unique active invariant being eventually consistent
// within the rotation window (both versions remain loadable, so decrypts never
// break).
func (s *PostgresKeyStore) RotateActive(ctx context.Context, provider string, keyRef string) (int, error) {
fe, ok := s.pool.(poolFullExec)
if !ok {
return 0, fmt.Errorf("crypto key store: pool does not support writes")
}
cur, err := s.ActiveVersion(ctx)
if err != nil {
return 0, err
}
next := cur + 1
if _, err := fe.Exec(ctx,
`UPDATE crypto_keys SET status = 'retiring' WHERE status = 'active'`); err != nil {
return 0, fmt.Errorf("retire current key: %w", err)
}
if _, err := fe.Exec(ctx,
`INSERT INTO crypto_keys (version, provider, key_ref, status)
VALUES ($1, $2, NULLIF($3, ''), 'active')
ON CONFLICT (version) DO UPDATE SET status = 'active', provider = EXCLUDED.provider, key_ref = EXCLUDED.key_ref`,
next, provider, keyRef); err != nil {
return 0, fmt.Errorf("insert next key: %w", err)
}
return next, nil
}
// RetireVersion marks a fully-superseded version as retired (no longer needed
// after the re-encrypt job has moved every row off it). The provider may then
// safely drop that key material.
func (s *PostgresKeyStore) RetireVersion(ctx context.Context, version int) error {
fe, ok := s.pool.(poolFullExec)
if !ok {
return fmt.Errorf("crypto key store: pool does not support writes")
}
if _, err := fe.Exec(ctx,
`UPDATE crypto_keys SET status = 'retired' WHERE version = $1`, version); err != nil {
return fmt.Errorf("retire key v%d: %w", version, err)
}
return nil
}
var _ KeyStore = (*PostgresKeyStore)(nil)
+103
View File
@@ -0,0 +1,103 @@
package crypto
import (
"context"
"encoding/hex"
"fmt"
"os"
"sync"
)
// Provider resolves the raw 32-byte AES-256 key material for a given key
// version. The key material itself never lives in the DB — only the version
// number (in crypto_keys + the per-row key_version column) is persisted; the
// provider maps that version back to actual bytes (env var, Vault, KMS, ...).
type Provider interface {
// KeyMaterial returns the 32-byte key for the given version, or an error if
// the version is unknown / unavailable. Implementations may cache.
KeyMaterial(ctx context.Context, version int) ([]byte, error)
}
// EnvProvider maps key versions to env vars. Version 1 is APP_MASTER_KEY
// (the historical single key); higher versions read APP_MASTER_KEY_V<n> so a
// rotation can stage the next key alongside the current one. The constructor
// also accepts an explicit version-1 key so callers that already decoded
// cfg.MasterKey do not re-read the env.
type EnvProvider struct {
mu sync.RWMutex
keys map[int][]byte
}
// NewEnvProvider builds an EnvProvider seeded with the version-1 key (typically
// the already-decoded cfg.MasterKey). A nil/empty v1 key falls back to reading
// APP_MASTER_KEY lazily on first KeyMaterial(1).
func NewEnvProvider(v1 []byte) *EnvProvider {
p := &EnvProvider{keys: map[int][]byte{}}
if len(v1) > 0 {
p.keys[1] = append([]byte(nil), v1...)
}
return p
}
// KeyMaterial returns the cached key for version, loading it from the matching
// env var on first use. Version 1 → APP_MASTER_KEY; version n>1 →
// APP_MASTER_KEY_V<n>. The value must be 64 hex chars (32 bytes).
func (p *EnvProvider) KeyMaterial(_ context.Context, version int) ([]byte, error) {
if version < 1 {
return nil, fmt.Errorf("crypto: invalid key version %d", version)
}
p.mu.RLock()
k, ok := p.keys[version]
p.mu.RUnlock()
if ok {
return k, nil
}
envName := "APP_MASTER_KEY"
if version > 1 {
envName = fmt.Sprintf("APP_MASTER_KEY_V%d", version)
}
raw := os.Getenv(envName)
if raw == "" {
return nil, fmt.Errorf("crypto: key version %d unavailable (%s unset)", version, envName)
}
key, err := hex.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("crypto: %s invalid hex: %w", envName, err)
}
if len(key) != 32 {
return nil, fmt.Errorf("crypto: %s must be 32 bytes (got %d)", envName, len(key))
}
p.mu.Lock()
p.keys[version] = key
p.mu.Unlock()
return key, nil
}
// SetKey installs key material for a version explicitly (used in tests and when
// a KMS-fetched key is handed to an EnvProvider-style cache).
func (p *EnvProvider) SetKey(version int, key []byte) {
p.mu.Lock()
defer p.mu.Unlock()
p.keys[version] = append([]byte(nil), key...)
}
// VaultProvider / KMSProvider are deferred: their interface is identical to
// EnvProvider (KeyMaterial(ctx, version) -> []byte), so wiring a real
// HashiCorp Vault transit / cloud KMS client is a drop-in replacement. They are
// declared here so config.Provider can select "vault"/"kms" without a build
// break; until a real client is wired they delegate to a backing Provider.
// DelegatingProvider wraps another Provider; it is the shape both VaultProvider
// and KMSProvider take until their remote clients are implemented.
type DelegatingProvider struct{ inner Provider }
// NewDelegatingProvider returns a Provider that forwards to inner.
func NewDelegatingProvider(inner Provider) *DelegatingProvider {
return &DelegatingProvider{inner: inner}
}
// KeyMaterial forwards to the wrapped provider.
func (d *DelegatingProvider) KeyMaterial(ctx context.Context, version int) ([]byte, error) {
return d.inner.KeyMaterial(ctx, version)
}
+44 -35
View File
@@ -3,61 +3,70 @@
//
// The master key is supplied by the caller (see config.MasterKey) and never
// stored alongside the ciphertext.
//
// As of the secret-hardening epic, the underlying engine is KeyedEncryptor:
// every blob is sealed under a key *version* whose number is persisted in a
// per-row key_version column (the key material stays in env/KMS). The legacy
// Encryptor type below is a thin version-1 shim so the ~30 existing call sites
// — which neither know nor care about versions — compile and behave unchanged.
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"context"
"fmt"
"io"
)
// Encryptor 用 AES-256-GCM 加解密 secret。
// 密文格式:nonce(12) || ciphertext || tag。
// Encryptor 用 AES-256-GCM 加解密 secret(version-1 shim over KeyedEncryptor)
// 密文格式:nonce(12) || ciphertext || tag(与历史一致,未变)
type Encryptor struct {
gcm cipher.AEAD
keyed *KeyedEncryptor
version int
}
// NewEncryptor constructs an Encryptor from a 32-byte (AES-256) key.
// Any other key length yields an error rather than silently downgrading.
// It builds a KeyedEncryptor backed by an EnvProvider seeded with this key at
// version 1 and a static version-1 store, preserving the previous semantics.
func NewEncryptor(key []byte) (*Encryptor, error) {
if len(key) != 32 {
return nil, fmt.Errorf("key must be 32 bytes (got %d)", len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes new cipher: %w", err)
keyed := NewKeyedEncryptor(NewEnvProvider(key), NewStaticKeyStore(1))
// Eagerly validate the key by building the v1 AEAD once.
if _, err := keyed.gcmFor(context.Background(), 1); err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("new gcm: %w", err)
}
return &Encryptor{gcm: gcm}, nil
return &Encryptor{keyed: keyed, version: 1}, nil
}
// Encrypt seals plaintext with a fresh random nonce and returns
// nonce || ciphertext || tag.
// NewEncryptorFromKeyed wraps an existing KeyedEncryptor as a fixed-version
// Encryptor shim. version is the version this shim seals/opens with (the call
// sites that use the shim only ever round-trip a single logical version because
// the DB column travels separately). app.go uses this so workspace/chat/acp/run
// keep the *crypto.Encryptor argument while sharing one KeyedEncryptor.
func NewEncryptorFromKeyed(keyed *KeyedEncryptor, version int) *Encryptor {
if version < 1 {
version = 1
}
return &Encryptor{keyed: keyed, version: version}
}
// Keyed exposes the underlying KeyedEncryptor so version-aware callers (the
// re-encrypt job, rotate-key endpoint) can use EncryptVersion/DecryptVersion.
func (e *Encryptor) Keyed() *KeyedEncryptor { return e.keyed }
// Encrypt seals plaintext under the shim's version and returns
// nonce || ciphertext || tag (version travels via the DB key_version column).
func (e *Encryptor) Encrypt(plaintext []byte) ([]byte, error) {
nonce := make([]byte, e.gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("rand nonce: %w", err)
}
// Seal 把 nonce 作为 dst 前缀写入
return e.gcm.Seal(nonce, nonce, plaintext, nil), nil
return e.keyed.sealWith(context.Background(), e.version, plaintext)
}
// Decrypt verifies and opens a ciphertext produced by Encrypt.
// It returns an error if the input is too short or authentication fails.
// Decrypt verifies and opens a ciphertext produced by Encrypt. It is
// version-agnostic: it tries the active key version then falls back through
// older versions (GCM auth guarantees correctness), so blobs re-sealed onto a
// newer version after a key rotation still open without the call site needing to
// know the row's key_version. Returns an error if input is too short or no key
// authenticates.
func (e *Encryptor) Decrypt(ciphertext []byte) ([]byte, error) {
if len(ciphertext) < e.gcm.NonceSize() {
return nil, errors.New("ciphertext too short")
}
nonce, ct := ciphertext[:e.gcm.NonceSize()], ciphertext[e.gcm.NonceSize():]
pt, err := e.gcm.Open(nil, nonce, ct, nil)
if err != nil {
return nil, fmt.Errorf("gcm open: %w", err)
}
return pt, nil
return e.keyed.DecryptAny(context.Background(), ciphertext)
}
+24 -10
View File
@@ -18,20 +18,22 @@ type Code string
// Canonical error codes. Keep this list in sync with HTTPStatus and any
// downstream consumers (e.g., HTTP transport, OpenAPI schemas).
const (
CodeInvalidInput Code = "invalid_input"
CodeUnauthorized Code = "unauthorized"
CodeForbidden Code = "forbidden"
CodeNotFound Code = "not_found"
CodeConflict Code = "conflict"
CodeRateLimited Code = "rate_limited"
CodeUpstream Code = "upstream_error"
CodeUnavailable Code = "unavailable"
CodeInternal Code = "internal"
CodeInvalidInput Code = "invalid_input"
CodeUnauthorized Code = "unauthorized"
CodeForbidden Code = "forbidden"
CodeNotFound Code = "not_found"
CodeConflict Code = "conflict"
CodeFailedPrecondition Code = "failed_precondition"
CodeRateLimited Code = "rate_limited"
CodeUpstream Code = "upstream_error"
CodeUnavailable Code = "unavailable"
CodeInternal Code = "internal"
// Project 模块专用细粒度 code(HTTP 仍 409,但前端可据此区分提示)。
CodeProjectSlugTaken Code = "project_slug_taken"
CodeProjectArchived Code = "project_archived"
CodeRequirementClosed Code = "requirement_closed"
CodePhaseGateFailed Code = "phase_gate_failed"
// Workspace 模块细粒度 code
CodeWorkspaceSlugTaken Code = "workspace_slug_taken"
@@ -108,6 +110,10 @@ const (
CodeRunProfileDisabled Code = "run.profile_disabled"
CodeRunSpawnFailed Code = "run.spawn_failed"
CodeRunCommandInvalid Code = "run.command_invalid"
// 一次性 exec(run_command / run_tests)专用错误。
CodeRunExecTimeout Code = "run.exec_timeout"
CodeRunExecCommandNotAllowed Code = "run.exec_command_not_allowed"
CodeRunExecFailed Code = "run.exec_failed"
)
// AppError is the application's structured error type. It carries a stable
@@ -179,10 +185,12 @@ func HTTPStatus(code Code) int {
case CodeNotFound:
return http.StatusNotFound
case CodeConflict,
CodeProjectSlugTaken, CodeProjectArchived, CodeRequirementClosed,
CodeProjectSlugTaken, CodeProjectArchived, CodeRequirementClosed, CodePhaseGateFailed,
CodeWorkspaceSlugTaken, CodeWorkspaceSyncInProgress,
CodeWorktreeBranchConflict, CodeWorktreeAlreadyActive:
return http.StatusConflict
case CodeFailedPrecondition:
return http.StatusPreconditionFailed
case CodeRateLimited:
return http.StatusTooManyRequests
case CodeUpstream:
@@ -257,6 +265,12 @@ func HTTPStatus(code Code) int {
return http.StatusBadRequest
case CodeRunSpawnFailed:
return http.StatusBadGateway
case CodeRunExecTimeout:
return http.StatusGatewayTimeout
case CodeRunExecCommandNotAllowed:
return http.StatusForbidden
case CodeRunExecFailed:
return http.StatusInternalServerError
default:
return http.StatusInternalServerError
}
+1
View File
@@ -37,6 +37,7 @@ func TestHTTPStatus(t *testing.T) {
CodeProjectSlugTaken: http.StatusConflict,
CodeProjectArchived: http.StatusConflict,
CodeRequirementClosed: http.StatusConflict,
CodePhaseGateFailed: http.StatusConflict,
CodeRateLimited: http.StatusTooManyRequests,
CodeUpstream: http.StatusBadGateway,
CodeUnavailable: http.StatusServiceUnavailable,
+106
View File
@@ -0,0 +1,106 @@
package git
import (
"context"
"fmt"
"strings"
)
// diffMaxBytes caps the raw unified-diff stdout to keep MCP payloads / memory
// bounded. Larger diffs are truncated and flagged via DiffResult.Truncated.
const diffMaxBytes = 1 << 20 // 1 MiB
// DiffOptions controls `git diff` argument building.
//
// - Ref/Against both empty => working-tree vs HEAD (the default).
// - Ref set, Against empty => `git diff <Ref>` (working-tree vs Ref).
// - Ref and Against both set => `git diff <Ref>..<Against>` (two refs).
// - Staged => add `--cached` (index vs HEAD); mutually composes with refs.
// - Paths => restrict to the given pathspecs (passed after `--`).
// - ContextLines => `-U<n>` when > 0 (git default 3 otherwise).
type DiffOptions struct {
Ref string
Against string
Staged bool
Paths []string
ContextLines int
}
// DiffResult carries the raw unified-diff text. The viewer parses it; the git
// layer never parses the hunks. Truncated reports whether stdout exceeded the
// 1 MiB cap and was cut.
type DiffResult struct {
Diff string
Truncated bool
}
// Diff returns the unified diff of dir per opts. The output is the raw `git
// diff --no-color` text (capped at 1 MiB). On a clean tree the diff is empty.
func (r *DefaultRunner) Diff(ctx context.Context, dir string, opts DiffOptions) (DiffResult, error) {
// 安全护栏:ref/against 来自 MCP/HTTP 入参,可能未经分支校验。以 '-' 开头的值会被
// git 当作选项解析(如 --output=、-O<file>),故一律拒绝,杜绝参数注入。
if strings.HasPrefix(opts.Ref, "-") || strings.HasPrefix(opts.Against, "-") {
return DiffResult{}, fmt.Errorf("git diff: ref/against must not begin with '-': %q %q", opts.Ref, opts.Against)
}
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
defer cancel()
args := buildDiffArgs(opts)
out, _, err := r.exec(ctx, dir, nil, args...)
if err != nil {
return DiffResult{}, err
}
res := DiffResult{}
if len(out) > diffMaxBytes {
out = out[:diffMaxBytes]
res.Truncated = true
}
res.Diff = string(out)
return res, nil
}
// buildDiffArgs assembles the `git diff` argument slice from opts. Refs are
// never user-supplied paths (workspace package validates branches), so they are
// passed positionally; pathspecs go after the `--` separator to disambiguate.
func buildDiffArgs(opts DiffOptions) []string {
args := []string{"diff", "--no-color"}
if opts.Staged {
args = append(args, "--cached")
}
if opts.ContextLines > 0 {
args = append(args, "-U"+itoa(opts.ContextLines))
}
switch {
case opts.Ref != "" && opts.Against != "":
args = append(args, opts.Ref+".."+opts.Against)
case opts.Ref != "":
args = append(args, opts.Ref)
}
if len(opts.Paths) > 0 {
args = append(args, "--")
args = append(args, opts.Paths...)
}
return args
}
// itoa is a tiny strconv.Itoa avoiding an import just for one call site.
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+125
View File
@@ -0,0 +1,125 @@
package git
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestBuildDiffArgs(t *testing.T) {
t.Parallel()
cases := []struct {
name string
opts DiffOptions
want []string
}{
{
name: "working tree vs HEAD",
opts: DiffOptions{},
want: []string{"diff", "--no-color"},
},
{
name: "staged",
opts: DiffOptions{Staged: true},
want: []string{"diff", "--no-color", "--cached"},
},
{
name: "single ref",
opts: DiffOptions{Ref: "HEAD~1"},
want: []string{"diff", "--no-color", "HEAD~1"},
},
{
name: "ref range",
opts: DiffOptions{Ref: "main", Against: "feature"},
want: []string{"diff", "--no-color", "main..feature"},
},
{
name: "context lines + paths",
opts: DiffOptions{ContextLines: 5, Paths: []string{"a.txt", "dir/b.go"}},
want: []string{"diff", "--no-color", "-U5", "--", "a.txt", "dir/b.go"},
},
{
name: "staged with ref range and paths",
opts: DiffOptions{Staged: true, Ref: "main", Against: "dev", Paths: []string{"x"}},
want: []string{"diff", "--no-color", "--cached", "main..dev", "--", "x"},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc.want, buildDiffArgs(tc.opts))
})
}
}
func TestDiff_WorkingTreeAndStaged(t *testing.T) {
t.Parallel()
gitAvailable(t)
bare := makeBareRepo(t)
r := newTestRunner(t)
dst := filepath.Join(t.TempDir(), "wc")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
require.NoError(t, r.Clone(ctx, dst, bare, "main", nil))
// clean tree => empty diff
res, err := r.Diff(ctx, dst, DiffOptions{})
require.NoError(t, err)
require.Empty(t, res.Diff)
require.False(t, res.Truncated)
// modify a tracked file => unified diff against working tree
require.NoError(t, os.WriteFile(filepath.Join(dst, "README.md"), []byte("hello\nworld\n"), 0o644))
res, err = r.Diff(ctx, dst, DiffOptions{})
require.NoError(t, err)
require.Contains(t, res.Diff, "diff --git")
require.Contains(t, res.Diff, "+world")
// stage it; working-tree diff now empty, --cached shows the change
mustGit(t, dst, "add", "README.md")
res, err = r.Diff(ctx, dst, DiffOptions{})
require.NoError(t, err)
require.Empty(t, res.Diff)
res, err = r.Diff(ctx, dst, DiffOptions{Staged: true})
require.NoError(t, err)
require.Contains(t, res.Diff, "+world")
}
func TestDiff_PathsFilter(t *testing.T) {
t.Parallel()
gitAvailable(t)
bare := makeBareRepo(t)
r := newTestRunner(t)
dst := filepath.Join(t.TempDir(), "wc")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
require.NoError(t, r.Clone(ctx, dst, bare, "main", nil))
require.NoError(t, os.WriteFile(filepath.Join(dst, "README.md"), []byte("changed\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dst, "other.txt"), []byte("new\n"), 0o644))
mustGit(t, dst, "add", "other.txt")
// restrict to README only => other.txt not in output
res, err := r.Diff(ctx, dst, DiffOptions{Paths: []string{"README.md"}})
require.NoError(t, err)
require.Contains(t, res.Diff, "README.md")
require.False(t, strings.Contains(res.Diff, "other.txt"))
}
func TestDiff_ErrorPropagation(t *testing.T) {
t.Parallel()
gitAvailable(t)
r := newTestRunner(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// not a git repo => git diff fails, error propagates
_, err := r.Diff(ctx, t.TempDir(), DiffOptions{})
require.Error(t, err)
}
+17
View File
@@ -0,0 +1,17 @@
package git
import "context"
// DiffStat 返回单个 commit 的变更摘要(git show --stat),供自动文档生成喂给
// LLM 一个有界的改动概览。commitSHA 由上层从可信来源(git log / commit 返回值)
// 取得,不接受用户任意输入。--no-color 保证输出稳定,-M 检测重命名。
func (r *DefaultRunner) DiffStat(ctx context.Context, dir, commitSHA string) (string, error) {
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
defer cancel()
out, _, err := r.exec(ctx, dir, nil,
"show", "--no-color", "--stat", "--format=%H%n%an%n%s%n%n%b", "-M", commitSHA)
if err != nil {
return "", err
}
return string(out), nil
}
+34
View File
@@ -0,0 +1,34 @@
package git
import (
"bytes"
"context"
)
// ListTrackedFiles 返回 dir 仓库中被 git 跟踪的文件(git ls-files -z),
// 路径相对 worktree 根,NUL 分隔以正确处理含空格/特殊字符的文件名。
// 仅跟踪文件 —— 忽略 .gitignore / 未跟踪 / vendored,避免索引器遍历海量无关文件。
func (r *DefaultRunner) ListTrackedFiles(ctx context.Context, dir string) ([]string, error) {
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
defer cancel()
out, _, err := r.exec(ctx, dir, nil, "ls-files", "-z")
if err != nil {
return nil, err
}
return parseNULList(out), nil
}
// parseNULList 解析 NUL 分隔的文件列表(git ls-files -z)。空输入返回 nil。
func parseNULList(buf []byte) []string {
if len(buf) == 0 {
return nil
}
var out []string
for _, rec := range bytes.Split(buf, []byte{0}) {
if len(rec) == 0 {
continue
}
out = append(out, string(rec))
}
return out
}
+56
View File
@@ -0,0 +1,56 @@
package git
import (
"context"
"os"
"path/filepath"
"sort"
"testing"
"github.com/stretchr/testify/require"
)
func TestListTrackedFiles(t *testing.T) {
gitAvailable(t)
dir := t.TempDir()
mustGit(t, dir, "init", "-b", "main")
mustGit(t, dir, "config", "user.email", "test@example.com")
mustGit(t, dir, "config", "user.name", "Test")
require.NoError(t, os.WriteFile(filepath.Join(dir, "a.go"), []byte("package a\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "sub"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "sub", "b.txt"), []byte("b\n"), 0o644))
// Untracked + ignored files must NOT appear.
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("ignored.txt\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "ignored.txt"), []byte("x\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("y\n"), 0o644))
mustGit(t, dir, "add", "a.go", "sub/b.txt", ".gitignore")
mustGit(t, dir, "commit", "-m", "init")
r := newTestRunner(t)
files, err := r.ListTrackedFiles(context.Background(), dir)
require.NoError(t, err)
sort.Strings(files)
require.Equal(t, []string{".gitignore", "a.go", "sub/b.txt"}, files)
}
func TestDiffStat(t *testing.T) {
gitAvailable(t)
dir := t.TempDir()
mustGit(t, dir, "init", "-b", "main")
mustGit(t, dir, "config", "user.email", "test@example.com")
mustGit(t, dir, "config", "user.name", "Test")
require.NoError(t, os.WriteFile(filepath.Join(dir, "f.go"), []byte("package f\n\nfunc F() {}\n"), 0o644))
mustGit(t, dir, "add", "f.go")
mustGit(t, dir, "commit", "-m", "add F")
r := newTestRunner(t)
// HEAD resolves the just-made commit.
commits, err := r.Log(context.Background(), dir, 1)
require.NoError(t, err)
require.Len(t, commits, 1)
stat, err := r.DiffStat(context.Background(), dir, commits[0].Hash)
require.NoError(t, err)
require.Contains(t, stat, "f.go")
require.Contains(t, stat, "add F") // subject from --format
}
+7
View File
@@ -34,6 +34,13 @@ type Runner interface {
ListRemoteBranches(ctx context.Context, dir string, cred *Credential) ([]string, error)
Checkout(ctx context.Context, dir, branch string) error
Log(ctx context.Context, dir string, n int) ([]Commit, error)
Diff(ctx context.Context, dir string, opts DiffOptions) (DiffResult, error)
// ListTrackedFiles returns the git-tracked files at HEAD (git ls-files),
// worktree-relative, so the indexer never walks ignored/vendored/untracked paths.
ListTrackedFiles(ctx context.Context, dir string) ([]string, error)
// DiffStat returns the `--stat` summary for a single commit (git show --stat),
// used by auto-doc generation to feed the LLM a bounded change overview.
DiffStat(ctx context.Context, dir, commitSHA string) (string, error)
}
// FileStatus 表示 `git status --porcelain=v1` 的一行。
+15 -2
View File
@@ -9,8 +9,12 @@ import (
"github.com/anthropics/anthropic-sdk-go/option"
)
// Compile-time assertion: AnthropicClient must satisfy LLMClient.
var _ LLMClient = (*AnthropicClient)(nil)
// Compile-time assertion: AnthropicClient must satisfy LLMClient + Embedder
// (Embed returns ErrEmbeddingsUnsupported).
var (
_ LLMClient = (*AnthropicClient)(nil)
_ Embedder = (*AnthropicClient)(nil)
)
// AnthropicClient 通过官方 SDK 调用 Anthropic Messages API。
// baseURL 可改写以走 LiteLLM/OpenRouter 等代理。
@@ -152,3 +156,12 @@ func (c *AnthropicClient) CountTokens(ctx context.Context, modelID string, req R
}
return int(res.InputTokens), nil
}
// Embed is unsupported: Anthropic has no embeddings API. Embeddings must target
// an OpenAI-compatible or Gemini endpoint; callers degrade to keyword fallback.
func (c *AnthropicClient) Embed(context.Context, string, []string) ([][]float32, error) {
return nil, ErrEmbeddingsUnsupported
}
// EmbedDims is 0 since Anthropic produces no embeddings.
func (c *AnthropicClient) EmbedDims(string) int { return 0 }
+25
View File
@@ -0,0 +1,25 @@
package llm
import (
"context"
"errors"
)
// ErrEmbeddingsUnsupported is returned by providers that have no embeddings API
// (notably Anthropic). Callers degrade to keyword fallback when they see it.
var ErrEmbeddingsUnsupported = errors.New("llm: embeddings not supported by provider")
// PlatformEmbeddingDims is the single vector dimension baked into the pgvector
// columns at migration time (vector(1536)). Embedding endpoints MUST be
// configured with a model that produces this dimension; the build runner rejects
// mismatched dims rather than corrupting the index.
const PlatformEmbeddingDims = 1536
// Embedder is implemented by providers that expose a text-embeddings endpoint.
// Embed returns one vector per input, in input order. EmbedDims returns the
// vector length the given model produces (used to validate against the fixed
// pgvector column dimension before inserting).
type Embedder interface {
Embed(ctx context.Context, modelID string, inputs []string) ([][]float32, error)
EmbedDims(modelID string) int
}
+129
View File
@@ -0,0 +1,129 @@
package llm
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestOpenAI_Embed_ReturnsVectors(t *testing.T) {
var gotInputs []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Input []string `json:"input"`
Model string `json:"model"`
Dimensions int `json:"dimensions"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
gotInputs = body.Input
require.Equal(t, PlatformEmbeddingDims, body.Dimensions)
// Echo two 3-dim vectors (dimension count here is irrelevant to the test).
resp := map[string]any{
"object": "list",
"data": []map[string]any{
{"object": "embedding", "index": 0, "embedding": []float64{0.1, 0.2, 0.3}},
{"object": "embedding", "index": 1, "embedding": []float64{0.4, 0.5, 0.6}},
},
"model": body.Model,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
c, _ := NewOpenAI("k", srv.URL)
vecs, err := c.Embed(context.Background(), "text-embedding-3-small", []string{"alpha", "beta"})
require.NoError(t, err)
require.Len(t, vecs, 2)
require.Equal(t, []float32{0.1, 0.2, 0.3}, vecs[0])
require.Equal(t, []float32{0.4, 0.5, 0.6}, vecs[1])
require.Equal(t, []string{"alpha", "beta"}, gotInputs)
require.Equal(t, PlatformEmbeddingDims, c.EmbedDims("text-embedding-3-small"))
}
func TestOpenAI_Embed_RespectsResponseIndex(t *testing.T) {
// Server returns vectors out of order; Embed must place them by Index.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"object": "list",
"data": []map[string]any{
{"object": "embedding", "index": 1, "embedding": []float64{9, 9}},
{"object": "embedding", "index": 0, "embedding": []float64{1, 1}},
},
"model": "m",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
c, _ := NewOpenAI("k", srv.URL)
vecs, err := c.Embed(context.Background(), "m", []string{"a", "b"})
require.NoError(t, err)
require.Equal(t, []float32{1, 1}, vecs[0])
require.Equal(t, []float32{9, 9}, vecs[1])
}
func TestOpenAI_Embed_EmptyInputs(t *testing.T) {
c, _ := NewOpenAI("k", "")
vecs, err := c.Embed(context.Background(), "m", nil)
require.NoError(t, err)
require.Nil(t, vecs)
}
func TestAnthropic_Embed_Unsupported(t *testing.T) {
c, _ := NewAnthropic("k", "")
_, err := c.Embed(context.Background(), "m", []string{"x"})
require.ErrorIs(t, err, ErrEmbeddingsUnsupported)
require.Equal(t, 0, c.EmbedDims("m"))
}
func TestFakeEmbedder_Deterministic(t *testing.T) {
e := NewFakeEmbedder()
v1, err := e.Embed(context.Background(), "m", []string{"hello"})
require.NoError(t, err)
v2, err := e.Embed(context.Background(), "m", []string{"hello"})
require.NoError(t, err)
require.Equal(t, v1[0], v2[0], "same input must yield identical vector")
require.Len(t, v1[0], PlatformEmbeddingDims)
other, err := e.Embed(context.Background(), "m", []string{"world"})
require.NoError(t, err)
require.NotEqual(t, v1[0], other[0], "different inputs must differ")
}
func TestFakeEmbedder_Error(t *testing.T) {
e := &FakeEmbedder{Dims: 4, Err: errors.New("boom")}
_, err := e.Embed(context.Background(), "m", []string{"x"})
require.Error(t, err)
}
func TestRegistry_GetEmbedder_TypeAsserts(t *testing.T) {
// OpenAI client implements Embedder.
id := uuid.New()
reg := NewRegistry(&fakeLoader{endpoints: map[uuid.UUID]fakeEndpoint{
id: {ID: id, Provider: "openai", BaseURL: "http://localhost", APIKey: "k"},
}})
emb, err := reg.GetEmbedder(context.Background(), id)
require.NoError(t, err)
require.NotNil(t, emb)
require.Equal(t, PlatformEmbeddingDims, emb.EmbedDims("m"))
}
func TestRegistry_GetEmbedder_AnthropicSupportedButUnsupportedEmbed(t *testing.T) {
// Anthropic client implements Embedder (Embed returns ErrEmbeddingsUnsupported).
id := uuid.New()
reg := NewRegistry(&fakeLoader{endpoints: map[uuid.UUID]fakeEndpoint{
id: {ID: id, Provider: "anthropic", BaseURL: "http://localhost", APIKey: "k"},
}})
emb, err := reg.GetEmbedder(context.Background(), id)
require.NoError(t, err)
_, eerr := emb.Embed(context.Background(), "m", []string{"x"})
require.ErrorIs(t, eerr, ErrEmbeddingsUnsupported)
}
+59
View File
@@ -64,3 +64,62 @@ func (f *FakeClient) Stream(ctx context.Context, modelID string, req Request) (<
func (f *FakeClient) CountTokens(ctx context.Context, modelID string, req Request) (int, error) {
return f.TokenCount, nil
}
// FakeEmbedder is a deterministic Embedder for tests. Each input maps to a
// fixed-length vector derived from its bytes, so identical text always yields the
// same vector (lets tests assert idempotency / ranking without a real endpoint).
type FakeEmbedder struct {
Dims int // output dimension; defaults to PlatformEmbeddingDims when 0
Err error // if non-nil, Embed returns it
mu sync.Mutex
Calls int
Inputs []string // accumulated inputs across calls (for assertions)
}
// NewFakeEmbedder constructs a FakeEmbedder with the platform dimension.
func NewFakeEmbedder() *FakeEmbedder { return &FakeEmbedder{Dims: PlatformEmbeddingDims} }
var _ Embedder = (*FakeEmbedder)(nil)
func (e *FakeEmbedder) dims() int {
if e.Dims > 0 {
return e.Dims
}
return PlatformEmbeddingDims
}
// EmbedDims returns the configured dimension.
func (e *FakeEmbedder) EmbedDims(string) int { return e.dims() }
// Embed returns one deterministic vector per input. The vector is a normalized
// projection of a small hash of the input across the dimension, so distinct
// inputs are distinguishable and identical inputs are identical.
func (e *FakeEmbedder) Embed(_ context.Context, _ string, inputs []string) ([][]float32, error) {
e.mu.Lock()
e.Calls++
e.Inputs = append(e.Inputs, inputs...)
err := e.Err
e.mu.Unlock()
if err != nil {
return nil, err
}
d := e.dims()
out := make([][]float32, len(inputs))
for i, s := range inputs {
v := make([]float32, d)
// Seed from a stable rolling hash of the bytes.
var h uint32 = 2166136261
for _, b := range []byte(s) {
h ^= uint32(b)
h *= 16777619
}
for j := 0; j < d; j++ {
h ^= uint32(j) * 2654435761
h *= 16777619
// Map to [-1,1).
v[j] = float32(int32(h)) / float32(1<<31)
}
out[i] = v
}
return out, nil
}
+39 -2
View File
@@ -13,8 +13,11 @@ type GeminiClient struct {
sdk *genai.Client
}
// Compile-time assertion: GeminiClient must satisfy LLMClient.
var _ LLMClient = (*GeminiClient)(nil)
// Compile-time assertion: GeminiClient must satisfy LLMClient + Embedder.
var (
_ LLMClient = (*GeminiClient)(nil)
_ Embedder = (*GeminiClient)(nil)
)
// NewGemini 构造 GeminiClient。apiKey 不可空;baseURL 可空(用 SDK 默认端点)。
func NewGemini(apiKey, baseURL string) (*GeminiClient, error) {
@@ -142,3 +145,37 @@ func (c *GeminiClient) CountTokens(ctx context.Context, modelID string, req Requ
}
return int(res.TotalTokens), nil
}
// EmbedDims returns the platform-standard embedding dimension. gemini-embedding
// honors OutputDimensionality so Embed pins the output to PlatformEmbeddingDims.
func (c *GeminiClient) EmbedDims(string) int { return PlatformEmbeddingDims }
// Embed calls EmbedContent with all inputs batched as separate Contents, pinning
// OutputDimensionality to the fixed pgvector column width. Returns one vector per
// input in order.
func (c *GeminiClient) Embed(ctx context.Context, modelID string, inputs []string) ([][]float32, error) {
if len(inputs) == 0 {
return nil, nil
}
contents := make([]*genai.Content, len(inputs))
for i, in := range inputs {
contents[i] = &genai.Content{Role: "user", Parts: []*genai.Part{genai.NewPartFromText(in)}}
}
dims := int32(PlatformEmbeddingDims)
resp, err := c.sdk.Models.EmbedContent(ctx, modelID, contents, &genai.EmbedContentConfig{
OutputDimensionality: &dims,
})
if err != nil {
return nil, fmt.Errorf("gemini embeddings: %w", err)
}
if len(resp.Embeddings) != len(inputs) {
return nil, fmt.Errorf("gemini embeddings: got %d vectors for %d inputs", len(resp.Embeddings), len(inputs))
}
out := make([][]float32, len(resp.Embeddings))
for i, e := range resp.Embeddings {
v := make([]float32, len(e.Values))
copy(v, e.Values)
out[i] = v
}
return out, nil
}
+43 -3
View File
@@ -12,8 +12,11 @@ import (
tiktoken "github.com/pkoukk/tiktoken-go"
)
// Compile-time assertion: OpenAIClient must satisfy LLMClient.
var _ LLMClient = (*OpenAIClient)(nil)
// Compile-time assertion: OpenAIClient must satisfy LLMClient + Embedder.
var (
_ LLMClient = (*OpenAIClient)(nil)
_ Embedder = (*OpenAIClient)(nil)
)
// OpenAIClient 通过官方 SDK 调用 Chat Completions API。
// baseURL 改写后兼容 DeepSeek / Qwen / Ollama / vLLM / Moonshot 等 OpenAI-compatible 端点。
@@ -54,7 +57,7 @@ func toOpenAIMessages(msgs []Message) []openai.ChatCompletionMessageParamUnion {
parts = append(parts, openai.ImageContentPart(openai.ChatCompletionContentPartImageImageURLParam{
URL: dataURI,
}))
// document/PDF 不翻译,由上层能力检查拦截
// document/PDF 不翻译,由上层能力检查拦截
}
}
out = append(out, openai.UserMessage(parts))
@@ -199,3 +202,40 @@ func (c *OpenAIClient) CountTokens(_ context.Context, modelID string, req Reques
}
return total, nil
}
// EmbedDims returns the platform-standard embedding dimension. text-embedding-3-*
// models honor the `dimensions` request param, so Embed always pins the output to
// PlatformEmbeddingDims to match the fixed pgvector column width.
func (c *OpenAIClient) EmbedDims(string) int { return PlatformEmbeddingDims }
// Embed calls POST /embeddings once for the whole batch (input order preserved)
// and converts the returned []float64 vectors to []float32 for pgvector storage.
func (c *OpenAIClient) Embed(ctx context.Context, modelID string, inputs []string) ([][]float32, error) {
if len(inputs) == 0 {
return nil, nil
}
resp, err := c.sdk.Embeddings.New(ctx, openai.EmbeddingNewParams{
Model: openai.EmbeddingModel(modelID),
Input: openai.EmbeddingNewParamsInputUnion{OfArrayOfStrings: inputs},
Dimensions: param.NewOpt(int64(PlatformEmbeddingDims)),
EncodingFormat: openai.EmbeddingNewParamsEncodingFormatFloat,
})
if err != nil {
return nil, fmt.Errorf("openai embeddings: %w", err)
}
if len(resp.Data) != len(inputs) {
return nil, fmt.Errorf("openai embeddings: got %d vectors for %d inputs", len(resp.Data), len(inputs))
}
out := make([][]float32, len(resp.Data))
for _, e := range resp.Data {
if e.Index < 0 || int(e.Index) >= len(out) {
return nil, fmt.Errorf("openai embeddings: out-of-range index %d", e.Index)
}
v := make([]float32, len(e.Embedding))
for i, f := range e.Embedding {
v[i] = float32(f)
}
out[e.Index] = v
}
return out, nil
}
+15
View File
@@ -63,6 +63,21 @@ func (r *Registry) GetClient(ctx context.Context, id uuid.UUID) (LLMClient, erro
return c, nil
}
// GetEmbedder 返回该 endpoint 对应的 Embedder(OpenAI / Gemini 实现;Anthropic
// 客户端虽实现接口但 Embed 返回 ErrEmbeddingsUnsupported)。底层复用 GetClient
// 的缓存与构造,再做类型断言。endpoint 的 client 未实现 Embedder 时返回错误。
func (r *Registry) GetEmbedder(ctx context.Context, id uuid.UUID) (Embedder, error) {
c, err := r.GetClient(ctx, id)
if err != nil {
return nil, err
}
emb, ok := c.(Embedder)
if !ok {
return nil, fmt.Errorf("llm registry: endpoint %s provider %q does not support embeddings", id, c.Provider())
}
return emb, nil
}
// Invalidate 删除该 endpoint 的缓存条目。endpoint CRUD 后由 service 层调用。
func (r *Registry) Invalidate(id uuid.UUID) {
r.mu.Lock()
+108
View File
@@ -0,0 +1,108 @@
// Package metrics 提供进程内的 Prometheus 指标注册中心与一组类型化的记录助手。
//
// 设计要点:
// - 用私有 *prometheus.Registry(NOT prometheus.DefaultRegisterer),避免在
// 热重载 / 测试中重复注册默认注册表导致 panic。
// - 通过 promhttp.HandlerFor(reg, ...) 暴露 Handler();调用方(router)负责
// 鉴权门禁(/metrics 泄露成本/会话数,必须 admin/内网受限)。
// - Record* 助手对外提供窄 API:其它模块只依赖这几个方法(或 acp 包内定义的
// 窄接口),无需 import prometheus。
//
// 指标清单(spec §11 §1205-1207):
// - acp_sessions_active GaugeFunc —— 当前活跃 ACP 会话数
// - acp_session_exit_total{status} CounterVec —— 会话退出计数(exited/crashed/…)
// - acp_permission_decisions_total{outcome} CounterVec —— 权限决策计数
// (auto/approved/denied/expired)
// - jobs_dead_letter_total{type} CounterVec —— 死信任务计数(按 job type)
// - llm_cost_usd_total GaugeFunc —— 累计 LLM 成本(USD)
package metrics
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// Metrics 持有私有注册表与各 collector。零值不可用,必须经 New 构造。
type Metrics struct {
reg *prometheus.Registry
sessionExits *prometheus.CounterVec
permDecisions *prometheus.CounterVec
deadLetters *prometheus.CounterVec
}
// New 构造 Metrics。activeSessions / llmCostTotal 是供 GaugeFunc 回调的取值
// 函数;二者可为 nil(nil 时对应 gauge 恒为 0),便于测试 / feature-off。
func New(activeSessions func() float64, llmCostTotal func() float64) *Metrics {
reg := prometheus.NewRegistry()
m := &Metrics{
reg: reg,
sessionExits: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "acp_session_exit_total",
Help: "Total number of ACP agent session exits, labeled by terminal status.",
}, []string{"status"}),
permDecisions: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "acp_permission_decisions_total",
Help: "Total number of ACP tool-permission decisions, labeled by outcome.",
}, []string{"outcome"}),
deadLetters: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "jobs_dead_letter_total",
Help: "Total number of jobs that exhausted retries and were dead-lettered, by job type.",
}, []string{"type"}),
}
reg.MustRegister(m.sessionExits, m.permDecisions, m.deadLetters)
if activeSessions == nil {
activeSessions = func() float64 { return 0 }
}
if llmCostTotal == nil {
llmCostTotal = func() float64 { return 0 }
}
reg.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "acp_sessions_active",
Help: "Current number of active (starting/running) ACP agent sessions.",
}, activeSessions))
reg.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "llm_cost_usd_total",
Help: "Cumulative LLM usage cost in USD across all endpoints.",
}, llmCostTotal))
return m
}
// Handler 返回 /metrics 的 http.Handler,绑定到本 Metrics 的私有注册表。
func (m *Metrics) Handler() http.Handler {
return promhttp.HandlerFor(m.reg, promhttp.HandlerOpts{})
}
// Registry 暴露底层注册表,便于测试直接 Gather。
func (m *Metrics) Registry() *prometheus.Registry { return m.reg }
// RecordSessionExit 记录一次会话退出。status 取 acp.SessionStatus 字符串值
// (exited/crashed/…)。
func (m *Metrics) RecordSessionExit(status string) {
if m == nil {
return
}
m.sessionExits.WithLabelValues(status).Inc()
}
// RecordPermissionDecision 记录一次权限决策。outcome 取 auto/approved/denied/expired。
func (m *Metrics) RecordPermissionDecision(outcome string) {
if m == nil {
return
}
m.permDecisions.WithLabelValues(outcome).Inc()
}
// RecordDeadLetter 记录一次死信任务,按 job type 标签累加。
func (m *Metrics) RecordDeadLetter(jobType string) {
if m == nil {
return
}
m.deadLetters.WithLabelValues(jobType).Inc()
}
+90
View File
@@ -0,0 +1,90 @@
package metrics
import (
"io"
"net/http/httptest"
"strings"
"testing"
)
func scrape(t *testing.T, m *Metrics) string {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/metrics", nil)
m.Handler().ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("metrics handler status = %d, want 200", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
return string(body)
}
func TestNewRegistersAllCollectors(t *testing.T) {
m := New(func() float64 { return 3 }, func() float64 { return 12.5 })
// CounterVecs emit no series until a label set is observed; touch each once
// so the metric family name surfaces in the scrape.
m.RecordSessionExit("exited")
m.RecordPermissionDecision("auto")
m.RecordDeadLetter("webhook.deliver")
body := scrape(t, m)
for _, name := range []string{
"acp_sessions_active",
"acp_session_exit_total",
"acp_permission_decisions_total",
"jobs_dead_letter_total",
"llm_cost_usd_total",
} {
if !strings.Contains(body, name) {
t.Errorf("metrics output missing %q\n%s", name, body)
}
}
// GaugeFunc values surface immediately even without any Record* call.
if !strings.Contains(body, "acp_sessions_active 3") {
t.Errorf("acp_sessions_active gauge not reporting func value; body:\n%s", body)
}
if !strings.Contains(body, "llm_cost_usd_total 12.5") {
t.Errorf("llm_cost_usd_total gauge not reporting func value; body:\n%s", body)
}
}
func TestRecordHelpersIncrementWithLabels(t *testing.T) {
m := New(nil, nil)
m.RecordSessionExit("crashed")
m.RecordSessionExit("crashed")
m.RecordSessionExit("exited")
m.RecordPermissionDecision("approved")
m.RecordPermissionDecision("denied")
m.RecordPermissionDecision("expired")
m.RecordDeadLetter("webhook.deliver")
body := scrape(t, m)
wants := []string{
`acp_session_exit_total{status="crashed"} 2`,
`acp_session_exit_total{status="exited"} 1`,
`acp_permission_decisions_total{outcome="approved"} 1`,
`acp_permission_decisions_total{outcome="denied"} 1`,
`acp_permission_decisions_total{outcome="expired"} 1`,
`jobs_dead_letter_total{type="webhook.deliver"} 1`,
}
for _, w := range wants {
if !strings.Contains(body, w) {
t.Errorf("metrics output missing %q\n%s", w, body)
}
}
}
func TestRegistryIsolatedNotGlobalDefault(t *testing.T) {
// Two independent Metrics must not collide (would panic if both used the
// global default registry).
_ = New(nil, nil)
_ = New(nil, nil)
}
func TestNilReceiverRecordSafe(t *testing.T) {
var m *Metrics
// Must not panic — callers may hold a nil *Metrics when metrics disabled.
m.RecordSessionExit("exited")
m.RecordPermissionDecision("auto")
m.RecordDeadLetter("x")
}
+11
View File
@@ -21,6 +21,8 @@ type Dispatcher struct {
log *slog.Logger
now func() time.Time
wg sync.WaitGroup
// hub 是可选的实时推送中心;落库成功后 Publish。nil 时仅持久化 + 外发通道。
hub StreamHub
}
// NewDispatcher 用 Repository 与 Logger 构造 Dispatcher。注册 Notifier 通过
@@ -35,6 +37,11 @@ func (d *Dispatcher) Register(n Notifier) {
d.notifiers = append(d.notifiers, n)
}
// SetStreamHub 注入实时推送中心(装配期)。落库成功后会向其 Publish。
func (d *Dispatcher) SetStreamHub(h StreamHub) {
d.hub = h
}
// Dispatch 同步落库;落库失败返回错误,不再异步投递。落库成功后,
// fire-and-forget 派发给所有外部 notifier,单个 notifier 的失败仅记日志,
// 不阻塞其它通道;调用方无法感知异步投递结果。
@@ -54,6 +61,10 @@ func (d *Dispatcher) Dispatch(ctx context.Context, msg Message) error {
if err := d.repo.Insert(persistCtx, msg); err != nil {
return err
}
// 落库成功后才推送实时事件(推送失败 / 慢订阅者不影响落库的权威 inbox)。
if d.hub != nil {
d.hub.Publish(msg.UserID, msg)
}
for _, n := range d.notifiers {
d.wg.Add(1)
go func(n Notifier) {
+91
View File
@@ -11,6 +11,8 @@
package notify
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
@@ -23,12 +25,23 @@ import (
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
)
// Encryptor 是 prefs handler 加密 im_webhook_url 所需的窄接口,由 crypto.Encryptor
// 满足。以接口注入避免 notify 依赖 crypto 包。
type Encryptor interface {
Encrypt(plaintext []byte) ([]byte, error)
}
// Handler 暴露 notify 模块的 HTTP 接口;依赖 Repository 与 SessionResolver。
// 不直接持有 Dispatcher——发送通知由其它模块通过 Dispatcher 完成,handler
// 仅服务 inbox 读取与状态变更。
type Handler struct {
repo Repository
resolver middleware.SessionResolver
// hub 为可选的实时推送中心;非 nil 时暴露 GET /stream SSE 端点。
hub StreamHub
// prefs/enc 为可选的偏好仓库与加密器;二者均非 nil 时暴露 GET/PUT /prefs。
prefs PrefsRepository
enc Encryptor
}
// NewHandler 构造 notify 模块的 HTTP handler。
@@ -36,6 +49,19 @@ func NewHandler(repo Repository, resolver middleware.SessionResolver) *Handler {
return &Handler{repo: repo, resolver: resolver}
}
// WithStreamHub 注入实时推送中心,启用 GET /api/v1/notifications/stream SSE。
func (h *Handler) WithStreamHub(hub StreamHub) *Handler {
h.hub = hub
return h
}
// WithPrefs 注入偏好仓库与加密器,启用 GET/PUT /api/v1/notifications/prefs。
func (h *Handler) WithPrefs(prefs PrefsRepository, enc Encryptor) *Handler {
h.prefs = prefs
h.enc = enc
return h
}
// Mount 把 inbox 路由挂到给定 chi.Router 上。Auth 中间件在子路由内统一注入。
func (h *Handler) Mount(r chi.Router) {
r.Route("/api/v1/notifications", func(r chi.Router) {
@@ -44,9 +70,74 @@ func (h *Handler) Mount(r chi.Router) {
r.Get("/unread-count", h.unreadCount)
r.Patch("/read-all", h.readAll)
r.Patch("/{id}/read", h.markRead)
if h.hub != nil {
r.Get("/stream", h.stream)
}
if h.prefs != nil && h.enc != nil {
r.Get("/prefs", h.getPrefs)
r.Put("/prefs", h.putPrefs)
}
})
}
// stream 实现 GET /api/v1/notifications/stream:把该用户的实时通知以 SSE 推送。
// 客户端用 EventSource 订阅;连接关闭时(r.Context().Done)自动退订。
func (h *Handler) stream(w http.ResponseWriter, r *http.Request) {
uid, ok := middleware.UserIDFromContext(r.Context())
if !ok {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
errs.New(errs.CodeUnauthorized, "未认证"))
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
ch, cancel := h.hub.Subscribe(uid)
defer cancel()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
for {
select {
case <-r.Context().Done():
return
case m, ok := <-ch:
if !ok {
return
}
writeNotificationSSE(w, flusher, m)
}
}
}
// writeNotificationSSE 把一条通知序列化为 SSE 帧并 flush。
func writeNotificationSSE(w http.ResponseWriter, f http.Flusher, m Message) {
dto := notificationDTO{
ID: m.ID.String(),
Topic: m.Topic,
Severity: string(m.Severity),
Title: m.Title,
Body: m.Body,
Link: m.Link,
Metadata: m.Metadata,
CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
}
if m.ReadAt != nil {
s := m.ReadAt.UTC().Format(time.RFC3339)
dto.ReadAt = &s
}
data, _ := json.Marshal(dto)
fmt.Fprintf(w, "event: notification\ndata: %s\n\n", data)
f.Flush()
}
// notificationDTO 是 inbox 单条通知的对外 JSON 形态。created_at/read_at
// 统一序列化为 RFC3339 字符串;read_at 为 nil 时省略(omitempty)。
type notificationDTO struct {
+141
View File
@@ -0,0 +1,141 @@
// Package notify 内的 notifier_email.go 实现 EmailNotifier:通过 SMTP(net/smtp)
// 把通知投递为邮件。它实现 Notifier 接口,由 Dispatcher 在配置启用时注册。
//
// 投递前置条件(任一不满足则静默跳过,返回 nil——per-channel 失败/跳过对
// Dispatcher 非致命):
// - 全局 cfg.Enabled 为 true;
// - 用户 notification_prefs.email_enabled 为 true;
// - 消息 severity 达到用户 min_severity;
// - 能解析出该用户的收件邮箱。
//
// SMTP 调用经 transport 抽象注入,测试用 fake transport 断言收件人/正文形态,
// 绝不真的发邮件;生产用 smtpTransport(net/smtp.SendMail)。
package notify
import (
"context"
"fmt"
"log/slog"
"net/smtp"
"strings"
"github.com/google/uuid"
)
// EmailConfig 是 EmailNotifier 的配置,对应 cfg.Notify.Email。
type EmailConfig struct {
Enabled bool
SMTPHost string
Port int
From string
Username string
Password string
}
// addr 返回 "host:port" 形式的 SMTP 地址。
func (c EmailConfig) addr() string {
return fmt.Sprintf("%s:%d", c.SMTPHost, c.Port)
}
// EmailLookup 把 userID 解析为收件邮箱。由 user.Service 适配注入,避免 notify
// 反向依赖 user 包造成的循环导入。
type EmailLookup interface {
EmailForUser(ctx context.Context, userID uuid.UUID) (string, error)
}
// SMTPTransport 抽象一次 SMTP 发送,便于测试注入 fake。参数与 net/smtp.SendMail
// 对齐:addr 形如 "host:port",auth 可为 nil(无认证),from 为发件人,to 为收件人
// 列表,msg 为完整 RFC 822 报文(含头)。
type SMTPTransport interface {
Send(addr string, auth smtp.Auth, from string, to []string, msg []byte) error
}
// smtpTransport 是 SMTPTransport 的生产实现,直接委托 net/smtp.SendMail。
type smtpTransport struct{}
func (smtpTransport) Send(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
return smtp.SendMail(addr, auth, from, to, msg)
}
// EmailNotifier 通过 SMTP 投递邮件,实现 Notifier。
type EmailNotifier struct {
cfg EmailConfig
prefs PrefsRepository
lookup EmailLookup
tr SMTPTransport
log *slog.Logger
}
// NewEmailNotifier 构造 EmailNotifier。tr 为 nil 时用生产 net/smtp 实现;
// log 为 nil 时回落到 slog.Default。
func NewEmailNotifier(cfg EmailConfig, prefs PrefsRepository, lookup EmailLookup, tr SMTPTransport, log *slog.Logger) *EmailNotifier {
if tr == nil {
tr = smtpTransport{}
}
if log == nil {
log = slog.Default()
}
return &EmailNotifier{cfg: cfg, prefs: prefs, lookup: lookup, tr: tr, log: log}
}
// Name 返回 ChannelEmail。
func (n *EmailNotifier) Name() Channel { return ChannelEmail }
// Send 在满足全部前置条件时发送一封邮件;任一条件不满足静默返回 nil。
// SMTP 调用失败返回错误(Dispatcher 仅记日志,不影响其它通道)。
func (n *EmailNotifier) Send(ctx context.Context, msg Message) error {
if !n.cfg.Enabled {
return nil
}
p, err := n.prefs.Get(ctx, msg.UserID)
if err != nil {
return err
}
if !p.EmailEnabled {
return nil
}
if !meetsMinSeverity(msg.Severity, p.MinSeverity) {
return nil
}
to, err := n.lookup.EmailForUser(ctx, msg.UserID)
if err != nil {
return err
}
if strings.TrimSpace(to) == "" {
return nil
}
var auth smtp.Auth
if n.cfg.Username != "" {
auth = smtp.PlainAuth("", n.cfg.Username, n.cfg.Password, n.cfg.SMTPHost)
}
body := buildEmailMessage(n.cfg.From, to, msg)
if err := n.tr.Send(n.cfg.addr(), auth, n.cfg.From, []string{to}, body); err != nil {
return fmt.Errorf("smtp send: %w", err)
}
return nil
}
// buildEmailMessage 组装一封最小 RFC 822 纯文本邮件报文(含 From/To/Subject 头)。
// 主题前缀用 severity 便于客户端过滤;正文带标题、正文与可选链接。
func buildEmailMessage(from, to string, msg Message) []byte {
subject := msg.Title
if subject == "" {
subject = msg.Topic
}
var b strings.Builder
fmt.Fprintf(&b, "From: %s\r\n", from)
fmt.Fprintf(&b, "To: %s\r\n", to)
fmt.Fprintf(&b, "Subject: [%s] %s\r\n", strings.ToUpper(string(msg.Severity)), subject)
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n")
b.WriteString("\r\n")
if msg.Body != "" {
b.WriteString(msg.Body)
b.WriteString("\r\n")
}
if msg.Link != "" {
fmt.Fprintf(&b, "\r\n%s\r\n", msg.Link)
}
return []byte(b.String())
}
@@ -0,0 +1,195 @@
package notify
import (
"context"
"errors"
"net/smtp"
"strings"
"sync"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
// fakePrefsRepo 是 PrefsRepository 的内存实现,供 notifier 测试断言偏好过滤。
type fakePrefsRepo struct {
mu sync.Mutex
m map[uuid.UUID]Prefs
err error
}
func newFakePrefs() *fakePrefsRepo { return &fakePrefsRepo{m: map[uuid.UUID]Prefs{}} }
func (f *fakePrefsRepo) set(p Prefs) {
f.mu.Lock()
defer f.mu.Unlock()
f.m[p.UserID] = p
}
func (f *fakePrefsRepo) Get(_ context.Context, userID uuid.UUID) (Prefs, error) {
if f.err != nil {
return Prefs{}, f.err
}
f.mu.Lock()
defer f.mu.Unlock()
if p, ok := f.m[userID]; ok {
return p, nil
}
return DefaultPrefs(userID), nil
}
func (f *fakePrefsRepo) Upsert(_ context.Context, p Prefs) error {
if f.err != nil {
return f.err
}
f.set(p)
return nil
}
// captureTransport 记录最后一次 SMTP 发送,便于断言收件人 / 报文形态。
type captureTransport struct {
mu sync.Mutex
calls int
addr string
from string
to []string
msg []byte
auth smtp.Auth
}
func (c *captureTransport) Send(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
c.calls++
c.addr = addr
c.from = from
c.to = to
c.msg = msg
c.auth = auth
return nil
}
// staticLookup 总是返回固定收件邮箱。
type staticLookup struct {
email string
err error
}
func (s staticLookup) EmailForUser(context.Context, uuid.UUID) (string, error) {
return s.email, s.err
}
func emailCfg() EmailConfig {
return EmailConfig{Enabled: true, SMTPHost: "smtp.test", Port: 587, From: "noreply@test"}
}
func TestEmailNotifier_SendsWhenEnabledAndSeverityMet(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityWarning})
tr := &captureTransport{}
n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "user@test"}, tr, newTestLogger())
err := n.Send(context.Background(), Message{
UserID: uid, Topic: "acp.permission_pending", Severity: SeverityWarning,
Title: "Approval needed", Body: "session X wants to run rm", Link: "/approvals",
})
require.NoError(t, err)
require.Equal(t, 1, tr.calls)
require.Equal(t, "smtp.test:587", tr.addr)
require.Equal(t, "noreply@test", tr.from)
require.Equal(t, []string{"user@test"}, tr.to)
require.Nil(t, tr.auth, "无 username 时不应携带 auth")
body := string(tr.msg)
require.Contains(t, body, "To: user@test")
require.Contains(t, body, "Subject: [WARNING] Approval needed")
require.Contains(t, body, "session X wants to run rm")
require.Contains(t, body, "/approvals")
}
func TestEmailNotifier_DisabledChannelSkipped(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(Prefs{UserID: uid, EmailEnabled: false, MinSeverity: SeverityInfo})
tr := &captureTransport{}
n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "user@test"}, tr, newTestLogger())
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
require.Equal(t, 0, tr.calls, "email_enabled=false 应跳过发送")
}
func TestEmailNotifier_BelowMinSeveritySkipped(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityError})
tr := &captureTransport{}
n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "user@test"}, tr, newTestLogger())
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityWarning}))
require.Equal(t, 0, tr.calls, "低于 min_severity 应跳过")
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
require.Equal(t, 1, tr.calls, "达到 min_severity 应发送")
}
func TestEmailNotifier_GloballyDisabledSkipped(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityInfo})
tr := &captureTransport{}
cfg := emailCfg()
cfg.Enabled = false
n := NewEmailNotifier(cfg, prefs, staticLookup{email: "user@test"}, tr, newTestLogger())
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
require.Equal(t, 0, tr.calls)
}
func TestEmailNotifier_NoRecipientSkipped(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityInfo})
tr := &captureTransport{}
n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: " "}, tr, newTestLogger())
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
require.Equal(t, 0, tr.calls, "空收件人应静默跳过")
}
func TestEmailNotifier_UsesPlainAuthWhenUsernameSet(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityInfo})
tr := &captureTransport{}
cfg := emailCfg()
cfg.Username = "u"
cfg.Password = "p"
n := NewEmailNotifier(cfg, prefs, staticLookup{email: "user@test"}, tr, newTestLogger())
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError, Title: "x"}))
require.Equal(t, 1, tr.calls)
require.NotNil(t, tr.auth, "配置 username 时应携带 PlainAuth")
}
func TestEmailNotifier_NameIsEmailChannel(t *testing.T) {
n := NewEmailNotifier(emailCfg(), newFakePrefs(), staticLookup{}, &captureTransport{}, newTestLogger())
require.Equal(t, ChannelEmail, n.Name())
}
func TestBuildEmailMessage_Headers(t *testing.T) {
body := string(buildEmailMessage("from@test", "to@test", Message{
Topic: "t", Severity: SeverityError, Title: "Title", Body: "Body", Link: "http://l",
}))
require.True(t, strings.HasPrefix(body, "From: from@test\r\n"))
require.Contains(t, body, "Subject: [ERROR] Title")
require.Contains(t, body, "Content-Type: text/plain")
}
func TestEmailNotifier_PrefsErrorPropagates(t *testing.T) {
prefs := newFakePrefs()
prefs.err = errors.New("db down")
n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "u@t"}, &captureTransport{}, newTestLogger())
require.Error(t, n.Send(context.Background(), Message{UserID: uuid.New(), Severity: SeverityError}))
}
+131
View File
@@ -0,0 +1,131 @@
// Package notify 内的 notifier_im.go 实现 IMNotifier:以通用 webhook POST
// (Slack incoming-webhook 兼容形态)把通知投递到用户配置的 IM 地址。
//
// 投递地址是 per-user 的 im_webhook_url,以密文落库于 notification_prefs
// (im_webhook_url_enc);发送时经 Decryptor 解密,明文 URL 不在领域内存层
// 长期驻留。投递前置条件与 EmailNotifier 对称:
// - 全局 cfg.Enabled 为 true;
// - 用户 im_enabled 为 true 且配置了 webhook URL;
// - 消息 severity 达到用户 min_severity。
//
// 任一不满足静默返回 nil;HTTP/解密失败返回错误(Dispatcher 仅记日志)。
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
)
// IMConfig 是 IMNotifier 的全局配置,对应 cfg.Notify.IM。
type IMConfig struct {
Enabled bool
}
// Decryptor 抽象 im_webhook_url 密文的解密。由 crypto.Encryptor 满足
// (Decrypt([]byte) ([]byte, error)),以窄接口注入避免 notify 依赖 crypto 包。
type Decryptor interface {
Decrypt(ciphertext []byte) ([]byte, error)
}
// httpDoer 抽象单次 HTTP 调用,便于测试注入 fake transport。
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
// IMNotifier 通过 webhook POST 投递 IM 消息,实现 Notifier。
type IMNotifier struct {
cfg IMConfig
prefs PrefsRepository
dec Decryptor
cl httpDoer
log *slog.Logger
}
// NewIMNotifier 构造 IMNotifier。cl 为 nil 时用默认 http.Client(含 30s 超时
// 由 Dispatcher 的 sendCtx 兜底);log 为 nil 时回落 slog.Default。
func NewIMNotifier(cfg IMConfig, prefs PrefsRepository, dec Decryptor, cl httpDoer, log *slog.Logger) *IMNotifier {
if cl == nil {
cl = http.DefaultClient
}
if log == nil {
log = slog.Default()
}
return &IMNotifier{cfg: cfg, prefs: prefs, dec: dec, cl: cl, log: log}
}
// Name 返回 ChannelIM。
func (n *IMNotifier) Name() Channel { return ChannelIM }
// Send 在满足全部前置条件时向用户的 IM webhook POST 一条消息。
func (n *IMNotifier) Send(ctx context.Context, msg Message) error {
if !n.cfg.Enabled {
return nil
}
p, err := n.prefs.Get(ctx, msg.UserID)
if err != nil {
return err
}
if !p.IMEnabled || len(p.ImWebhookURLEnc) == 0 {
return nil
}
if !meetsMinSeverity(msg.Severity, p.MinSeverity) {
return nil
}
urlBytes, err := n.dec.Decrypt(p.ImWebhookURLEnc)
if err != nil {
return fmt.Errorf("decrypt im webhook url: %w", err)
}
url := strings.TrimSpace(string(urlBytes))
if url == "" {
return nil
}
payload, err := json.Marshal(buildIMPayload(msg))
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := n.cl.Do(req)
if err != nil {
return fmt.Errorf("im webhook post: %w", err)
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("im webhook status %d", resp.StatusCode)
}
return nil
}
// buildIMPayload 把 Message 渲染为 Slack incoming-webhook 兼容的 JSON 体。
// 主要字段是 text(含级别、标题、正文、可选链接);附加结构化字段方便富客户端。
func buildIMPayload(msg Message) map[string]any {
title := msg.Title
if title == "" {
title = msg.Topic
}
var b strings.Builder
fmt.Fprintf(&b, "[%s] %s", strings.ToUpper(string(msg.Severity)), title)
if msg.Body != "" {
fmt.Fprintf(&b, "\n%s", msg.Body)
}
if msg.Link != "" {
fmt.Fprintf(&b, "\n%s", msg.Link)
}
return map[string]any{
"text": b.String(),
"topic": msg.Topic,
"severity": string(msg.Severity),
"title": title,
}
}
+177
View File
@@ -0,0 +1,177 @@
package notify
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"sync"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
// reverseDecryptor 是测试用 Decryptor:把密文逐字节取反作为 "解密",与
// reverseEncrypt 配对,确保 IMNotifier 走解密路径而非裸读明文。
type reverseDecryptor struct{ err error }
func reverseBytes(b []byte) []byte {
out := make([]byte, len(b))
for i, c := range b {
out[i] = ^c
}
return out
}
func (d reverseDecryptor) Decrypt(ct []byte) ([]byte, error) {
if d.err != nil {
return nil, d.err
}
return reverseBytes(ct), nil
}
// captureDoer 记录最后一次 HTTP 请求并返回固定响应码。
type captureDoer struct {
mu sync.Mutex
calls int
url string
body []byte
ctype string
status int
err error
}
func (c *captureDoer) Do(req *http.Request) (*http.Response, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.err != nil {
return nil, c.err
}
c.calls++
c.url = req.URL.String()
c.ctype = req.Header.Get("Content-Type")
if req.Body != nil {
c.body, _ = io.ReadAll(req.Body)
}
st := c.status
if st == 0 {
st = http.StatusOK
}
return &http.Response{StatusCode: st, Body: io.NopCloser(strings.NewReader("ok"))}, nil
}
func imPrefs(uid uuid.UUID, enabled bool, sev Severity, webhookEnc []byte) Prefs {
return Prefs{UserID: uid, IMEnabled: enabled, MinSeverity: sev, ImWebhookURLEnc: webhookEnc}
}
func TestIMNotifier_PostsDecryptedWebhookWithSlackPayload(t *testing.T) {
uid := uuid.New()
enc := reverseBytes([]byte("https://hooks.slack.test/abc"))
prefs := newFakePrefs()
prefs.set(imPrefs(uid, true, SeverityWarning, enc))
doer := &captureDoer{}
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
err := n.Send(context.Background(), Message{
UserID: uid, Topic: "acp.permission_pending", Severity: SeverityWarning,
Title: "Approval needed", Body: "run rm", Link: "/approvals",
})
require.NoError(t, err)
require.Equal(t, 1, doer.calls)
require.Equal(t, "https://hooks.slack.test/abc", doer.url, "应 POST 到解密后的 URL")
require.Equal(t, "application/json", doer.ctype)
var payload map[string]any
require.NoError(t, json.Unmarshal(doer.body, &payload))
require.Equal(t, "acp.permission_pending", payload["topic"])
require.Equal(t, "warning", payload["severity"])
require.Equal(t, "Approval needed", payload["title"])
text, _ := payload["text"].(string)
require.Contains(t, text, "[WARNING] Approval needed")
require.Contains(t, text, "run rm")
require.Contains(t, text, "/approvals")
}
func TestIMNotifier_DisabledChannelSkipped(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(imPrefs(uid, false, SeverityInfo, reverseBytes([]byte("https://x"))))
doer := &captureDoer{}
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
require.Equal(t, 0, doer.calls)
}
func TestIMNotifier_NoWebhookSkipped(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(imPrefs(uid, true, SeverityInfo, nil))
doer := &captureDoer{}
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
require.Equal(t, 0, doer.calls, "未配置 webhook 应跳过")
}
func TestIMNotifier_BelowMinSeveritySkipped(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(imPrefs(uid, true, SeverityError, reverseBytes([]byte("https://x"))))
doer := &captureDoer{}
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityWarning}))
require.Equal(t, 0, doer.calls)
}
func TestIMNotifier_GloballyDisabledSkipped(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(imPrefs(uid, true, SeverityInfo, reverseBytes([]byte("https://x"))))
doer := &captureDoer{}
n := NewIMNotifier(IMConfig{Enabled: false}, prefs, reverseDecryptor{}, doer, newTestLogger())
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
require.Equal(t, 0, doer.calls)
}
func TestIMNotifier_DecryptErrorReturnsErr(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(imPrefs(uid, true, SeverityInfo, []byte("garbage")))
doer := &captureDoer{}
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{err: errors.New("bad key")}, doer, newTestLogger())
require.Error(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
require.Equal(t, 0, doer.calls)
}
func TestIMNotifier_Non2xxReturnsErr(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(imPrefs(uid, true, SeverityInfo, reverseBytes([]byte("https://x"))))
doer := &captureDoer{status: http.StatusInternalServerError}
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
require.Error(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
require.Equal(t, 1, doer.calls)
}
func TestIMNotifier_HTTPErrorReturnsErr(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(imPrefs(uid, true, SeverityInfo, reverseBytes([]byte("https://x"))))
doer := &captureDoer{err: errors.New("dial fail")}
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
require.Error(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
}
func TestIMNotifier_NameIsIMChannel(t *testing.T) {
n := NewIMNotifier(IMConfig{Enabled: true}, newFakePrefs(), reverseDecryptor{}, &captureDoer{}, newTestLogger())
require.Equal(t, ChannelIM, n.Name())
}
+107
View File
@@ -0,0 +1,107 @@
// Package notify 内的 prefs.go 实现 per-user 通知偏好(notification_prefs 表)。
//
// 偏好控制 email / im 两条外发通道的开关与最低投递级别(min_severity)。in-app
// inbox(持久化 + SSE)始终投递,不受偏好影响。im_webhook_url 以密文落库
// (im_webhook_url_enc BYTEA,经 crypto.Encryptor 加密),repo 层只搬运密文,
// 解密由 IMNotifier 在发送时按需进行——避免明文 webhook URL 进入领域内存层。
package notify
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
notifysqlc "github.com/yan1h/agent-coding-workflow/internal/infra/notify/sqlc"
)
// Prefs 是一个用户的通知偏好领域形态。ImWebhookURLEnc 保持密文(BYTEA 原样),
// 解密延迟到 IMNotifier.Send。MinSeverity 取值与 Severity 枚举一致。
type Prefs struct {
UserID uuid.UUID
EmailEnabled bool
IMEnabled bool
ImWebhookURLEnc []byte
MinSeverity Severity
}
// DefaultPrefs 是用户尚未显式设置偏好时的兜底:email / im 均关闭,最低级别
// warning(与 notification_prefs.min_severity 列默认值一致)。
func DefaultPrefs(userID uuid.UUID) Prefs {
return Prefs{
UserID: userID,
EmailEnabled: false,
IMEnabled: false,
MinSeverity: SeverityWarning,
}
}
// PrefsRepository 抽象 notification_prefs 的读写。Get 在无记录时返回
// DefaultPrefs(不报 not-found),让上层无需区分 "未设置" 与 "全关"。
type PrefsRepository interface {
Get(ctx context.Context, userID uuid.UUID) (Prefs, error)
Upsert(ctx context.Context, p Prefs) error
}
// pgPrefsRepo 是 PrefsRepository 的 Postgres 实现。
type pgPrefsRepo struct {
q *notifysqlc.Queries
}
// NewPostgresPrefsRepository 用现有 pgxpool 构造偏好仓库。
func NewPostgresPrefsRepository(pool *pgxpool.Pool) PrefsRepository {
return &pgPrefsRepo{q: notifysqlc.New(pool)}
}
// Get 读取用户偏好。无记录时返回 DefaultPrefs(userID) 与 nil error。
func (r *pgPrefsRepo) Get(ctx context.Context, userID uuid.UUID) (Prefs, error) {
row, err := r.q.GetNotificationPrefs(ctx, toPgUUID(userID))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return DefaultPrefs(userID), nil
}
return Prefs{}, errs.Wrap(err, errs.CodeInternal, "get notification prefs")
}
return Prefs{
UserID: fromPgUUID(row.UserID),
EmailEnabled: row.EmailEnabled,
IMEnabled: row.ImEnabled,
ImWebhookURLEnc: row.ImWebhookUrlEnc,
MinSeverity: Severity(row.MinSeverity),
}, nil
}
// Upsert 写入(或更新)用户偏好。ImWebhookURLEnc 为 nil 时写 NULL。
func (r *pgPrefsRepo) Upsert(ctx context.Context, p Prefs) error {
if err := r.q.UpsertNotificationPrefs(ctx, notifysqlc.UpsertNotificationPrefsParams{
UserID: toPgUUID(p.UserID),
EmailEnabled: p.EmailEnabled,
ImEnabled: p.IMEnabled,
ImWebhookUrlEnc: p.ImWebhookURLEnc,
MinSeverity: string(p.MinSeverity),
}); err != nil {
return errs.Wrap(err, errs.CodeInternal, "upsert notification prefs")
}
return nil
}
// severityRank 把 Severity 映射为可比较的等级序数,用于 min_severity 过滤。
// 未知值按最低(info)处理,确保 "至少投递" 而非误丢。
func severityRank(s Severity) int {
switch s {
case SeverityError:
return 2
case SeverityWarning:
return 1
default:
return 0
}
}
// meetsMinSeverity 报告消息级别是否达到(>=)用户设定的最低投递级别。
func meetsMinSeverity(msg Severity, min Severity) bool {
return severityRank(msg) >= severityRank(min)
}
+133
View File
@@ -0,0 +1,133 @@
// Package notify 内的 prefs_handler.go 实现 per-user 通知偏好的 HTTP 接口:
//
// GET /api/v1/notifications/prefs → 当前用户的偏好(im_webhook_url 不回显明文,
// 仅返回 im_webhook_url_set 布尔标记是否已配置)。
// PUT /api/v1/notifications/prefs → 整体更新偏好。im_webhook_url 提供非空字符串
// 则加密落库;提供空字符串则清除;字段省略则保留原值。
//
// im_webhook_url 经 Encryptor 加密为 im_webhook_url_enc BYTEA。偏好只在 Handler
// 注入了 prefsRepo + enc 后才挂载(WithPrefs),未配置 crypto 时该路由不暴露。
package notify
import (
"encoding/json"
"net/http"
"strings"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
)
// prefsDTO 是偏好的对外 JSON 形态。im_webhook_url 绝不回显明文:读取时只暴露
// im_webhook_url_set 标记是否已配置,写入时通过单独的可空字段提交。
type prefsDTO struct {
EmailEnabled bool `json:"email_enabled"`
IMEnabled bool `json:"im_enabled"`
IMWebhookURLSet bool `json:"im_webhook_url_set"`
MinSeverity string `json:"min_severity"`
}
// prefsUpdateReq 是 PUT 请求体。IMWebhookURL 用指针区分三态:
// - nil(字段省略) → 保留原密文不变;
// - 指向空字符串 "" → 清除已配置的 webhook;
// - 指向非空字符串 → 加密为新密文。
type prefsUpdateReq struct {
EmailEnabled *bool `json:"email_enabled"`
IMEnabled *bool `json:"im_enabled"`
IMWebhookURL *string `json:"im_webhook_url"`
MinSeverity *string `json:"min_severity"`
}
// getPrefs 处理 GET /api/v1/notifications/prefs。
func (h *Handler) getPrefs(w http.ResponseWriter, r *http.Request) {
uid, ok := middleware.UserIDFromContext(r.Context())
if !ok {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
errs.New(errs.CodeUnauthorized, "未认证"))
return
}
p, err := h.prefs.Get(r.Context(), uid)
if err != nil {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
return
}
httpx.WriteJSON(w, http.StatusOK, prefsDTO{
EmailEnabled: p.EmailEnabled,
IMEnabled: p.IMEnabled,
IMWebhookURLSet: len(p.ImWebhookURLEnc) > 0,
MinSeverity: string(p.MinSeverity),
})
}
// putPrefs 处理 PUT /api/v1/notifications/prefs。
func (h *Handler) putPrefs(w http.ResponseWriter, r *http.Request) {
uid, ok := middleware.UserIDFromContext(r.Context())
if !ok {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
errs.New(errs.CodeUnauthorized, "未认证"))
return
}
var req prefsUpdateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
errs.New(errs.CodeInvalidInput, "请求体格式错误"))
return
}
cur, err := h.prefs.Get(r.Context(), uid)
if err != nil {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
return
}
if req.EmailEnabled != nil {
cur.EmailEnabled = *req.EmailEnabled
}
if req.IMEnabled != nil {
cur.IMEnabled = *req.IMEnabled
}
if req.MinSeverity != nil {
sev := Severity(strings.TrimSpace(*req.MinSeverity))
if !validSeverity(sev) {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
errs.New(errs.CodeInvalidInput, "min_severity 取值非法(info|warning|error)"))
return
}
cur.MinSeverity = sev
}
if req.IMWebhookURL != nil {
url := strings.TrimSpace(*req.IMWebhookURL)
if url == "" {
cur.ImWebhookURLEnc = nil
} else {
ct, err := h.enc.Encrypt([]byte(url))
if err != nil {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
errs.Wrap(err, errs.CodeInternal, "加密 im_webhook_url"))
return
}
cur.ImWebhookURLEnc = ct
}
}
if err := h.prefs.Upsert(r.Context(), cur); err != nil {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
return
}
httpx.WriteJSON(w, http.StatusOK, prefsDTO{
EmailEnabled: cur.EmailEnabled,
IMEnabled: cur.IMEnabled,
IMWebhookURLSet: len(cur.ImWebhookURLEnc) > 0,
MinSeverity: string(cur.MinSeverity),
})
}
// validSeverity 报告 s 是否为合法的 min_severity 取值。
func validSeverity(s Severity) bool {
switch s {
case SeverityInfo, SeverityWarning, SeverityError:
return true
default:
return false
}
}
+168
View File
@@ -0,0 +1,168 @@
package notify
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
type prefsFakeResolver struct{ uid uuid.UUID }
func (f *prefsFakeResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) {
if token == "" {
return uuid.Nil, false, nil
}
return f.uid, true, nil
}
// reverseEncryptor 是测试用 Encryptor:取反作为 "加密",与 reverseDecryptor 配对。
type reverseEncryptor struct{}
func (reverseEncryptor) Encrypt(pt []byte) ([]byte, error) { return reverseBytes(pt), nil }
func mountPrefs(t *testing.T, uid uuid.UUID, prefs PrefsRepository) http.Handler {
t.Helper()
r := chi.NewRouter()
NewHandler(&noopRepo{}, &prefsFakeResolver{uid: uid}).
WithPrefs(prefs, reverseEncryptor{}).Mount(r)
return r
}
// noopRepo 满足 inbox Repository(prefs 测试不触达 inbox 端点)。
type noopRepo struct{}
func (noopRepo) Insert(context.Context, Message) error { return nil }
func (noopRepo) List(context.Context, uuid.UUID, bool, int) ([]Message, error) {
return nil, nil
}
func (noopRepo) CountUnread(context.Context, uuid.UUID) (int, error) { return 0, nil }
func (noopRepo) MarkRead(context.Context, uuid.UUID, uuid.UUID) error { return nil }
func (noopRepo) MarkAllRead(context.Context, uuid.UUID) error { return nil }
func TestPrefsHandler_GetReturnsDefaultsForNewUser(t *testing.T) {
uid := uuid.New()
h := mountPrefs(t, uid, newFakePrefs())
req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/prefs", nil)
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var dto prefsDTO
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &dto))
require.False(t, dto.EmailEnabled)
require.False(t, dto.IMEnabled)
require.False(t, dto.IMWebhookURLSet)
require.Equal(t, "warning", dto.MinSeverity)
}
func TestPrefsHandler_GetRequiresAuth(t *testing.T) {
h := mountPrefs(t, uuid.New(), newFakePrefs())
req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/prefs", nil) // no token
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestPrefsHandler_PutPersistsAndEncryptsWebhook(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
h := mountPrefs(t, uid, prefs)
body, _ := json.Marshal(prefsUpdateReq{
EmailEnabled: boolPtr(true),
IMEnabled: boolPtr(true),
IMWebhookURL: strPtr("https://hooks.slack.test/xyz"),
MinSeverity: strPtr("error"),
})
req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var dto prefsDTO
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &dto))
require.True(t, dto.EmailEnabled)
require.True(t, dto.IMEnabled)
require.True(t, dto.IMWebhookURLSet)
require.Equal(t, "error", dto.MinSeverity)
// 落库的 webhook 应为密文(取反),解回应得回原 URL;明文绝不直接存。
saved, err := prefs.Get(context.Background(), uid)
require.NoError(t, err)
require.NotEmpty(t, saved.ImWebhookURLEnc)
require.NotEqual(t, []byte("https://hooks.slack.test/xyz"), saved.ImWebhookURLEnc)
require.Equal(t, "https://hooks.slack.test/xyz", string(reverseBytes(saved.ImWebhookURLEnc)))
}
func TestPrefsHandler_PutPartialUpdatePreservesWebhook(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(Prefs{UserID: uid, IMEnabled: true, MinSeverity: SeverityWarning,
ImWebhookURLEnc: reverseBytes([]byte("https://existing"))})
h := mountPrefs(t, uid, prefs)
// 仅改 email_enabled,省略 im_webhook_url -> 保留原密文。
body, _ := json.Marshal(prefsUpdateReq{EmailEnabled: boolPtr(true)})
req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
saved, _ := prefs.Get(context.Background(), uid)
require.Equal(t, "https://existing", string(reverseBytes(saved.ImWebhookURLEnc)))
require.True(t, saved.EmailEnabled)
}
func TestPrefsHandler_PutEmptyWebhookClearsIt(t *testing.T) {
uid := uuid.New()
prefs := newFakePrefs()
prefs.set(Prefs{UserID: uid, IMEnabled: true, MinSeverity: SeverityWarning,
ImWebhookURLEnc: reverseBytes([]byte("https://existing"))})
h := mountPrefs(t, uid, prefs)
body, _ := json.Marshal(prefsUpdateReq{IMWebhookURL: strPtr("")})
req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
saved, _ := prefs.Get(context.Background(), uid)
require.Empty(t, saved.ImWebhookURLEnc)
}
func TestPrefsHandler_PutRejectsBadSeverity(t *testing.T) {
uid := uuid.New()
h := mountPrefs(t, uid, newFakePrefs())
body, _ := json.Marshal(prefsUpdateReq{MinSeverity: strPtr("catastrophic")})
req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestPrefsHandler_NotMountedWithoutEncryptor(t *testing.T) {
uid := uuid.New()
r := chi.NewRouter()
// WithPrefs 未调用 -> /prefs 不应被挂载。
NewHandler(&noopRepo{}, &prefsFakeResolver{uid: uid}).Mount(r)
req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/prefs", nil)
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusNotFound, rec.Code)
}
func boolPtr(b bool) *bool { return &b }
func strPtr(s string) *string { return &s }
+14
View File
@@ -0,0 +1,14 @@
-- name: GetNotificationPrefs :one
SELECT user_id, email_enabled, im_enabled, im_webhook_url_enc, min_severity, updated_at
FROM notification_prefs
WHERE user_id = $1;
-- name: UpsertNotificationPrefs :exec
INSERT INTO notification_prefs (user_id, email_enabled, im_enabled, im_webhook_url_enc, min_severity, updated_at)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (user_id) DO UPDATE SET
email_enabled = EXCLUDED.email_enabled,
im_enabled = EXCLUDED.im_enabled,
im_webhook_url_enc = EXCLUDED.im_webhook_url_enc,
min_severity = EXCLUDED.min_severity,
updated_at = now();
+240 -17
View File
@@ -8,6 +8,7 @@ import (
"net/netip"
"github.com/jackc/pgx/v5/pgtype"
"github.com/pgvector/pgvector-go"
)
type AcpAgentKind struct {
@@ -25,6 +26,11 @@ type AcpAgentKind struct {
ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
ModelID pgtype.UUID `json:"model_id"`
MaxCostUsd pgtype.Numeric `json:"max_cost_usd"`
MaxTokens *int64 `json:"max_tokens"`
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"`
KeyVersion int16 `json:"key_version"`
}
type AcpAgentKindConfigFile struct {
@@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct {
UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
}
type AcpEvent struct {
@@ -64,23 +71,64 @@ type AcpPermissionRequest struct {
}
type AcpSession struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ProjectID pgtype.UUID `json:"project_id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
UserID pgtype.UUID `json:"user_id"`
IssueID pgtype.UUID `json:"issue_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
AgentSessionID *string `json:"agent_session_id"`
Branch string `json:"branch"`
CwdPath string `json:"cwd_path"`
IsMainWorktree bool `json:"is_main_worktree"`
Status string `json:"status"`
Pid *int32 `json:"pid"`
ExitCode *int32 `json:"exit_code"`
LastError *string `json:"last_error"`
StartedAt pgtype.Timestamptz `json:"started_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ProjectID pgtype.UUID `json:"project_id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
UserID pgtype.UUID `json:"user_id"`
IssueID pgtype.UUID `json:"issue_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
AgentSessionID *string `json:"agent_session_id"`
Branch string `json:"branch"`
CwdPath string `json:"cwd_path"`
IsMainWorktree bool `json:"is_main_worktree"`
Status string `json:"status"`
Pid *int32 `json:"pid"`
ExitCode *int32 `json:"exit_code"`
LastError *string `json:"last_error"`
StartedAt pgtype.Timestamptz `json:"started_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
LastStopReason *string `json:"last_stop_reason"`
OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
TotalCostUsd pgtype.Numeric `json:"total_cost_usd"`
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"`
BudgetMaxTokens *int64 `json:"budget_max_tokens"`
BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"`
TerminatedReason *string `json:"terminated_reason"`
SandboxMode *string `json:"sandbox_mode"`
CostUsd pgtype.Numeric `json:"cost_usd"`
TokensTotal int64 `json:"tokens_total"`
}
type AcpSessionUsage struct {
ID int64 `json:"id"`
SessionID pgtype.UUID `json:"session_id"`
UserID pgtype.UUID `json:"user_id"`
ProjectID pgtype.UUID `json:"project_id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
ModelID pgtype.UUID `json:"model_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
CostUsd pgtype.Numeric `json:"cost_usd"`
SourceEventID *int64 `json:"source_event_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type AcpTurn struct {
ID int64 `json:"id"`
SessionID pgtype.UUID `json:"session_id"`
TurnIndex int32 `json:"turn_index"`
PromptRequestID *string `json:"prompt_request_id"`
Status string `json:"status"`
StopReason *string `json:"stop_reason"`
UpdateCount int32 `json:"update_count"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
}
type AuditLog struct {
@@ -94,6 +142,81 @@ type AuditLog struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type ChangeRequest struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
IssueID pgtype.UUID `json:"issue_id"`
Number int32 `json:"number"`
Title string `json:"title"`
Description string `json:"description"`
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
State string `json:"state"`
ReviewVerdict string `json:"review_verdict"`
CiState string `json:"ci_state"`
Provider string `json:"provider"`
ExternalID *int64 `json:"external_id"`
ExternalUrl string `json:"external_url"`
MergeCommitSha string `json:"merge_commit_sha"`
ReviewedBy pgtype.UUID `json:"reviewed_by"`
ReviewedAt pgtype.Timestamptz `json:"reviewed_at"`
CreatedBy pgtype.UUID `json:"created_by"`
MergedAt pgtype.Timestamptz `json:"merged_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CodeChunk struct {
ID pgtype.UUID `json:"id"`
RunID pgtype.UUID `json:"run_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CommitSha string `json:"commit_sha"`
FilePath string `json:"file_path"`
Lang *string `json:"lang"`
StartLine int32 `json:"start_line"`
EndLine int32 `json:"end_line"`
Content string `json:"content"`
ContentSha []byte `json:"content_sha"`
TokenCount *int32 `json:"token_count"`
Embedding *pgvector.Vector `json:"embedding"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CodeIndexRun struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
WorktreeID pgtype.UUID `json:"worktree_id"`
Branch string `json:"branch"`
CommitSha string `json:"commit_sha"`
Status string `json:"status"`
EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"`
EmbeddingModel string `json:"embedding_model"`
Dims int32 `json:"dims"`
FilesIndexed int32 `json:"files_indexed"`
ChunksIndexed int32 `json:"chunks_indexed"`
Error *string `json:"error"`
StartedAt pgtype.Timestamptz `json:"started_at"`
FinishedAt pgtype.Timestamptz `json:"finished_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CommitSummary struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
WorktreeID pgtype.UUID `json:"worktree_id"`
Branch string `json:"branch"`
CommitSha string `json:"commit_sha"`
Kind string `json:"kind"`
Title *string `json:"title"`
BodyMd string `json:"body_md"`
Model *string `json:"model"`
Status string `json:"status"`
Error *string `json:"error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Conversation struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
@@ -110,6 +233,14 @@ type Conversation struct {
RequirementID pgtype.UUID `json:"requirement_id"`
}
type CryptoKey struct {
Version int32 `json:"version"`
Provider string `json:"provider"`
KeyRef *string `json:"key_ref"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type GitCredential struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
@@ -119,6 +250,7 @@ type GitCredential struct {
Fingerprint *string `json:"fingerprint"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
}
type Issue struct {
@@ -135,6 +267,17 @@ type Issue struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ParentID pgtype.UUID `json:"parent_id"`
Priority int32 `json:"priority"`
}
type IssueDependency struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
BlockedID pgtype.UUID `json:"blocked_id"`
BlockerID pgtype.UUID `json:"blocker_id"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Job struct {
@@ -162,6 +305,7 @@ type LlmEndpoint struct {
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
}
type LlmModel struct {
@@ -252,6 +396,50 @@ type Notification struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type NotificationPref struct {
UserID pgtype.UUID `json:"user_id"`
EmailEnabled bool `json:"email_enabled"`
ImEnabled bool `json:"im_enabled"`
ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"`
MinSeverity string `json:"min_severity"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type OrchestratorRun struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
OwnerID pgtype.UUID `json:"owner_id"`
Status string `json:"status"`
CurrentPhase string `json:"current_phase"`
Config []byte `json:"config"`
LastError *string `json:"last_error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
}
type OrchestratorStep struct {
ID pgtype.UUID `json:"id"`
RunID pgtype.UUID `json:"run_id"`
Phase string `json:"phase"`
Attempt int32 `json:"attempt"`
ParentStepID pgtype.UUID `json:"parent_step_id"`
Depth int32 `json:"depth"`
Status string `json:"status"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
AcpSessionID pgtype.UUID `json:"acp_session_id"`
Prompt string `json:"prompt"`
StopReason *string `json:"stop_reason"`
GateResult []byte `json:"gate_result"`
LastError *string `json:"last_error"`
JobID pgtype.UUID `json:"job_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
}
type Project struct {
ID pgtype.UUID `json:"id"`
Slug string `json:"slug"`
@@ -264,6 +452,30 @@ type Project struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type ProjectMember struct {
ProjectID pgtype.UUID `json:"project_id"`
UserID pgtype.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
AddedBy pgtype.UUID `json:"added_by"`
}
type ProjectMemory struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
Tags []string `json:"tags"`
Source string `json:"source"`
Embedding *pgvector.Vector `json:"embedding"`
EmbeddingModel *string `json:"embedding_model"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type PromptTemplate struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
@@ -299,6 +511,16 @@ type RequirementArtifact struct {
SourceMessageID *int64 `json:"source_message_id"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Verdict string `json:"verdict"`
}
type RequirementPhasePointer struct {
RequirementID pgtype.UUID `json:"requirement_id"`
Phase string `json:"phase"`
ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"`
ApprovedBy pgtype.UUID `json:"approved_by"`
ApprovedAt pgtype.Timestamptz `json:"approved_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type User struct {
@@ -354,6 +576,7 @@ type WorkspaceRunProfile struct {
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
}
type WorkspaceWorktree struct {
+62
View File
@@ -0,0 +1,62 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: prefs.sql
package notifysqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const getNotificationPrefs = `-- name: GetNotificationPrefs :one
SELECT user_id, email_enabled, im_enabled, im_webhook_url_enc, min_severity, updated_at
FROM notification_prefs
WHERE user_id = $1
`
func (q *Queries) GetNotificationPrefs(ctx context.Context, userID pgtype.UUID) (NotificationPref, error) {
row := q.db.QueryRow(ctx, getNotificationPrefs, userID)
var i NotificationPref
err := row.Scan(
&i.UserID,
&i.EmailEnabled,
&i.ImEnabled,
&i.ImWebhookUrlEnc,
&i.MinSeverity,
&i.UpdatedAt,
)
return i, err
}
const upsertNotificationPrefs = `-- name: UpsertNotificationPrefs :exec
INSERT INTO notification_prefs (user_id, email_enabled, im_enabled, im_webhook_url_enc, min_severity, updated_at)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (user_id) DO UPDATE SET
email_enabled = EXCLUDED.email_enabled,
im_enabled = EXCLUDED.im_enabled,
im_webhook_url_enc = EXCLUDED.im_webhook_url_enc,
min_severity = EXCLUDED.min_severity,
updated_at = now()
`
type UpsertNotificationPrefsParams struct {
UserID pgtype.UUID `json:"user_id"`
EmailEnabled bool `json:"email_enabled"`
ImEnabled bool `json:"im_enabled"`
ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"`
MinSeverity string `json:"min_severity"`
}
func (q *Queries) UpsertNotificationPrefs(ctx context.Context, arg UpsertNotificationPrefsParams) error {
_, err := q.db.Exec(ctx, upsertNotificationPrefs,
arg.UserID,
arg.EmailEnabled,
arg.ImEnabled,
arg.ImWebhookUrlEnc,
arg.MinSeverity,
)
return err
}
+84
View File
@@ -0,0 +1,84 @@
// stream.go 实现通知 inbox 的实时推送(spec §11 步骤8)。
//
// StreamHub 是 per-user 的订阅扇出中心:每个浏览器标签页 Subscribe 一个有界缓冲
// channel;Dispatcher 落库成功后 Publish 到该用户的全部订阅者。慢订阅者(缓冲满)
// 直接丢弃该消息(drop-on-slow),绝不阻塞 Publish / Dispatch,避免 goroutine /
// 内存泄漏(镜像 chat StreamerHub 的丢弃纪律)。
package notify
import (
"sync"
"github.com/google/uuid"
)
// StreamHub 是 Dispatcher 推送实时通知所需的窄接口。
type StreamHub interface {
Subscribe(userID uuid.UUID) (<-chan Message, func())
Publish(userID uuid.UUID, m Message)
}
// streamSubBuffer 是单个订阅者的缓冲深度。满时新消息被丢弃(客户端可用 inbox
// 列表接口补偿,与 chat WS onopen 主动拉取同理)。
const streamSubBuffer = 16
// memHub 是 StreamHub 的进程内实现。
type memHub struct {
mu sync.Mutex
subs map[uuid.UUID]map[int]chan Message
next int
}
// NewStreamHub 构造进程内的 per-user 通知推送中心。
func NewStreamHub() StreamHub {
return &memHub{subs: map[uuid.UUID]map[int]chan Message{}}
}
// Subscribe 为某用户注册一个订阅者,返回只读 channel 与取消函数。取消函数幂等:
// 关闭 channel 并从订阅表移除;调用方应在请求结束时 defer 调用。
func (h *memHub) Subscribe(userID uuid.UUID) (<-chan Message, func()) {
ch := make(chan Message, streamSubBuffer)
h.mu.Lock()
if h.subs[userID] == nil {
h.subs[userID] = map[int]chan Message{}
}
id := h.next
h.next++
h.subs[userID][id] = ch
h.mu.Unlock()
var once sync.Once
cancel := func() {
once.Do(func() {
h.mu.Lock()
if m, ok := h.subs[userID]; ok {
if c, ok := m[id]; ok {
delete(m, id)
close(c)
}
if len(m) == 0 {
delete(h.subs, userID)
}
}
h.mu.Unlock()
})
}
return ch, cancel
}
// Publish 向某用户的全部订阅者非阻塞投递一条消息。缓冲满的订阅者被跳过。
//
// 关键:整个投递循环持有 h.mu。因为发送是非阻塞的(select default),持锁开销极小,
// 但这让 Publish 与 cancel()(同样持 h.mu 后 close channel)互斥——否则快照 channel
// 释放锁后、cancel 并发 close,会 panic "send on closed channel"。
func (h *memHub) Publish(userID uuid.UUID, m Message) {
h.mu.Lock()
defer h.mu.Unlock()
for _, c := range h.subs[userID] {
select {
case c <- m:
default:
// drop-on-slow:缓冲满,丢弃,避免阻塞 Dispatch。
}
}
}
+120
View File
@@ -0,0 +1,120 @@
package notify
import (
"context"
"testing"
"time"
"github.com/google/uuid"
)
func TestStreamHubSubscribeReceivesPublish(t *testing.T) {
hub := NewStreamHub()
uid := uuid.New()
ch, cancel := hub.Subscribe(uid)
defer cancel()
want := Message{ID: uuid.New(), UserID: uid, Topic: "t", Title: "hi"}
hub.Publish(uid, want)
select {
case got := <-ch:
if got.ID != want.ID {
t.Fatalf("got %v, want %v", got.ID, want.ID)
}
case <-time.After(time.Second):
t.Fatal("did not receive published message")
}
}
func TestStreamHubUnsubscribeStopsDelivery(t *testing.T) {
hub := NewStreamHub()
uid := uuid.New()
ch, cancel := hub.Subscribe(uid)
cancel() // immediate unsubscribe closes the channel
// channel should be closed, draining returns zero value + !ok.
if _, ok := <-ch; ok {
t.Fatal("expected channel closed after cancel")
}
// Publishing to an unsubscribed user must not panic.
hub.Publish(uid, Message{UserID: uid})
}
func TestStreamHubDropsSlowSubscriber(t *testing.T) {
hub := NewStreamHub()
uid := uuid.New()
ch, cancel := hub.Subscribe(uid)
defer cancel()
// Flood beyond buffer; Publish must not block / deadlock.
done := make(chan struct{})
go func() {
for i := 0; i < streamSubBuffer*4; i++ {
hub.Publish(uid, Message{UserID: uid, Topic: "flood"})
}
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Publish deadlocked on slow subscriber")
}
// Buffer holds at most streamSubBuffer; the rest were dropped.
count := 0
for {
select {
case <-ch:
count++
default:
if count > streamSubBuffer {
t.Fatalf("buffered %d > cap %d (drop-on-slow violated)", count, streamSubBuffer)
}
return
}
}
}
func TestDispatcherPublishesAfterInsert(t *testing.T) {
repo := &fakeRepo{}
d := NewDispatcher(repo, newTestLogger())
hub := NewStreamHub()
d.SetStreamHub(hub)
uid := uuid.New()
ch, cancel := hub.Subscribe(uid)
defer cancel()
if err := d.Dispatch(context.Background(), Message{UserID: uid, Topic: "x", Title: "y"}); err != nil {
t.Fatalf("dispatch: %v", err)
}
select {
case got := <-ch:
if got.Topic != "x" {
t.Fatalf("topic = %q", got.Topic)
}
case <-time.After(time.Second):
t.Fatal("hub did not receive dispatched message")
}
}
func TestDispatcherNoPublishOnInsertFailure(t *testing.T) {
repo := &fakeRepo{failNext: true}
d := NewDispatcher(repo, newTestLogger())
hub := NewStreamHub()
d.SetStreamHub(hub)
uid := uuid.New()
ch, cancel := hub.Subscribe(uid)
defer cancel()
if err := d.Dispatch(context.Background(), Message{UserID: uid}); err == nil {
t.Fatal("expected dispatch error on insert failure")
}
select {
case <-ch:
t.Fatal("hub received message despite insert failure")
case <-time.After(100 * time.Millisecond):
// expected: no publish.
}
}
+168
View File
@@ -0,0 +1,168 @@
package proc
import (
"bytes"
"context"
"os/exec"
"time"
)
// ExecSpec 描述一次一次性(one-shot)命令执行的全部输入。
// 调用方(run.ExecService)负责前置鉴权、cwd / env 构造与命令白名单校验。
type ExecSpec struct {
Command string
Args []string
Dir string
Env []string // 完整环境(宿主白名单 + 覆盖),nil 表示空环境而非继承宿主
// Timeout 是硬上限:超时即对整组发起 TerminateGroup(树终止)并置 TimedOut。
// <=0 时不设独立超时,仅受 ctx 约束。
Timeout time.Duration
// KillGrace 是树终止时的优雅宽限(Unix SIGTERM → grace → SIGKILL)。
KillGrace time.Duration
// MaxOutputBytes 是 stdout / stderr 各自的捕获上限;超出截断并置 truncated。
// <=0 时使用 defaultMaxOutputBytes。
MaxOutputBytes int
}
// ExecResult 是一次性执行的结构化结果。
type ExecResult struct {
ExitCode int
Stdout string
Stderr string
Duration time.Duration
TimedOut bool
Truncated bool // stdout 或 stderr 任一被截断时为 true
StdoutSize int // 截断前的实际字节数
StderrSize int // 截断前的实际字节数
}
const defaultMaxOutputBytes = 1 << 20 // 1 MiB
// RunOnce 同步执行 spec 描述的命令直至退出(或超时 / ctx 取消),捕获 stdout /
// stderr(各自封顶 MaxOutputBytes),返回退出码、时长、超时标志。
//
// 进程树管理沿用 RunSupervisor.Spawn 的次序:Prepare(Start 前)→ Start →
// Adopt(Start 后)→ 退出或超时时 TerminateGroup 整树清理。这样测试运行器派生的
// 子孙进程(go test 编译出的子进程、node 子进程等)在超时时也会被一并终止。
//
// 与长驻 profile 的差异:本函数阻塞到进程退出后才返回,不接入 relay;输出在内存
// 缓冲并随返回值一次性交回。错误仅在无法启动进程(exec.Start 失败)时返回;命令
// 以非零码退出不算 error,体现在 ExitCode 上。
func RunOnce(ctx context.Context, spec ExecSpec) (ExecResult, error) {
maxOut := spec.MaxOutputBytes
if maxOut <= 0 {
maxOut = defaultMaxOutputBytes
}
cmd := exec.Command(spec.Command, spec.Args...)
cmd.Dir = spec.Dir
cmd.Env = spec.Env
outBuf := &cappedBuffer{limit: maxOut}
errBuf := &cappedBuffer{limit: maxOut}
cmd.Stdout = outBuf
cmd.Stderr = errBuf
group := NewGroup()
group.Prepare(cmd)
started := time.Now()
if err := cmd.Start(); err != nil {
return ExecResult{}, err
}
if err := group.Adopt(cmd); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
group.Close()
return ExecResult{}, err
}
// waitCh 携带 Wait() 的错误;done 在 Wait 返回后 close,供 TerminateGroup
// 感知组长已被回收(语义对齐 RunSupervisor.monitorProcess 的 done channel)。
waitCh := make(chan error, 1)
done := make(chan struct{})
go func() {
err := cmd.Wait()
waitCh <- err
close(done)
}()
// 超时 / ctx-cancel watchdog:触发整树终止;result 由下方 select 收尾。
var timeoutTimer <-chan time.Time
if spec.Timeout > 0 {
t := time.NewTimer(spec.Timeout)
defer t.Stop()
timeoutTimer = t.C
}
var timedOut bool
var waitErr error
loop:
for {
select {
case waitErr = <-waitCh:
break loop
case <-timeoutTimer:
timedOut = true
group.TerminateGroup(done, spec.KillGrace)
waitErr = <-waitCh
break loop
case <-ctx.Done():
group.TerminateGroup(done, spec.KillGrace)
waitErr = <-waitCh
break loop
}
}
group.Close()
res := ExecResult{
ExitCode: exitCodeOf(waitErr),
Stdout: outBuf.String(),
Stderr: errBuf.String(),
Duration: time.Since(started),
TimedOut: timedOut,
Truncated: outBuf.truncated || errBuf.truncated,
StdoutSize: outBuf.total,
StderrSize: errBuf.total,
}
return res, nil
}
// exitCodeOf 从 Cmd.Wait 的返回错误提取退出码。正常退出(nil)= 0;
// *exec.ExitError 取其 ExitCode(被信号杀死时为 -1);其它错误统一 -1。
func exitCodeOf(waitErr error) int {
if waitErr == nil {
return 0
}
if ee, ok := waitErr.(*exec.ExitError); ok {
return ee.ExitCode()
}
return -1
}
// cappedBuffer 是一个带上限的 io.Writer:累计写入 limit 字节后丢弃后续数据并置
// truncated,同时仍记录 total(截断前的总字节数)便于上层提示。
type cappedBuffer struct {
buf bytes.Buffer
limit int
total int
truncated bool
}
func (c *cappedBuffer) Write(p []byte) (int, error) {
c.total += len(p)
if c.buf.Len() >= c.limit {
c.truncated = true
return len(p), nil // 谎报全写入:避免 cmd 因 short write 报错而中断
}
remain := c.limit - c.buf.Len()
if len(p) > remain {
c.buf.Write(p[:remain])
c.truncated = true
return len(p), nil
}
c.buf.Write(p)
return len(p), nil
}
func (c *cappedBuffer) String() string { return c.buf.String() }
+143
View File
@@ -0,0 +1,143 @@
package proc
import (
"context"
"fmt"
"runtime"
"strings"
"testing"
"time"
)
// helperShell 返回执行一段 shell 脚本的跨平台命令前缀(command, args...)。
func helperShell(script string) (string, []string) {
if runtime.GOOS == "windows" {
return "cmd", []string{"/c", script}
}
return "sh", []string{"-c", script}
}
// TestRunOnce_ExitCodeAndCapture 验证捕获 stdout / stderr 与非零退出码。
func TestRunOnce_ExitCodeAndCapture(t *testing.T) {
var script string
if runtime.GOOS == "windows" {
script = "echo out& echo err 1>&2& exit 3"
} else {
script = "echo out; echo err 1>&2; exit 3"
}
cmd, args := helperShell(script)
res, err := RunOnce(context.Background(), ExecSpec{Command: cmd, Args: args, Timeout: 10 * time.Second})
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if res.ExitCode != 3 {
t.Fatalf("exit code = %d, want 3", res.ExitCode)
}
if !strings.Contains(res.Stdout, "out") {
t.Fatalf("stdout = %q, want contains 'out'", res.Stdout)
}
if !strings.Contains(res.Stderr, "err") {
t.Fatalf("stderr = %q, want contains 'err'", res.Stderr)
}
if res.TimedOut {
t.Fatal("TimedOut should be false")
}
}
// TestRunOnce_Success 验证零退出码路径。
func TestRunOnce_Success(t *testing.T) {
cmd, args := helperShell("echo hello")
res, err := RunOnce(context.Background(), ExecSpec{Command: cmd, Args: args, Timeout: 10 * time.Second})
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if res.ExitCode != 0 {
t.Fatalf("exit code = %d, want 0", res.ExitCode)
}
if !strings.Contains(res.Stdout, "hello") {
t.Fatalf("stdout = %q", res.Stdout)
}
}
// TestRunOnce_Truncation 验证 stdout 超过 MaxOutputBytes 时截断并置标志。
func TestRunOnce_Truncation(t *testing.T) {
// 打印约 1000 字节,但上限设为 100。
var cmd string
var args []string
if runtime.GOOS == "windows" {
// PowerShell 直接调用避免 cmd 转义;-NoProfile 加快启动。
cmd, args = "powershell", []string{"-NoProfile", "-Command", "Write-Output ('a' * 1000)"}
} else {
cmd, args = "sh", []string{"-c", "for i in $(seq 1 1000); do printf a; done"}
}
res, err := RunOnce(context.Background(), ExecSpec{Command: cmd, Args: args, MaxOutputBytes: 100, Timeout: 15 * time.Second})
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if !res.Truncated {
t.Fatalf("Truncated should be true; stdout len=%d size=%d", len(res.Stdout), res.StdoutSize)
}
if len(res.Stdout) > 100 {
t.Fatalf("captured stdout len=%d exceeds limit 100", len(res.Stdout))
}
}
// TestRunOnce_Timeout 验证超时触发整树终止并置 TimedOut。
//
// Unix:父 shell trap 掉 TERM 并 sleep,再后台派生一个 grandchild sleep;超时后
// TerminateGroup 必须 SIGKILL 整组,确保父退出(done 被关闭)。
// Windows:直接 timeout 长睡,Job-close 终止。
func TestRunOnce_Timeout(t *testing.T) {
var cmd string
var args []string
if runtime.GOOS == "windows" {
// ping -n N localhost 是无需控制台的可靠延时(timeout /t 在无 stdin 下立即返回)。
cmd, args = "cmd", []string{"/c", "ping -n 30 127.0.0.1 >nul"}
} else {
// trap '' TERM 忽略 SIGTERM;后台派生 grandchild;wait 阻塞存活。
cmd, args = "sh", []string{"-c", "trap '' TERM; sleep 30 & sleep 30; wait"}
}
start := time.Now()
res, err := RunOnce(context.Background(), ExecSpec{
Command: cmd,
Args: args,
Timeout: 1 * time.Second,
KillGrace: 500 * time.Millisecond,
})
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if !res.TimedOut {
t.Fatal("TimedOut should be true")
}
// 必须在远小于 30s 内返回(超时 + grace + kill),证明整树被回收而非等自然退出。
if elapsed := time.Since(start); elapsed > 10*time.Second {
t.Fatalf("RunOnce blocked %v, expected prompt tree-kill", elapsed)
}
}
// TestRunOnce_CtxCancel 验证 ctx 取消亦触发树终止并尽快返回。
func TestRunOnce_CtxCancel(t *testing.T) {
cmd, args := helperShell(fmt.Sprintf("%s", sleepScript(30)))
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(500 * time.Millisecond)
cancel()
}()
start := time.Now()
res, err := RunOnce(ctx, ExecSpec{Command: cmd, Args: args, KillGrace: 500 * time.Millisecond})
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if elapsed := time.Since(start); elapsed > 10*time.Second {
t.Fatalf("RunOnce blocked %v after ctx cancel", elapsed)
}
_ = res
}
func sleepScript(sec int) string {
if runtime.GOOS == "windows" {
return fmt.Sprintf("ping -n %d 127.0.0.1 >nul", sec)
}
return fmt.Sprintf("sleep %d", sec)
}
+259
View File
@@ -0,0 +1,259 @@
package vcs
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// DefaultGiteaBaseURL is the platform's self-hosted Gitea instance.
const DefaultGiteaBaseURL = "https://git.jerryyan.net"
// giteaErrBodyLimit caps how much of a non-2xx response body is surfaced in the
// wrapped error message.
const giteaErrBodyLimit = 512
// GiteaProvider implements Provider against the Gitea REST v1 API. Auth uses
// the "Authorization: token <PAT>" header with a single platform-level token.
type GiteaProvider struct {
baseURL string
token string
client *http.Client
}
// NewGiteaProvider constructs a GiteaProvider. baseURL empty defaults to
// DefaultGiteaBaseURL; timeout <= 0 defaults to 15s. A fresh http.Client is
// used (low call frequency), mirroring the webhook handler pattern.
func NewGiteaProvider(baseURL, token string, timeout time.Duration) *GiteaProvider {
if baseURL == "" {
baseURL = DefaultGiteaBaseURL
}
if timeout <= 0 {
timeout = 15 * time.Second
}
return &GiteaProvider{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
client: &http.Client{Timeout: timeout},
}
}
// Host returns the bare host (no scheme/port) this provider serves, for
// ParseRepoRef host matching.
func (g *GiteaProvider) Host() string {
u, err := url.Parse(g.baseURL)
if err != nil || u.Host == "" {
return ""
}
return u.Hostname()
}
// ===== Gitea wire DTOs =====
type giteaPR struct {
Number int64 `json:"number"`
State string `json:"state"`
Mergeable bool `json:"mergeable"`
Merged bool `json:"merged"`
MergedCommitID string `json:"merge_commit_sha"`
HTMLURL string `json:"html_url"`
Head giteaBranch `json:"head"`
}
type giteaBranch struct {
Sha string `json:"sha"`
Ref string `json:"ref"`
}
type giteaCombinedStatus struct {
State string `json:"state"` // pending|success|error|failure
}
type giteaIssue struct {
Number int64 `json:"number"`
Title string `json:"title"`
State string `json:"state"`
HTMLURL string `json:"html_url"`
}
// ===== Provider impl =====
func (g *GiteaProvider) CreatePR(ctx context.Context, in CreatePRInput) (*PullRequest, error) {
body := map[string]any{
"head": in.Head,
"base": in.Base,
"title": in.Title,
"body": in.Body,
}
path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls", esc(in.Repo.Owner), esc(in.Repo.Name))
var pr giteaPR
if err := g.do(ctx, http.MethodPost, path, body, &pr); err != nil {
return nil, err
}
out := mapPR(pr)
// best-effort CI state from the head commit's combined status
out.CIState = g.ciState(ctx, in.Repo, pr.Head.Sha)
return &out, nil
}
func (g *GiteaProvider) GetPR(ctx context.Context, repo RepoRef, number int64) (*PullRequest, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", esc(repo.Owner), esc(repo.Name), number)
var pr giteaPR
if err := g.do(ctx, http.MethodGet, path, nil, &pr); err != nil {
return nil, err
}
out := mapPR(pr)
out.CIState = g.ciState(ctx, repo, pr.Head.Sha)
return &out, nil
}
func (g *GiteaProvider) Comment(ctx context.Context, repo RepoRef, number int64, body string) error {
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", esc(repo.Owner), esc(repo.Name), number)
return g.do(ctx, http.MethodPost, path, map[string]any{"body": body}, nil)
}
func (g *GiteaProvider) Merge(ctx context.Context, repo RepoRef, number int64, in MergeInput) (string, error) {
method := in.Method
if method == "" {
method = "merge"
}
body := map[string]any{"Do": method}
if in.Title != "" {
body["MergeTitleField"] = in.Title
}
if in.Message != "" {
body["MergeMessageField"] = in.Message
}
path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", esc(repo.Owner), esc(repo.Name), number)
if err := g.do(ctx, http.MethodPost, path, body, nil); err != nil {
return "", err
}
// Gitea's merge endpoint returns 200 with no SHA; re-fetch to read the merge commit.
pr, err := g.GetPR(ctx, repo, number)
if err != nil {
return "", nil // merge succeeded; sha lookup best-effort
}
return pr.MergeCommitSHA, nil
}
func (g *GiteaProvider) ListIssues(ctx context.Context, repo RepoRef, opts IssueListOptions) ([]Issue, error) {
q := url.Values{}
q.Set("type", "issues")
if opts.State != "" {
q.Set("state", opts.State)
}
if opts.Limit > 0 {
q.Set("limit", strconv.Itoa(opts.Limit))
}
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues?%s", esc(repo.Owner), esc(repo.Name), q.Encode())
var rows []giteaIssue
if err := g.do(ctx, http.MethodGet, path, nil, &rows); err != nil {
return nil, err
}
out := make([]Issue, 0, len(rows))
for _, r := range rows {
out = append(out, Issue{Number: r.Number, Title: r.Title, State: r.State, HTMLURL: r.HTMLURL})
}
return out, nil
}
// ciState fetches the combined commit status for sha and maps it to the
// platform's ci_state vocabulary. Missing status / errors degrade to "unknown".
func (g *GiteaProvider) ciState(ctx context.Context, repo RepoRef, sha string) string {
if sha == "" {
return "unknown"
}
path := fmt.Sprintf("/api/v1/repos/%s/%s/commits/%s/status", esc(repo.Owner), esc(repo.Name), esc(sha))
var cs giteaCombinedStatus
if err := g.do(ctx, http.MethodGet, path, nil, &cs); err != nil {
return "unknown"
}
switch cs.State {
case "success":
return "success"
case "pending":
return "pending"
case "error", "failure":
return "failure"
default:
return "unknown"
}
}
// ===== HTTP plumbing =====
// do performs a request, sets the token auth header, and decodes a 2xx JSON
// body into out (out nil => body discarded). Non-2xx becomes a typed upstream
// error carrying a truncated body.
func (g *GiteaProvider) do(ctx context.Context, method, path string, body, out any) error {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return errs.Wrap(err, errs.CodeInternal, "vcs: marshal request body")
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, g.baseURL+path, rdr)
if err != nil {
return errs.Wrap(err, errs.CodeInternal, "vcs: build request")
}
if g.token != "" {
req.Header.Set("Authorization", "token "+g.token)
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := g.client.Do(req)
if err != nil {
return errs.Wrap(err, errs.CodeUpstream, "vcs: gitea request failed")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, giteaErrBodyLimit))
code := errs.CodeUpstream
if resp.StatusCode == http.StatusNotFound {
code = errs.CodeNotFound
}
return errs.New(code, fmt.Sprintf("vcs: gitea %s %s -> %d: %s",
method, path, resp.StatusCode, strings.TrimSpace(string(snippet))))
}
if out == nil {
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return errs.Wrap(err, errs.CodeUpstream, "vcs: decode gitea response")
}
return nil
}
func mapPR(pr giteaPR) PullRequest {
state := pr.State
if pr.Merged {
state = "merged"
}
return PullRequest{
Number: pr.Number,
State: state,
Mergeable: pr.Mergeable,
Merged: pr.Merged,
MergeCommitSHA: pr.MergedCommitID,
HTMLURL: pr.HTMLURL,
}
}
// esc path-escapes a single URL segment (owner/name/sha may contain odd chars).
func esc(s string) string { return url.PathEscape(s) }
+152
View File
@@ -0,0 +1,152 @@
package vcs
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
func newTestProvider(t *testing.T, h http.Handler) *GiteaProvider {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
return NewGiteaProvider(srv.URL, "test-token", 5*time.Second)
}
func TestGitea_CreatePR(t *testing.T) {
repo := RepoRef{Owner: "owner", Name: "repo"}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/owner/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "token test-token", r.Header.Get("Authorization"))
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
assert.Equal(t, "feature", body["head"])
assert.Equal(t, "main", body["base"])
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(giteaPR{
Number: 42, State: "open", Mergeable: true,
HTMLURL: "https://git.jerryyan.net/owner/repo/pulls/42",
Head: giteaBranch{Sha: "abc123", Ref: "feature"},
})
})
mux.HandleFunc("/api/v1/repos/owner/repo/commits/abc123/status", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(giteaCombinedStatus{State: "success"})
})
p := newTestProvider(t, mux)
pr, err := p.CreatePR(context.Background(), CreatePRInput{
Repo: repo, Head: "feature", Base: "main", Title: "T", Body: "B",
})
require.NoError(t, err)
assert.Equal(t, int64(42), pr.Number)
assert.Equal(t, "open", pr.State)
assert.Equal(t, "https://git.jerryyan.net/owner/repo/pulls/42", pr.HTMLURL)
assert.Equal(t, "success", pr.CIState)
}
func TestGitea_GetPR_MergedAndCI(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/owner/repo/pulls/7", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(giteaPR{
Number: 7, State: "closed", Merged: true, MergedCommitID: "deadbeef",
HTMLURL: "https://git.jerryyan.net/owner/repo/pulls/7",
Head: giteaBranch{Sha: "sha7"},
})
})
mux.HandleFunc("/api/v1/repos/owner/repo/commits/sha7/status", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(giteaCombinedStatus{State: "failure"})
})
p := newTestProvider(t, mux)
pr, err := p.GetPR(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, 7)
require.NoError(t, err)
assert.True(t, pr.Merged)
assert.Equal(t, "merged", pr.State)
assert.Equal(t, "deadbeef", pr.MergeCommitSHA)
assert.Equal(t, "failure", pr.CIState)
}
func TestGitea_Merge(t *testing.T) {
var mergeCalled bool
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/owner/repo/pulls/3/merge", func(w http.ResponseWriter, r *http.Request) {
mergeCalled = true
assert.Equal(t, http.MethodPost, r.Method)
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
assert.Equal(t, "squash", body["Do"])
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/api/v1/repos/owner/repo/pulls/3", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(giteaPR{Number: 3, State: "closed", Merged: true, MergedCommitID: "mergesha", Head: giteaBranch{Sha: "h3"}})
})
mux.HandleFunc("/api/v1/repos/owner/repo/commits/h3/status", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(giteaCombinedStatus{State: "success"})
})
p := newTestProvider(t, mux)
sha, err := p.Merge(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, 3, MergeInput{Method: "squash"})
require.NoError(t, err)
assert.True(t, mergeCalled)
assert.Equal(t, "mergesha", sha)
}
func TestGitea_Comment(t *testing.T) {
var gotBody string
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/owner/repo/issues/5/comments", func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
gotBody, _ = body["body"].(string)
w.WriteHeader(http.StatusCreated)
})
p := newTestProvider(t, mux)
require.NoError(t, p.Comment(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, 5, "hello"))
assert.Equal(t, "hello", gotBody)
}
func TestGitea_ListIssues(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/owner/repo/issues", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "issues", r.URL.Query().Get("type"))
_ = json.NewEncoder(w).Encode([]giteaIssue{
{Number: 1, Title: "bug", State: "open", HTMLURL: "u1"},
})
})
p := newTestProvider(t, mux)
issues, err := p.ListIssues(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, IssueListOptions{State: "open"})
require.NoError(t, err)
require.Len(t, issues, 1)
assert.Equal(t, "bug", issues[0].Title)
}
func TestGitea_Non2xxError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/owner/repo/pulls/9", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
})
p := newTestProvider(t, mux)
_, err := p.GetPR(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, 9)
require.Error(t, err)
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeNotFound, ae.Code)
}
func TestGitea_Host(t *testing.T) {
p := NewGiteaProvider("https://git.jerryyan.net:3000", "", 0)
assert.Equal(t, "git.jerryyan.net", p.Host())
}
+89
View File
@@ -0,0 +1,89 @@
package vcs
import (
"net/url"
"strings"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// ErrUnsupportedHost is returned by ParseRepoRef when the remote points at a
// host other than the one the v1 Gitea provider serves.
var ErrUnsupportedHost = errs.New(errs.CodeInvalidInput, "vcs: unsupported git host for PR operations")
// ParseRepoRef derives owner/name from a workspace GitRemoteURL. It accepts:
//
// https://host/owner/name(.git)
// http://host/owner/name(.git)
// ssh://git@host/owner/name(.git)
// git@host:owner/name(.git)
//
// wantHost is the bare host the provider serves (e.g. "git.jerryyan.net"); when
// non-empty the remote host must match it (port stripped, case-insensitive),
// otherwise ErrUnsupportedHost is returned. wantHost empty skips the host
// check (host-agnostic parse, used in tests).
func ParseRepoRef(remoteURL, wantHost string) (RepoRef, error) {
remoteURL = strings.TrimSpace(remoteURL)
if remoteURL == "" {
return RepoRef{}, errs.New(errs.CodeInvalidInput, "vcs: empty remote url")
}
host, path, err := splitHostPath(remoteURL)
if err != nil {
return RepoRef{}, err
}
if wantHost != "" && !sameHost(host, wantHost) {
return RepoRef{}, ErrUnsupportedHost
}
owner, name, err := splitOwnerName(path)
if err != nil {
return RepoRef{}, err
}
return RepoRef{Owner: owner, Name: name}, nil
}
// splitHostPath extracts the bare host and the repo path from a remote URL,
// handling both scheme URLs and the scp-like git@host:owner/name form.
func splitHostPath(remoteURL string) (host, path string, err error) {
// scp-like: git@host:owner/name.git (no scheme, single colon before path)
if !strings.Contains(remoteURL, "://") && strings.Contains(remoteURL, "@") && strings.Contains(remoteURL, ":") {
at := strings.Index(remoteURL, "@")
rest := remoteURL[at+1:]
colon := strings.Index(rest, ":")
if colon < 0 {
return "", "", errs.New(errs.CodeInvalidInput, "vcs: malformed scp remote url")
}
return rest[:colon], rest[colon+1:], nil
}
u, perr := url.Parse(remoteURL)
if perr != nil || u.Host == "" {
return "", "", errs.Wrap(perr, errs.CodeInvalidInput, "vcs: cannot parse remote url")
}
return u.Hostname(), u.Path, nil
}
// splitOwnerName turns "/owner/name.git" or "owner/name.git" into (owner, name).
func splitOwnerName(path string) (owner, name string, err error) {
path = strings.Trim(path, "/")
path = strings.TrimSuffix(path, ".git")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" || parts[len(parts)-1] == "" {
return "", "", errs.New(errs.CodeInvalidInput, "vcs: remote url must contain owner/name")
}
// Gitea repos are owner/name; deeper paths are not valid repo refs.
return parts[0], parts[len(parts)-1], nil
}
// sameHost compares hosts case-insensitively, ignoring any :port suffix.
func sameHost(a, b string) bool {
return strings.EqualFold(stripPort(a), stripPort(b))
}
func stripPort(h string) string {
if i := strings.LastIndex(h, ":"); i >= 0 {
return h[:i]
}
return h
}
+60
View File
@@ -0,0 +1,60 @@
package vcs
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseRepoRef_HTTPS(t *testing.T) {
cases := []struct{ url, owner, name string }{
{"https://git.jerryyan.net/owner/repo.git", "owner", "repo"},
{"https://git.jerryyan.net/owner/repo", "owner", "repo"},
{"http://git.jerryyan.net/acme/widgets.git", "acme", "widgets"},
{"https://git.jerryyan.net:3000/owner/repo.git", "owner", "repo"},
}
for _, c := range cases {
ref, err := ParseRepoRef(c.url, "git.jerryyan.net")
require.NoError(t, err, c.url)
assert.Equal(t, c.owner, ref.Owner, c.url)
assert.Equal(t, c.name, ref.Name, c.url)
}
}
func TestParseRepoRef_SSHAndScp(t *testing.T) {
cases := []struct{ url, owner, name string }{
{"git@git.jerryyan.net:owner/repo.git", "owner", "repo"},
{"git@git.jerryyan.net:owner/repo", "owner", "repo"},
{"ssh://git@git.jerryyan.net/owner/repo.git", "owner", "repo"},
}
for _, c := range cases {
ref, err := ParseRepoRef(c.url, "git.jerryyan.net")
require.NoError(t, err, c.url)
assert.Equal(t, c.owner, ref.Owner, c.url)
assert.Equal(t, c.name, ref.Name, c.url)
}
}
func TestParseRepoRef_UnsupportedHost(t *testing.T) {
_, err := ParseRepoRef("https://github.com/owner/repo.git", "git.jerryyan.net")
require.ErrorIs(t, err, ErrUnsupportedHost)
_, err = ParseRepoRef("git@github.com:owner/repo.git", "git.jerryyan.net")
require.ErrorIs(t, err, ErrUnsupportedHost)
}
func TestParseRepoRef_Invalid(t *testing.T) {
for _, bad := range []string{"", "not-a-url", "https://git.jerryyan.net/only-owner"} {
_, err := ParseRepoRef(bad, "git.jerryyan.net")
require.Error(t, err, bad)
}
}
func TestParseRepoRef_HostAgnostic(t *testing.T) {
// wantHost empty => skip host check
ref, err := ParseRepoRef("https://github.com/owner/repo.git", "")
require.NoError(t, err)
assert.Equal(t, "owner", ref.Owner)
assert.Equal(t, "repo", ref.Name)
}
+75
View File
@@ -0,0 +1,75 @@
// Package vcs abstracts the git-host (VCS) REST API the platform uses to open
// and merge pull requests. v1 ships a Gitea-first implementation (gitea.go)
// targeting the self-hosted host git.jerryyan.net; other hosts return an
// Unimplemented error from the RepoRef parser / provider.
//
// The provider is intentionally narrow: only the verbs the change-request
// merge gate needs (CreatePR / GetPR / Comment / Merge / ListIssues). Secrets
// (the platform Gitea token) are held by the concrete provider, never passed
// through the interface.
package vcs
import "context"
// Provider is the git-host API surface consumed by the changerequest service.
type Provider interface {
// CreatePR opens a pull request from in.Head into in.Base.
CreatePR(ctx context.Context, in CreatePRInput) (*PullRequest, error)
// GetPR fetches a single PR by its host-side index/number, including a
// best-effort CI state derived from the head commit's combined status.
GetPR(ctx context.Context, repo RepoRef, number int64) (*PullRequest, error)
// Comment posts a comment on the PR (Gitea treats PRs as issues for comments).
Comment(ctx context.Context, repo RepoRef, number int64, body string) error
// Merge merges the PR via in.Method, returning the resulting merge commit SHA.
Merge(ctx context.Context, repo RepoRef, number int64, in MergeInput) (mergeSHA string, err error)
// ListIssues lists repository issues (not PRs) for orientation / linking.
ListIssues(ctx context.Context, repo RepoRef, opts IssueListOptions) ([]Issue, error)
}
// RepoRef identifies a repository on the host as owner/name. Derived from a
// workspace's GitRemoteURL by ParseRepoRef.
type RepoRef struct {
Owner string
Name string
}
// CreatePRInput is the input to Provider.CreatePR.
type CreatePRInput struct {
Repo RepoRef
Head string // source branch
Base string // target branch
Title string
Body string
}
// MergeInput controls how Provider.Merge merges the PR.
type MergeInput struct {
Method string // "merge" | "squash" | "rebase"
Title string // merge commit title (optional)
Message string // merge commit message body (optional)
}
// PullRequest is the host's view of a PR mapped to platform fields.
type PullRequest struct {
Number int64
State string // "open" | "closed" (Gitea: PR closed covers merged too)
Mergeable bool
Merged bool
MergeCommitSHA string
HTMLURL string
CIState string // "unknown" | "pending" | "success" | "failure"
}
// Issue is a minimal host issue projection.
type Issue struct {
Number int64
Title string
State string
HTMLURL string
}
// IssueListOptions filters ListIssues. State empty => host default ("open").
type IssueListOptions struct {
State string // "open" | "closed" | "all"
Limit int
}