mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add harness engineering layer for agent workflows (#24791)
This PR adds an opinionated harness-engineering layer for agent-driven workflows: a small set of agent-readable docs, mechanical structure checks, structured CI failure summaries, an architecture-lint umbrella, and per-worktree dev-server isolation. The goal is to make local dev, tests, and CI mechanically inspectable by agents without changing app runtime behavior. ## What landed **Agent docs and navigation** - `.claude/docs/OBSERVABILITY.md`, `.claude/docs/DEV_ISOLATION.md`, `.claude/docs/AGENT_FAILURES.md`: task-oriented guides for logs, tracing, Prometheus, dev-server isolation, and a seeded failure catalog. - `AGENTS.md`: added an `Agent navigation` block, then trimmed the file from 375 to 229 lines by migrating duplicated detail into `WORKFLOWS.md`, `GO.md`, `TESTING.md`, and `DATABASE.md`. The user-managed custom-instructions block is preserved. - `.agents/docs`: symlink mirror of `.claude/docs` for agent runtimes that look under `.agents`. **Mechanical checks** - `scripts/check_agents_structure.sh`: validates `@...` references in tracked `AGENTS.md` files and warns when root grows past 600 lines. Wired as `make lint/agents` and into `make lint`. - `scripts/audit-agent-readiness.sh`: report-first audit of harness readiness. Currently `10 ok, 0 warn, 0 fail`. - `scripts/check_architecture.sh` / `make lint/architecture`: umbrella architecture-lint target. Consolidates the existing `check_enterprise_imports.sh` and `check_codersdk_imports.sh` so they run exactly once via the umbrella. Slot is open for new high-confidence rules. **Structured CI failure summaries** - `scripts/playwright-failure-summary.sh`: parses `site/test-results/results.json` and writes Markdown to `$GITHUB_STEP_SUMMARY` on failure. Wired into the `test-e2e` matrix job. - `scripts/go-test-failure-summary.sh`: parses `go test -json` line-delimited output the same way. Wired into `test-go-pg`, `test-go-pg-17`, and `test-go-race-pg` by injecting `gotestsum --jsonfile` in the workflow without touching `Makefile`. JSON also uploaded as a CI artifact on failure. - `site/e2e/playwright.config.ts`: enables `screenshot: only-on-failure`, `trace: retain-on-failure`, JSON reporter, and HTML reporter alongside existing reporters. - `.github/workflows/ci.yaml`: failure artifact uploads for Playwright now use `if: failure()` and predictable names (`playwright-artifacts-<variant>-<sha>`). **Per-worktree dev-server isolation** (`scripts/develop/main.go`) - Deterministic FNV-64a hash of the worktree path produces a port offset in `[0, 1000)` (50 buckets, step 20 to avoid API/proxy overlap across adjacent buckets). - Offset is applied only to defaults; both env vars (`CODER_DEV_PORT`, `CODER_DEV_WEB_PORT`, `CODER_DEV_PROXY_PORT`, `CODER_DEV_PROMETHEUS_PORT`) and CLI flags retain priority. - Hardcoded ports `9090` (embedded Prometheus UI) and `12345` (Delve) are unchanged by design. - Startup banner shows each port's source: `default`, `offset`, or `explicit`. - Unit tests in `scripts/develop/main_test.go` cover determinism, bounds, no-overlap across the four ports, and explicit-skip behavior. - State (`.coderv2/`) was already worktree-isolated via `os.Getwd()`, so no state-dir changes were needed. ## Validation `make lint/agents`, `make lint/architecture`, `make lint/emdash`, `bash scripts/audit-agent-readiness.sh` (10 ok, 0 warn, 0 fail), `shellcheck` on all 5 new scripts, `go test ./scripts/develop/...`, and `js-yaml` parse of `ci.yaml` all pass. Synthetic fixtures verify both failure-summary scripts handle empty/missing input (silent exit 0), ANSI-stripped output, and parent/subtest formatting. ## Known follow-ups (deferred) - Frontend Storybook/Vitest failure summary: lowest-leverage slice of the failure-summary work. Skipping until observed pain. - Architecture lint currently only delegates to existing import checks; new rules (`InTx` outer-store detection, swagger-annotation lint) plug in as needed. - 50 port-offset buckets means two worktree paths can occasionally collide. The DEV_ISOLATION doc tells users to set the relevant env var when this happens. > Mux opened this PR on Mike's behalf.
This commit is contained in:
+164
-27
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -52,7 +53,13 @@ const (
|
||||
prometheusContainerName = "coder-prometheus"
|
||||
// defaultPrometheusPort avoids 2112 (agent prometheus) and
|
||||
// 2113 (agent debug) already bound inside Coder workspaces.
|
||||
defaultPrometheusPort = "2114"
|
||||
defaultPrometheusPort = "2114"
|
||||
// portOffsetBuckets keeps the offset below 1000 while leaving
|
||||
// enough hash buckets for common multi-worktree use.
|
||||
portOffsetBuckets = 50
|
||||
// portOffsetStep avoids overlap between the default API and proxy
|
||||
// ports when two worktrees land in adjacent buckets.
|
||||
portOffsetStep = 20
|
||||
prometheusImage = "prom/prometheus:v3.11.2"
|
||||
defaultAccessURL = "http://127.0.0.1:%d"
|
||||
defaultPassword = "SomeSecurePassword!"
|
||||
@@ -96,6 +103,13 @@ func main() {
|
||||
Description: "Prometheus metrics port. Set to 0 to disable.",
|
||||
Value: serpent.Int64Of(&cfg.coderMetricsPort),
|
||||
},
|
||||
{
|
||||
Flag: "port-offset",
|
||||
Env: "CODER_DEV_PORT_OFFSET",
|
||||
Default: "false",
|
||||
Description: "Apply a deterministic per-worktree offset to default API, web, proxy, and Coder metrics ports. Useful when running multiple worktrees in parallel.",
|
||||
Value: serpent.BoolOf(&cfg.portOffsetEnabled),
|
||||
},
|
||||
{
|
||||
Flag: "prometheus-server",
|
||||
Env: "CODER_DEV_PROMETHEUS_SERVER",
|
||||
@@ -171,12 +185,13 @@ func main() {
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
cfg.serverExtraArgs = inv.Args
|
||||
cfg.portExplicit = portExplicitFromInvocation(inv)
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stderr))
|
||||
if err := cfg.validate(); err != nil {
|
||||
if err := cfg.resolveEnv(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.resolveEnv(); err != nil {
|
||||
if err := cfg.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return develop(inv.Context(), logger, &cfg)
|
||||
@@ -191,30 +206,123 @@ func main() {
|
||||
}
|
||||
|
||||
type devConfig struct {
|
||||
apiPort int64
|
||||
webPort int64
|
||||
proxyPort int64
|
||||
coderMetricsPort int64
|
||||
prometheusServer bool
|
||||
agpl bool
|
||||
accessURL string
|
||||
password string
|
||||
useProxy bool
|
||||
debug bool
|
||||
skipSetup bool
|
||||
multiOrg bool
|
||||
starterTemplate string
|
||||
dbRollback bool
|
||||
dbReset bool
|
||||
dbContinue bool
|
||||
projectRoot string
|
||||
binaryPath string
|
||||
configDir string
|
||||
childEnv []string
|
||||
apiPort int64
|
||||
webPort int64
|
||||
proxyPort int64
|
||||
coderMetricsPort int64
|
||||
portOffsetEnabled bool
|
||||
prometheusServer bool
|
||||
agpl bool
|
||||
accessURL string
|
||||
password string
|
||||
useProxy bool
|
||||
debug bool
|
||||
skipSetup bool
|
||||
multiOrg bool
|
||||
starterTemplate string
|
||||
dbRollback bool
|
||||
dbReset bool
|
||||
dbContinue bool
|
||||
projectRoot string
|
||||
binaryPath string
|
||||
configDir string
|
||||
childEnv []string
|
||||
portExplicit portExplicit
|
||||
portOffset int
|
||||
apiPortSource portSource
|
||||
webPortSource portSource
|
||||
proxyPortSource portSource
|
||||
metricsPortSource portSource
|
||||
// Extra args after flags forwarded to "coder server".
|
||||
serverExtraArgs []string
|
||||
}
|
||||
|
||||
type portExplicit struct {
|
||||
api bool
|
||||
web bool
|
||||
proxy bool
|
||||
metrics bool
|
||||
}
|
||||
|
||||
type portSource string
|
||||
|
||||
const (
|
||||
portSourceDefault portSource = "default"
|
||||
portSourceExplicit portSource = "explicit"
|
||||
portSourceOffset portSource = "offset"
|
||||
)
|
||||
|
||||
func portExplicitFromInvocation(inv *serpent.Invocation) portExplicit {
|
||||
return portExplicit{
|
||||
api: isPortExplicit(inv, "port", "CODER_DEV_PORT"),
|
||||
web: isPortExplicit(inv, "web-port", "CODER_DEV_WEB_PORT"),
|
||||
proxy: isPortExplicit(inv, "proxy-port", "CODER_DEV_PROXY_PORT"),
|
||||
metrics: isPortExplicit(inv, "prometheus-port", "CODER_DEV_PROMETHEUS_PORT"),
|
||||
}
|
||||
}
|
||||
|
||||
func isPortExplicit(inv *serpent.Invocation, flagName, envName string) bool {
|
||||
if flag := inv.ParsedFlags().Lookup(flagName); flag != nil && flag.Changed {
|
||||
return true
|
||||
}
|
||||
if val, ok := inv.Environ.Lookup(envName); ok && val != "" {
|
||||
return true
|
||||
}
|
||||
for _, opt := range inv.Command.Options {
|
||||
if opt.Flag == flagName {
|
||||
return opt.ValueSource == serpent.ValueSourceFlag ||
|
||||
opt.ValueSource == serpent.ValueSourceEnv
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// portOffset returns a deterministic offset in [0, 1000) derived from the
|
||||
// worktree path. Successive callers with the same projectRoot get the same
|
||||
// offset; different projectRoots get different offsets with high probability.
|
||||
func portOffset(projectRoot string) int {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(projectRoot))
|
||||
bucket := h.Sum64() % uint64(portOffsetBuckets)
|
||||
return int(bucket) * portOffsetStep //nolint:gosec // Bucket is less than portOffsetBuckets.
|
||||
}
|
||||
|
||||
func (c *devConfig) applyPortOffset() {
|
||||
c.portOffset = 0
|
||||
if !c.portOffsetEnabled {
|
||||
return
|
||||
}
|
||||
c.portOffset = portOffset(c.projectRoot)
|
||||
if c.portExplicit.api {
|
||||
c.apiPortSource = portSourceExplicit
|
||||
} else {
|
||||
c.apiPortSource = c.applyDefaultPortOffset(&c.apiPort)
|
||||
}
|
||||
if c.portExplicit.web {
|
||||
c.webPortSource = portSourceExplicit
|
||||
} else {
|
||||
c.webPortSource = c.applyDefaultPortOffset(&c.webPort)
|
||||
}
|
||||
if c.portExplicit.proxy {
|
||||
c.proxyPortSource = portSourceExplicit
|
||||
} else {
|
||||
c.proxyPortSource = c.applyDefaultPortOffset(&c.proxyPort)
|
||||
}
|
||||
if c.portExplicit.metrics {
|
||||
c.metricsPortSource = portSourceExplicit
|
||||
} else {
|
||||
c.metricsPortSource = c.applyDefaultPortOffset(&c.coderMetricsPort)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *devConfig) applyDefaultPortOffset(port *int64) portSource {
|
||||
if c.portOffset == 0 {
|
||||
return portSourceDefault
|
||||
}
|
||||
*port += int64(c.portOffset)
|
||||
return portSourceOffset
|
||||
}
|
||||
|
||||
func (c *devConfig) validate() error {
|
||||
if c.agpl && c.useProxy {
|
||||
return xerrors.New("cannot use both --agpl and --use-proxy")
|
||||
@@ -293,10 +401,6 @@ func (c *devConfig) validate() error {
|
||||
// resolveEnv sets defaults, unsets leaked credentials, resolves
|
||||
// filesystem paths, and computes the child process environment.
|
||||
func (c *devConfig) resolveEnv() error {
|
||||
if strings.Contains(c.accessURL, "%d") {
|
||||
c.accessURL = fmt.Sprintf(c.accessURL, c.apiPort)
|
||||
}
|
||||
|
||||
// Prevent inherited credentials from leaking into child
|
||||
// processes or being picked up by config reads.
|
||||
_ = os.Unsetenv("CODER_SESSION_TOKEN")
|
||||
@@ -311,6 +415,11 @@ func (c *devConfig) resolveEnv() error {
|
||||
fmt.Sprintf("coder_%s_%s", runtime.GOOS, runtime.GOARCH))
|
||||
c.configDir = filepath.Join(c.projectRoot, ".coderv2")
|
||||
|
||||
c.applyPortOffset()
|
||||
if strings.Contains(c.accessURL, "%d") {
|
||||
c.accessURL = fmt.Sprintf(c.accessURL, c.apiPort)
|
||||
}
|
||||
|
||||
// Compute once, reused by cmd().
|
||||
c.childEnv = filterEnv(os.Environ(), "CODER_SESSION_TOKEN", "CODER_URL")
|
||||
|
||||
@@ -1120,6 +1229,28 @@ func prometheusBannerEntry(cfg *devConfig, prometheusServerStarted bool) (label
|
||||
}
|
||||
}
|
||||
|
||||
func portBannerLine(label string, port int64, source portSource, offset int) string {
|
||||
portValue := strconv.FormatInt(port, 10)
|
||||
if port == 0 {
|
||||
portValue = "disabled"
|
||||
}
|
||||
if source == "" {
|
||||
return fmt.Sprintf("%s: %s", label, portValue)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s (%s)", label, portValue, portSourceLabel(source, offset))
|
||||
}
|
||||
|
||||
func portSourceLabel(source portSource, offset int) string {
|
||||
switch source {
|
||||
case portSourceExplicit:
|
||||
return fmt.Sprintf("explicit, offset +%d skipped", offset)
|
||||
case portSourceOffset:
|
||||
return fmt.Sprintf("offset +%d", offset)
|
||||
default:
|
||||
return fmt.Sprintf("default, offset +%d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
func printBanner(ctx context.Context, logger slog.Logger, cfg *devConfig, prometheusServerStarted bool) {
|
||||
ifaces := []string{"localhost"}
|
||||
if addrs, err := net.InterfaceAddrs(); err == nil {
|
||||
@@ -1153,6 +1284,12 @@ func printBanner(ctx context.Context, logger slog.Logger, cfg *devConfig, promet
|
||||
"",
|
||||
indent("Coder is now running in development mode."),
|
||||
"",
|
||||
"Effective ports:",
|
||||
indent(portBannerLine("API", cfg.apiPort, cfg.apiPortSource, cfg.portOffset)),
|
||||
indent(portBannerLine("Web UI", cfg.webPort, cfg.webPortSource, cfg.portOffset)),
|
||||
indent(portBannerLine("Proxy", cfg.proxyPort, cfg.proxyPortSource, cfg.portOffset)),
|
||||
indent(portBannerLine("Coder metrics", cfg.coderMetricsPort, cfg.metricsPortSource, cfg.portOffset)),
|
||||
"",
|
||||
"API:",
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user