429 lines
13 KiB
Markdown
429 lines
13 KiB
Markdown
# AGENTS.md
|
|
|
|
## Build & Development
|
|
|
|
| Command | Purpose |
|
|
|---|---|
|
|
| `make build` | Build `bin/probod` (includes frontend apps and codegen) |
|
|
| `SKIP_APPS=1 make build` | Build `bin/probod` without frontend apps (faster for backend-only work) |
|
|
| `make test` | Run tests with race detection and coverage |
|
|
| `make test MODULE=./pkg/foo` | Run tests for a single module |
|
|
| `make test-verbose` | Tests with verbose output |
|
|
| `make lint` | Vet + Go lint + npm lint |
|
|
| `make fmt` | Format Go code |
|
|
| `make dev` | Start dev server (Go + console hot-reload) |
|
|
| `make test-e2e` | Run console end-to-end tests (requires `bin/probod`) |
|
|
| `make deadcode` | Detect dead code — run after removing or renaming exported functions |
|
|
| `make stack-up` / `make stack-down` | Start / stop Docker compose infra |
|
|
| `make psql` | Open psql shell to dev database |
|
|
|
|
GraphQL and MCP codegen is triggered by `go generate`:
|
|
- `go generate ./pkg/server/api/console/v1`
|
|
- `go generate ./pkg/server/api/connect/v1`
|
|
- `go generate ./pkg/server/api/trust/v1`
|
|
- `go generate ./pkg/server/api/mcp/v1`
|
|
|
|
## Project
|
|
|
|
- Module: `go.probo.inc/probo`
|
|
- Router: `github.com/go-chi/chi/v5`
|
|
- Database: `go.gearno.de/kit/pg` — all raw SQL lives in `pkg/coredata`, never elsewhere
|
|
- HTTP server: `go.gearno.de/kit/httpserver`
|
|
- HTTP client: `go.gearno.de/kit/httpclient`
|
|
- Logging: `go.gearno.de/kit/log`
|
|
- Tracing: OpenTelemetry (`go.opentelemetry.io/otel`)
|
|
- UUID: `go.gearno.de/crypto/uuid` (never use `github.com/google/uuid`)
|
|
- Pointers: `go.gearno.de/x/ref` for pointer helpers (`ref.UnrefOrZero`, etc.)
|
|
- Tests: `github.com/stretchr/testify` (`require` for fatal, `assert` for non-fatal)
|
|
- Go version: 1.26 — use `new(expr)` to create pointers to values (e.g. `new(1)`, `new("foo")`, `new(time.Now())`) instead of helper functions or temporary variables
|
|
|
|
## Commit Messages
|
|
|
|
Follow the [seven rules of a great Git commit message](https://cbea.ms/git-commit/):
|
|
|
|
1. Separate subject from body with a blank line
|
|
2. Limit the subject line to 50 characters
|
|
3. Capitalize the subject line
|
|
4. Do not end the subject line with a period
|
|
5. Use the imperative mood in the subject line
|
|
6. Wrap the body at 72 characters
|
|
7. Use the body to explain *what* and *why* vs. *how*
|
|
|
|
The subject line should complete the sentence: "If applied, this commit will *your subject line here*".
|
|
|
|
```
|
|
Add vendor assessment agent for third-party reviews
|
|
|
|
The existing changelog generator only covers internal changes.
|
|
This introduces a dedicated agent that evaluates third-party
|
|
vendors against our compliance criteria, producing a structured
|
|
risk report.
|
|
```
|
|
|
|
Not every commit needs a body -- a single line is fine when the change is self-explanatory:
|
|
|
|
```
|
|
Fix typo in vendor assessment prompt
|
|
```
|
|
|
|
## Go Style
|
|
|
|
### Grouped declarations
|
|
|
|
Use `type ()`, `const ()`, and `var ()` blocks to group related declarations. Use explicit typed values for string enums, not `iota`.
|
|
|
|
```go
|
|
type (
|
|
CreateFooRequest struct {
|
|
Name string
|
|
Active bool
|
|
}
|
|
|
|
UpdateFooRequest struct {
|
|
ID gid.GID
|
|
Name *string
|
|
Active *bool
|
|
}
|
|
)
|
|
|
|
const (
|
|
NameMaxLength = 100
|
|
ContentMaxLength = 5000
|
|
)
|
|
|
|
var (
|
|
_ Reader = (*FileReader)(nil)
|
|
_ Writer = (*FileWriter)(nil)
|
|
)
|
|
```
|
|
|
|
### One argument per line
|
|
|
|
A function call is either entirely on one line or fully expanded with one argument per line. Never mix the two styles.
|
|
|
|
```go
|
|
// Good — short enough to fit on one line
|
|
id := gid.New(tenantID, "Foo")
|
|
|
|
// Good — multiple arguments, one per line
|
|
svc, err := foo.NewService(
|
|
ctx,
|
|
db,
|
|
logger,
|
|
foo.Config{
|
|
Interval: 10 * time.Second,
|
|
MaxRetry: 3,
|
|
},
|
|
)
|
|
|
|
// Bad — mixed inline and multiline
|
|
svc, err := foo.NewService(ctx, db, logger, foo.Config{
|
|
Interval: 10 * time.Second,
|
|
})
|
|
```
|
|
|
|
### License header
|
|
|
|
Every `.go` file starts with the ISC license header. Use the **current year** for new files. When editing an existing file whose header has a different year, update it to a range (e.g. `2025-2026`). Never overwrite the original year.
|
|
|
|
```
|
|
// Copyright (c) <YEAR> Probo Inc <hello@getprobo.com>.
|
|
//
|
|
// Permission to use, copy, modify, and/or distribute this software for any
|
|
// purpose with or without fee is hereby granted, provided that the above
|
|
// copyright notice and this permission notice appear in all copies.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
// PERFORMANCE OF THIS SOFTWARE.
|
|
```
|
|
|
|
### Import ordering
|
|
|
|
Two groups separated by a blank line: stdlib, then everything else (third-party and internal sorted together alphabetically).
|
|
|
|
```go
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"go.gearno.de/kit/httpserver"
|
|
"go.gearno.de/kit/log"
|
|
"go.probo.inc/probo/pkg/iam"
|
|
"go.probo.inc/probo/pkg/probo"
|
|
"go.probo.inc/probo/pkg/trust"
|
|
)
|
|
```
|
|
|
|
### Receiver names
|
|
|
|
Short receivers: usually single-letter matching the type (`s` for Service, `c` for Client, `p` for Provider).
|
|
|
|
### Error handling
|
|
|
|
Wrap errors with `fmt.Errorf` using lowercase messages starting with `cannot`:
|
|
|
|
```go
|
|
return nil, fmt.Errorf("cannot load trust center: %w", err)
|
|
return nil, fmt.Errorf("cannot create SAML service: %w", err)
|
|
```
|
|
|
|
Sentinel errors in grouped `var ()` blocks. Custom error types implement `Unwrap() error`. Use `errors.Is` / `errors.As` for checks.
|
|
|
|
### Naming
|
|
|
|
- Constructors: `New*` (e.g. `NewService`, `NewServer`, `NewBridge`)
|
|
- Config structs: `*Config` suffix (e.g. `APIConfig`, `PgConfig`, `TrustCenterConfig`)
|
|
- Request structs: `*Request` suffix (e.g. `UpdateTrustCenterRequest`)
|
|
- Unexported types for internal data: lowercase (e.g. `vendorInfo`, `ctxKey`)
|
|
|
|
### Tests
|
|
|
|
Black-box test packages (`package foo_test`). White-box (`package foo`) only when testing unexported functions. Test names follow `TestFunctionName_Scenario`. Use `t.Run` for subtests with lowercase descriptive names. Table-driven tests for parameterized cases.
|
|
|
|
**Parallel tests:** Always call `t.Parallel()` at both the top-level test and each subtest.
|
|
|
|
**`require` vs `assert`:**
|
|
- `require` — stops the test immediately on failure. Use for **preconditions** that would make subsequent assertions meaningless: `require.NoError`, `require.Error`, `require.ErrorAs`, `require.Len`, `require.NotNil`, `require.True` (as a guard).
|
|
- `assert` — logs failure but continues the test. Use for the **actual values** being verified: `assert.Equal`, `assert.Contains`, `assert.True`, `assert.False`, `assert.Nil`.
|
|
|
|
Rule of thumb: if a failure would cause a nil-pointer panic or make every following assertion nonsensical, use `require`; otherwise use `assert`.
|
|
|
|
```go
|
|
func TestRun_Handoff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run(
|
|
"handoff with custom tool name and description",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// ... setup ...
|
|
|
|
result, err := triage.Run(
|
|
context.Background(),
|
|
[]llm.Message{
|
|
userMessage("How much is my invoice?"),
|
|
},
|
|
)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Your invoice is $42.", result.FinalMessage().Text())
|
|
assert.Equal(t, "billing", result.LastAgent.Name())
|
|
},
|
|
)
|
|
}
|
|
```
|
|
|
|
**Helpers and mocks:** Define mock types and helper functions (e.g. `stopResponse`, `userMessage`) at the top of the test file, not inline in each test.
|
|
|
|
### Functional options and Config structs
|
|
|
|
Use `Config` structs when a constructor has many required parameters. Use functional options (`With*` functions) for optional configuration.
|
|
|
|
```go
|
|
type Option func(*Bridge)
|
|
|
|
func WithDryRun(dryRun bool) Option {
|
|
return func(s *Bridge) {
|
|
s.dryRun = dryRun
|
|
}
|
|
}
|
|
|
|
func NewBridge(provider provider.Provider, client *scimclient.Client, opts ...Option) *Bridge {
|
|
s := &Bridge{provider: provider, scimClient: client}
|
|
for _, opt := range opts {
|
|
opt(s)
|
|
}
|
|
return s
|
|
}
|
|
```
|
|
|
|
### Interfaces
|
|
|
|
Define interfaces in the consumer package. Keep them small. Verify satisfaction at compile time:
|
|
|
|
```go
|
|
var (
|
|
_ unit.Configurable = (*Implm)(nil)
|
|
_ unit.Runnable = (*Implm)(nil)
|
|
)
|
|
```
|
|
|
|
### Context
|
|
|
|
Always first parameter. Private struct keys for context values:
|
|
|
|
```go
|
|
type ctxKey struct{ name string }
|
|
var trustCenterIDKey = &ctxKey{name: "trust_center_id"}
|
|
```
|
|
|
|
### Logging
|
|
|
|
Named, context-aware structured logging with typed fields. **Never log PII, PHI, or other sensitive data** (e.g. emails, names, passwords, tokens, health records). Log opaque identifiers (IDs, request IDs) instead.
|
|
|
|
```go
|
|
l.InfoCtx(ctx, "HTTP request to trust center custom domain, redirecting to HTTPS",
|
|
log.String("domain", domain),
|
|
log.String("path", r.URL.Path),
|
|
log.String("to", httpsURL),
|
|
)
|
|
```
|
|
|
|
### Service `Run()` orchestration
|
|
|
|
A top-level `Run` method starts child subsystems (workers, servers) as goroutines via `sync.WaitGroup.Go`. Each child gets its own cancellable context created with `context.WithCancel(context.WithoutCancel(ctx))` so that a parent cancellation does not kill in-flight work — the parent explicitly calls each `stop*` function and then `wg.Wait()` for a controlled shutdown.
|
|
|
|
When a child crashes, it calls `cancel(fmt.Errorf("… crashed: %w", err))` to signal the parent.
|
|
|
|
```go
|
|
func (impl *Implm) Run(ctx context.Context, l *log.Logger) error {
|
|
wg := sync.WaitGroup{}
|
|
ctx, cancel := context.WithCancelCause(ctx)
|
|
defer cancel(context.Canceled)
|
|
|
|
// Start a worker
|
|
workerCtx, stopWorker := context.WithCancel(context.WithoutCancel(ctx))
|
|
worker := NewFooWorker(pgClient, l.Named("foo-worker"))
|
|
wg.Go(
|
|
func() {
|
|
if err := worker.Run(workerCtx); err != nil {
|
|
cancel(fmt.Errorf("foo worker crashed: %w", err))
|
|
}
|
|
},
|
|
)
|
|
|
|
// Start a server
|
|
serverCtx, stopServer := context.WithCancel(context.WithoutCancel(ctx))
|
|
defer stopServer()
|
|
wg.Go(
|
|
func() {
|
|
if err := impl.runServer(serverCtx, l); err != nil {
|
|
cancel(fmt.Errorf("server crashed: %w", err))
|
|
}
|
|
},
|
|
)
|
|
|
|
<-ctx.Done()
|
|
|
|
stopServer()
|
|
stopWorker()
|
|
|
|
wg.Wait()
|
|
|
|
return context.Cause(ctx)
|
|
}
|
|
```
|
|
|
|
### Workers
|
|
|
|
Background workers follow a poll-based pattern with bounded concurrency. The struct holds a `*pg.Client`, a `*log.Logger`, and tuning knobs (`interval`, `staleAfter`, `maxConcurrency`). Use functional options (`With*` functions) for the tuning knobs with sensible defaults.
|
|
|
|
The `Run(ctx context.Context) error` method loops with a `select` on `ctx.Done()` and `time.After(interval)`. On each tick it recovers stale rows, then drains available work via `processNext`. Work items are claimed inside a transaction with `FOR UPDATE SKIP LOCKED`, marked as processing, then handled concurrently in goroutines bounded by a semaphore channel. Use `context.WithoutCancel` for work that must complete even after shutdown, and `sync.WaitGroup` with `defer wg.Wait()` to ensure in-flight goroutines finish before `Run` returns.
|
|
|
|
```go
|
|
type (
|
|
FooWorker struct {
|
|
pg *pg.Client
|
|
logger *log.Logger
|
|
interval time.Duration
|
|
staleAfter time.Duration
|
|
maxConcurrency int
|
|
}
|
|
|
|
FooWorkerOption func(*FooWorker)
|
|
)
|
|
|
|
func NewFooWorker(
|
|
pgClient *pg.Client,
|
|
logger *log.Logger,
|
|
opts ...FooWorkerOption,
|
|
) *FooWorker {
|
|
w := &FooWorker{
|
|
pg: pgClient,
|
|
logger: logger,
|
|
interval: 10 * time.Second,
|
|
staleAfter: 5 * time.Minute,
|
|
maxConcurrency: 5,
|
|
}
|
|
for _, opt := range opts {
|
|
opt(w)
|
|
}
|
|
return w
|
|
}
|
|
|
|
func (w *FooWorker) Run(ctx context.Context) error {
|
|
var (
|
|
wg sync.WaitGroup
|
|
sem = make(chan struct{}, w.maxConcurrency)
|
|
)
|
|
defer wg.Wait()
|
|
|
|
LOOP:
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(w.interval):
|
|
nonCancelableCtx := context.WithoutCancel(ctx)
|
|
w.recoverStaleRows(nonCancelableCtx)
|
|
for {
|
|
if err := w.processNext(ctx, sem, &wg); err != nil {
|
|
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
|
w.logger.ErrorCtx(nonCancelableCtx, "cannot claim item", log.Error(err))
|
|
}
|
|
break
|
|
}
|
|
}
|
|
goto LOOP
|
|
}
|
|
}
|
|
|
|
func (w *FooWorker) processNext(ctx context.Context, sem chan struct{}, wg *sync.WaitGroup) error {
|
|
select {
|
|
case sem <- struct{}{}:
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
|
|
var (
|
|
item coredata.FooItem
|
|
now = time.Now()
|
|
nonCancelableCtx = context.WithoutCancel(ctx)
|
|
)
|
|
|
|
if err := w.pg.WithTx(
|
|
nonCancelableCtx,
|
|
func(tx pg.Conn) error {
|
|
if err := item.LoadNextPendingForUpdateSkipLocked(nonCancelableCtx, tx); err != nil {
|
|
return err
|
|
}
|
|
item.Status = coredata.FooStatusProcessing
|
|
item.UpdatedAt = now
|
|
return item.Update(nonCancelableCtx, tx, coredata.NewNoScope())
|
|
},
|
|
); err != nil {
|
|
<-sem
|
|
return err
|
|
}
|
|
|
|
wg.Add(1)
|
|
go func(item coredata.FooItem) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
|
|
if err := w.handle(nonCancelableCtx, &item); err != nil {
|
|
w.logger.ErrorCtx(nonCancelableCtx, "cannot process item", log.Error(err))
|
|
}
|
|
}(item)
|
|
|
|
return nil
|
|
}
|
|
```
|