Skip to documentation
Documentation navigation

Documentation navigation

Documentation / guides

Submit commands

Submit validated Harness commands through HTTP endpoints.

developer

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.

ConditionStatusBody
valid live session and non-empty blocks200{"command_id":"..."}
malformed UUID400invalid_parameter
empty or absent blocks, malformed JSON, unknown block, or body over cap400invalid_body
session not live in this process404session_not_found
LiveSession.Submit fails500internal

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.

ConditionStatusResponse
live session, one or more in-flight turns cancelled200{"interrupted":true}
live session, no in-flight turn200{"interrupted":false}
malformed UUID400invalid_parameter
session not live404session_not_found
Interrupt fails500internal

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

go test ./pkg/serve -run 'TestServerHandle(Input|Interrupt)|TestRegistry'

← back to documentation