feat: add chat lifecycle hook dispatch backend (#27401)

Adds the chat lifecycle hook wire contract and dispatch plumbing, first
PR of the lifecycle hooks stack (followed by #27428, #27429, #27430).

- `codersdk/x/agenthooks`: event and response wire types, JWT creation
and verification with the shared secret (HS256, request body digest,
expiry and not-before freshness checks), and an HTTP handler helper so
consumers only implement the events they use. The `codersdk/x` location
marks the consumer SDK as experimental.
- `coderd/x/agenthooks/dispatch`: a stateless dispatcher that signs and
posts hook events, enforces a concurrency cap under one configured
timeout that bounds both the capacity wait and both post attempts,
retries one connection failure with the same JWT, sends a distinctive
`coderd-agenthooks/<version>` User-Agent, and records Prometheus
metrics. Delivery is at least once; consumers own durable decision
state, audit records, and deduplication keyed by the stable payload
identifiers. Nothing is persisted by Coder.
- Response bodies decode strictly: unknown fields, duplicate JSON keys
(including inside `input_override`), and trailing data fail the dispatch
closed as protocol errors instead of silently reading as allow.
- `coderd/util/xnet`: shared timeout and connection error classification
used by the dispatcher retry logic. Transient HTTP/2 stream aborts count
as connection errors, so the documented single retry also applies to h2
consumers, which is the shape Go's default transport negotiates against
any TLS consumer. Deterministic protocol failures stay terminal. Only
the struct form of a stream error is matched, because `net/http` bundles
its own HTTP/2 types and `h2_error.go` bridges only that shape.
- `scripts/agenthooks-server`: a reference consumer that logs events and
demonstrates consumer-owned pre-tool decision deduplication. It requires
an explicitly configured JWT audience rather than deriving one from the
request, and its startup output names the mode it is running in so an
operator can see that the example policy flags need `-log-only=false`.
- `scripts/apitypings`: generate TypeScript types for the hook wire
contract.

Dispatch failures log without the error's stack frames, since a failed
dispatch is an expected, operator-visible condition.

Nothing dispatches these events yet; chatd wiring lands in #27429.

> This PR was written by Mux, an AI coding agent, on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-07-28 13:59:37 +02:00
committed by GitHub
parent bd5d640f1e
commit 8ea2586189
13 changed files with 3099 additions and 0 deletions
+39
View File
@@ -26,6 +26,7 @@ func main() {
generateDirectories := map[string]string{
"github.com/coder/coder/v2/codersdk": "",
"github.com/coder/coder/v2/coderd/healthcheck/health": "Health",
"github.com/coder/coder/v2/codersdk/x/agenthooks": "AgentHook",
"github.com/coder/coder/v2/codersdk/healthsdk": "",
}
for dir, prefix := range generateDirectories {
@@ -78,6 +79,7 @@ func TSMutations(ts *guts.Typescript) {
config.NotNullMaps,
FixSerpentStruct,
DiscriminatedChatMessagePart,
AgentHookRawMessages,
// Prefer enums as types
config.EnumAsTypes,
// Enum list generator
@@ -146,6 +148,43 @@ func TypeMappings(gen *guts.GoParser) error {
return nil
}
// AgentHookRawMessages maps agent-hook raw JSON fields to unknown instead of
// the global object type.
func AgentHookRawMessages(ts *guts.Typescript) {
if _, ok := ts.Node("AgentHookRequest"); !ok {
return
}
unknown := bindings.KeywordUnknown
fields := map[string]string{
"AgentHookRequest": "data",
"AgentHookUserPromptSubmitData": "parts",
"AgentHookPreToolUseData": "tool_input",
"AgentHookPostToolUseData": "tool_response",
"AgentHookPermission": "input_override",
}
for typeName, fieldName := range fields {
node, ok := ts.Node(typeName)
if !ok {
panic(fmt.Sprintf("agent hook type %q was not generated", typeName))
}
iface, ok := node.(*bindings.Interface)
if !ok {
panic(fmt.Sprintf("agent hook type %q is not an interface", typeName))
}
found := false
for _, field := range iface.Fields {
if field.Name == fieldName {
field.Type = &unknown
found = true
break
}
}
if !found {
panic(fmt.Sprintf("agent hook field %q.%s was not generated", typeName, fieldName))
}
}
}
// DiscriminatedChatMessagePart splits the flat ChatMessagePart
// interface into a discriminated union of per-type sub-interfaces.
// Each sub-interface narrows the `type` field to a string literal