Documentation / guides
Submit commands
Submit validated Harness commands through HTTP endpoints.
The HTTP control routes are fire-and-forget session operations. They return a command ID or an interrupt fact after the live session accepts the call. They do not wait for a completed Turn.
Input
POST /v1/sessions/{sid}/input resolves {sid} in the server’s live registry,
then decodes a required non-empty {"blocks":[...]} body. The block decoder
delegates tagged block semantics to content.UnmarshalBlocks.
| Condition | Status | Body |
|---|---|---|
| valid live session and non-empty blocks | 200 | {"command_id":"..."} |
| malformed UUID | 400 | invalid_parameter |
| empty or absent blocks, malformed JSON, unknown block, or body over cap | 400 | invalid_body |
| session not live in this process | 404 | session_not_found |
LiveSession.Submit fails | 500 | internal |
The returned UUID is the correlation key for the events generated by the
submission. It identifies acceptance by Submit, not completion of the Turn.
func submitInput(ctx context.Context, client *http.Client, endpoint string) (uuid.UUID, error) {
// The block object uses Harness's tagged content wire shape.
payload := []byte(`{"blocks":[{"type":"text","Text":"check status"}]}`)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return uuid.UUID{}, err
}
res, err := client.Do(req)
if err != nil {
return uuid.UUID{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return uuid.UUID{}, fmt.Errorf("submit: HTTP %s", res.Status)
}
var body struct {
CommandID uuid.UUID `json:"command_id"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
return uuid.UUID{}, err
}
return body.CommandID, nil
}
The handler looks up the session before reading the body. A missing session therefore does not spend work decoding an input that cannot be delivered.
Interrupt
POST /v1/sessions/{sid}/interrupt has no request body. It resolves the same
live registry entry and calls LiveSession.Interrupt.
| Condition | Status | Response |
|---|---|---|
| live session, one or more in-flight turns cancelled | 200 | {"interrupted":true} |
| live session, no in-flight turn | 200 | {"interrupted":false} |
| malformed UUID | 400 | invalid_parameter |
| session not live | 404 | session_not_found |
Interrupt fails | 500 | internal |
interrupted is a fact about work that was running when the call reached the
session. It is not a promise that every event already in the stream has been
consumed.
func interrupt(ctx context.Context, client *http.Client, endpoint string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, http.NoBody)
if err != nil {
return err
}
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var body struct{ Interrupted bool `json:"interrupted"` }
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
return err
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("interrupt: HTTP %s", res.Status)
}
return nil
}
Live registry boundary
The read plane can inspect a durable session on any pod. The input, interrupt,
gate, and event routes intentionally cannot: they require an in-process
LiveSession. Restore the session first when durable history exists but the
current process has no live entry.
The registry uses an RWMutex only for map membership. It releases the lock
before calling Submit, Interrupt, or any other live-session method, so a
slow actor cannot block unrelated lookups.
%%{init: {"theme":"dark"}}%%
flowchart TD
Req[POST input or interrupt] --> UUID[validate sid]
UUID --> Lookup{live registry entry?}
Lookup -- no --> Missing[404 session_not_found]
Lookup -- yes --> Unlock[release registry lock]
Unlock --> Call[call LiveSession method]
Call --> Result[command_id or interrupted]
Source and runnable proof
LiveSessionmethodsinputandinterrupthandlersregistry locking and lookupcontrol route testsregistry concurrency tests
go test ./pkg/serve -run 'TestServerHandle(Input|Interrupt)|TestRegistry'