hermes-client 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module HermesAgent
6
+ class Client
7
+ ##
8
+ # Base class for all errors raised by the client. Rescue this to catch
9
+ # every failure mode the client can produce.
10
+ #
11
+ class Error < ::StandardError
12
+ end
13
+
14
+ ##
15
+ # Raised when the client cannot reach the server at all: a socket, DNS, or
16
+ # TLS failure that produced no HTTP response.
17
+ #
18
+ class ConnectionError < Error
19
+ end
20
+
21
+ ##
22
+ # Raised when a request exceeds the configured open or read timeout.
23
+ #
24
+ class TimeoutError < Error
25
+ end
26
+
27
+ ##
28
+ # Raised when the server returns a body the client expected to be JSON but
29
+ # could not parse — a malformed payload on an otherwise successful
30
+ # response, or a malformed streamed SSE frame. Distinct from {APIError}:
31
+ # the HTTP request itself succeeded; only the body was unparseable. The
32
+ # unparseable text is available as {#body}, and the underlying
33
+ # `JSON::ParserError` is preserved as the exception's `#cause`.
34
+ #
35
+ class MalformedResponseError < Error
36
+ ##
37
+ # Create a malformed-response error.
38
+ #
39
+ # @param message [String] The human-readable error message.
40
+ # @param body [String, nil] The raw text that could not be parsed.
41
+ #
42
+ # @private
43
+ def initialize(message, body: nil)
44
+ super(message)
45
+ @body = body
46
+ end
47
+
48
+ ##
49
+ # The raw text that could not be parsed.
50
+ # @return [String, nil]
51
+ #
52
+ attr_reader :body
53
+ end
54
+
55
+ ##
56
+ # Raised when the server returns a non-2xx HTTP response.
57
+ #
58
+ # The concrete class reflects the HTTP status: {BadRequestError},
59
+ # {AuthenticationError}, {PermissionError}, {NotFoundError},
60
+ # {RateLimitError}, or {ServerError}. A bare {APIError} is raised for any
61
+ # status that maps to none of those.
62
+ #
63
+ # ## Error payloads
64
+ #
65
+ # The server uses three distinct error formats, all of which the client
66
+ # handles when building the error. Application-level errors (authentication,
67
+ # body validation, missing resources on the `/v1` surface) return an
68
+ # OpenAI-style JSON body of the form `{"error": {"message", "type",
69
+ # "param"?, "code"?}}`, and {#error} exposes that inner hash. The jobs
70
+ # surface (`/api/jobs`) instead returns a **flat** `{"error": "<message>"}`
71
+ # for its business errors (`400`/`404`/`500`); the message is still surfaced
72
+ # on the exception message, but {#error} is `nil` (there is no inner hash).
73
+ # Router-level
74
+ # errors (an unrouted path, a wrong method) return a bare text body such as
75
+ # `"404: Not Found"`; for those too {#error} is `nil` and only {#body} is
76
+ # meaningful.
77
+ #
78
+ # Even within the JSON family the field set is inconsistent — `message` and
79
+ # `type` are always present, but `param` and `code` may be null or absent —
80
+ # so treat {#error} entries as best-effort. Do not switch on `type`/`code`
81
+ # to classify a failure (the server returns `type: "invalid_request_error"`
82
+ # even for `401`s); branch on the HTTP {#status} or the error subclass.
83
+ #
84
+ class APIError < Error
85
+ ##
86
+ # Build the {APIError} subclass that matches an HTTP error response,
87
+ # extracting the structured payload when the server provides one.
88
+ #
89
+ # @param status [Integer] The HTTP status code.
90
+ # @param body [String] The raw response body.
91
+ # @param headers [Hash] The response headers.
92
+ # @return [APIError] An instance of the subclass matching the status.
93
+ #
94
+ # @private
95
+ def self.from_response(status:, body:, headers: {})
96
+ inner = error_field(body)
97
+ error = inner if inner.is_a?(::Hash)
98
+ message = (error && error["message"]) ||
99
+ (inner if inner.is_a?(::String)) ||
100
+ "Unexpected HTTP status #{status}"
101
+ class_for_status(status).new(message, status: status, body: body,
102
+ headers: headers, error: error)
103
+ end
104
+
105
+ # Pick the {APIError} subclass that represents an HTTP status code.
106
+ def self.class_for_status(status)
107
+ case status
108
+ when 400, 422 then BadRequestError
109
+ when 401 then AuthenticationError
110
+ when 403 then PermissionError
111
+ when 404 then NotFoundError
112
+ when 429 then RateLimitError
113
+ when 500..599 then ServerError
114
+ else APIError
115
+ end
116
+ end
117
+ private_class_method :class_for_status
118
+
119
+ # Pull the body's top-level `error` field out in a single JSON parse: the
120
+ # inner hash for the OpenAI-style envelope (`{"error":{...}}`) or the flat
121
+ # string for the jobs business errors (`{"error":"..."}`). The caller
122
+ # branches on the returned type. Returns nil when the body carries no
123
+ # `error` field or is a non-JSON (router-level) error body.
124
+ def self.error_field(body)
125
+ parsed = ::JSON.parse(body.to_s)
126
+ parsed["error"] if parsed.is_a?(::Hash)
127
+ rescue ::JSON::ParserError
128
+ nil
129
+ end
130
+ private_class_method :error_field
131
+
132
+ ##
133
+ # Create an API error. Prefer `from_response`, which selects the correct
134
+ # subclass and parses the payload for you.
135
+ #
136
+ # @param message [String] The human-readable error message.
137
+ # @param status [Integer] The HTTP status code.
138
+ # @param body [String] The raw response body.
139
+ # @param headers [Hash] The response headers.
140
+ # @param error [Hash, nil] The parsed structured error hash, if the body
141
+ # carried one.
142
+ #
143
+ # @private
144
+ def initialize(message, status:, body:, headers: {}, error: nil)
145
+ super(message)
146
+ @status = status
147
+ @body = body
148
+ @headers = headers
149
+ @error = error
150
+ end
151
+
152
+ ##
153
+ # The HTTP status code of the error response.
154
+ # @return [Integer]
155
+ #
156
+ attr_reader :status
157
+
158
+ ##
159
+ # The raw response body.
160
+ # @return [String]
161
+ #
162
+ attr_reader :body
163
+
164
+ ##
165
+ # The response headers, keyed by downcased name (the same normalized
166
+ # shape the success path exposes, so e.g. `headers["retry-after"]` works
167
+ # regardless of the casing the server sent).
168
+ # @return [Hash{String=>String}]
169
+ #
170
+ attr_reader :headers
171
+
172
+ ##
173
+ # The structured error payload (the inner `error` object), or `nil` when
174
+ # the server returned a non-JSON body. Field set is best-effort: expect
175
+ # `message` and `type`, but `param` and `code` may be missing.
176
+ # @return [Hash, nil]
177
+ #
178
+ attr_reader :error
179
+ end
180
+
181
+ ##
182
+ # Raised on a `400` or `422` response: the request was malformed or failed
183
+ # server-side validation.
184
+ #
185
+ class BadRequestError < APIError
186
+ end
187
+
188
+ ##
189
+ # Raised on a `401` response: the bearer token was missing or invalid.
190
+ #
191
+ class AuthenticationError < APIError
192
+ end
193
+
194
+ ##
195
+ # Raised on a `403` response: the token is valid but not permitted to
196
+ # perform the request.
197
+ #
198
+ class PermissionError < APIError
199
+ end
200
+
201
+ ##
202
+ # Raised on a `404` response: the requested resource does not exist.
203
+ #
204
+ class NotFoundError < APIError
205
+ end
206
+
207
+ ##
208
+ # Raised on a `429` response: the request was refused for exceeding a
209
+ # server-side limit. In practice the server raises this only when a new run
210
+ # would exceed its fixed ceiling of concurrent runs; the limit clears as
211
+ # in-flight runs finish, not after a fixed interval.
212
+ #
213
+ # The server sends **no `Retry-After` header or other timing hint**, so the
214
+ # client does not retry automatically — whether and when to retry is left to
215
+ # the caller. Because the limit is concurrency- rather than time-based, a
216
+ # short pause (or simply retrying once an in-flight run completes) is more
217
+ # apt than fixed exponential backoff.
218
+ #
219
+ class RateLimitError < APIError
220
+ end
221
+
222
+ ##
223
+ # Raised on a `5xx` response: the server failed to handle the request.
224
+ #
225
+ class ServerError < APIError
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hermes_agent/client/entities/capabilities"
4
+
5
+ module HermesAgent
6
+ class Client
7
+ module Resources
8
+ ##
9
+ # The capabilities resource: the server's self-description of the
10
+ # endpoints and features it supports (`/v1/capabilities`).
11
+ #
12
+ class Capabilities
13
+ ##
14
+ # Create the resource.
15
+ #
16
+ # @param transport [Transport] The transport used to issue requests.
17
+ #
18
+ # @private
19
+ def initialize(transport)
20
+ @transport = transport
21
+ end
22
+
23
+ ##
24
+ # Fetch the server's advertised capabilities.
25
+ #
26
+ # @return [Entities::Capabilities] The capabilities document.
27
+ #
28
+ def get
29
+ Entities::Capabilities.new(@transport.get("/v1/capabilities"))
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hermes_agent/client/entities/chat_completion"
4
+ require "hermes_agent/client/util"
5
+
6
+ module HermesAgent
7
+ class Client
8
+ module Resources
9
+ ##
10
+ # The chat resource: OpenAI-compatible chat completions
11
+ # (`POST /v1/chat/completions`). This endpoint is stateless — each call
12
+ # is independent — and, on a server configured with an API key, requires
13
+ # a bearer token (see {Client} / {Configuration}).
14
+ #
15
+ class Chat
16
+ ##
17
+ # The SSE `event:` name of the server's custom tool-progress frames,
18
+ # which are routed to {Entities::ChatToolProgress} rather than treated
19
+ # as completion chunks.
20
+ #
21
+ TOOL_PROGRESS_EVENT = "hermes.tool.progress"
22
+
23
+ ##
24
+ # Create the resource.
25
+ #
26
+ # @param transport [Transport] The transport used to issue requests.
27
+ #
28
+ # @private
29
+ def initialize(transport)
30
+ @transport = transport
31
+ end
32
+
33
+ ##
34
+ # Create a chat completion.
35
+ #
36
+ # No `model` is sent: the model is configured server-side and the
37
+ # server ignores a client-supplied one. (A caller who really wants to
38
+ # send fields we have not modeled — including `model` — can pass them
39
+ # through `extra`.)
40
+ #
41
+ # @param messages [Array<Hash>] The OpenAI-style message array. Each
42
+ # message is a hash such as `{role: "user", content: "…"}`;
43
+ # content may include `image_url` parts for inline images.
44
+ # @param session_id [String, nil] A session id to continue, sent as the
45
+ # `X-Hermes-Session-ID` request header. When omitted, the server
46
+ # generates a fresh one (returned on {Entities::SessionHeaders#session_id}).
47
+ # @param session_key [String, nil] A session key, sent as the
48
+ # `X-Hermes-Session-Key` request header.
49
+ # @param idempotency_key [String, nil] An idempotency key, sent as the
50
+ # `Idempotency-Key` request header. The server caches the result for
51
+ # ~5 minutes and replays it for a repeat call carrying the same key
52
+ # and an equivalent request, so a retry does not re-run the model.
53
+ # The replay is **transparent**: the response is indistinguishable
54
+ # from a fresh one (same status, no replay header, and a freshly
55
+ # regenerated `id`), so there is no way — here or in the returned
56
+ # entity — to tell whether a given call was served from the cache.
57
+ # Reusing a key with a *different* request silently recomputes (no
58
+ # error) and overwrites the cached entry. Honored only on this
59
+ # non-streaming endpoint (not on {#stream_create}).
60
+ # @param extra [Hash] Additional request-body fields (e.g. sampling
61
+ # parameters) merged into the body as-is.
62
+ # @return [Entities::ChatCompletion] The completion, carrying the
63
+ # session headers returned by the server.
64
+ # @raise [APIError] If the server returns a non-2xx response.
65
+ #
66
+ def create(messages:, session_id: nil, session_key: nil, idempotency_key: nil, **extra)
67
+ body = {messages: messages, **extra}
68
+ headers = session_request_headers(session_id, session_key)
69
+ headers["Idempotency-Key"] = idempotency_key if idempotency_key
70
+ result = @transport.post("/v1/chat/completions", body, headers: headers)
71
+ Entities::ChatCompletion.new(result.body, **Util.session_headers(result.headers))
72
+ end
73
+
74
+ ##
75
+ # Create a chat completion, streaming the response.
76
+ #
77
+ # While the server agent executes tools it interleaves custom
78
+ # `hermes.tool.progress` frames; these are surfaced as
79
+ # {Entities::ChatToolProgress} events (distinct from the text
80
+ # {Entities::ChatCompletionChunk}s) and are not folded into the
81
+ # assembled completion.
82
+ #
83
+ # With a block, each event is yielded as it arrives and the assembled
84
+ # {Entities::ChatCompletion} is returned once the stream closes. Without
85
+ # a block, a {Stream} is returned for the caller to iterate; its
86
+ # {Stream#result} is the assembled completion.
87
+ #
88
+ # @param messages [Array<Hash>] The OpenAI-style message array (see
89
+ # {#create}).
90
+ # @param session_id [String, nil] A session id to continue, sent as the
91
+ # `X-Hermes-Session-ID` request header (see {#create}).
92
+ # @param session_key [String, nil] A session key, sent as the
93
+ # `X-Hermes-Session-Key` request header (see {#create}).
94
+ # @param extra [Hash] Additional request-body fields merged into the
95
+ # body as-is.
96
+ # @yieldparam event [Entities::ChatCompletionChunk, Entities::ChatToolProgress]
97
+ # Each streamed event: a text chunk, or a tool-progress frame.
98
+ # @return [Entities::ChatCompletion, Stream] The assembled completion
99
+ # (carrying the server's session headers) when a block is given,
100
+ # otherwise the {Stream}.
101
+ # @raise [APIError] If the server returns a non-2xx response.
102
+ #
103
+ def stream_create(messages:, session_id: nil, session_key: nil, **extra, &block)
104
+ body = {messages: messages, stream: true, **extra}
105
+ result = @transport.stream_post("/v1/chat/completions", body,
106
+ headers: session_request_headers(session_id, session_key))
107
+ session = Util.session_headers(result.headers)
108
+ event_class = lambda do |name|
109
+ name == TOOL_PROGRESS_EVENT ? Entities::ChatToolProgress : Entities::ChatCompletionChunk
110
+ end
111
+ stream = Stream.new(result.body, event_class: event_class, terminator: "[DONE]") do |events|
112
+ Entities::ChatCompletion.from_chunks(events, **session)
113
+ end
114
+ return stream unless block
115
+
116
+ stream.each(&block)
117
+ stream.result
118
+ end
119
+
120
+ private
121
+
122
+ ##
123
+ # Build the session-continuity request headers, omitting either header
124
+ # the caller did not supply. Returns an empty hash when neither is set.
125
+ #
126
+ # @param session_id [String, nil] The session id, if any.
127
+ # @param session_key [String, nil] The session key, if any.
128
+ # @return [Hash{String=>String}]
129
+ #
130
+ def session_request_headers(session_id, session_key)
131
+ headers = {}
132
+ headers["X-Hermes-Session-ID"] = session_id if session_id
133
+ headers["X-Hermes-Session-Key"] = session_key if session_key
134
+ headers
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hermes_agent/client/entities/health"
4
+
5
+ module HermesAgent
6
+ class Client
7
+ ##
8
+ # Resource groups, each exposing the verb methods for one area of the API.
9
+ # Reached through accessors on {Client}, such as {Client#health}.
10
+ #
11
+ module Resources
12
+ ##
13
+ # The health resource. Health endpoints live at the server root, not
14
+ # under the `/v1` prefix.
15
+ #
16
+ class Health
17
+ ##
18
+ # Create the resource.
19
+ #
20
+ # @param transport [Transport] The transport used to issue requests.
21
+ #
22
+ # @private
23
+ def initialize(transport)
24
+ @transport = transport
25
+ end
26
+
27
+ ##
28
+ # Check whether the server is healthy.
29
+ #
30
+ # @return [Entities::Health] The health result; {Entities::Health#status}
31
+ # is `"ok"` on a healthy server.
32
+ #
33
+ def check
34
+ Entities::Health.new(@transport.get("/health"))
35
+ end
36
+
37
+ ##
38
+ # Fetch detailed server health, including gateway state, per-platform
39
+ # connection status, and the active-agent count.
40
+ #
41
+ # @return [Entities::HealthDetails] The detailed health result.
42
+ #
43
+ def detailed
44
+ Entities::HealthDetails.new(@transport.get("/health/detailed"))
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hermes_agent/client/entities/job"
4
+
5
+ module HermesAgent
6
+ class Client
7
+ module Resources
8
+ ##
9
+ # The jobs resource: the Jobs API (`/api/jobs`) for scheduled background
10
+ # work — cron-like recurring tasks, one-shot deferred tasks, and watchdog
11
+ # scripts. On a server configured with an API key, these calls require a
12
+ # bearer token (see {Client} / {Configuration}).
13
+ #
14
+ # Note this resource lives under `/api/jobs`, **not** `/v1`, and is **not**
15
+ # advertised in `/v1/capabilities` — it may be gated, versioned
16
+ # separately, or absent in some builds; confirm against a server that
17
+ # exposes it.
18
+ #
19
+ # **No terminal state to poll.** The server **deletes** a job once it is
20
+ # exhausted: a one-shot (`once`) job is gone after its single run, and a
21
+ # `repeat`-capped job is gone after its final run. After that, {#get} (and
22
+ # {#trigger}) raise {NotFoundError} — a client cannot poll such a job for
23
+ # its outcome once it completes.
24
+ #
25
+ class Jobs
26
+ ##
27
+ # Create the resource.
28
+ #
29
+ # @param transport [Transport] The transport used to issue requests.
30
+ #
31
+ # @private
32
+ def initialize(transport)
33
+ @transport = transport
34
+ end
35
+
36
+ ##
37
+ # List the scheduled jobs. By default the server returns only enabled
38
+ # jobs; pass `include_disabled: true` to include paused/disabled ones.
39
+ # (The list is never paginated — the full set is always returned.)
40
+ #
41
+ # @param include_disabled [Boolean] Whether to include disabled
42
+ # (paused) jobs. Defaults to `false` (enabled jobs only).
43
+ # @return [Array<Entities::Job>] The jobs (empty when there are none).
44
+ # @raise [APIError] If the server returns a non-2xx response.
45
+ #
46
+ def list(include_disabled: false)
47
+ path = include_disabled ? "/api/jobs?include_disabled=true" : "/api/jobs"
48
+ body = @transport.get(path)
49
+ Array(body["jobs"]).map { |raw| Entities::Job.new(raw) }
50
+ end
51
+
52
+ ##
53
+ # Create a scheduled job. `name` and `schedule` are required.
54
+ #
55
+ # `schedule` is a string parsed server-side into a `once` / `interval` /
56
+ # `cron` schedule: a bare duration (`"30m"`), an absolute timestamp
57
+ # (`"2027-02-03T14:00:00"`), an interval (`"every 30m"`), or a cron
58
+ # expression (`"0 9 * * *"`). The override slots (`model`, `provider`,
59
+ # `base_url`, `workdir`, `profile`, `context_from`) are **not** writable
60
+ # via this API and so are not parameters — they are silently ignored if
61
+ # sent. A caller who really wants to send unmodeled fields can pass them
62
+ # through `extra`.
63
+ #
64
+ # @param name [String] The job name (required).
65
+ # @param schedule [String] The schedule string (required; see above).
66
+ # @param prompt [String, nil] The task instruction. Omitted when `nil`.
67
+ # @param repeat [Integer, nil] The maximum number of runs (`nil` =
68
+ # unbounded). Omitted when `nil`.
69
+ # @param deliver [String, nil] The delivery target (defaults server-side
70
+ # to `"local"`). Omitted when `nil`. Not validated on write.
71
+ # @param skills [Array<String>, nil] Attached skill names. Omitted when
72
+ # `nil`.
73
+ # @param script [String, nil] A script path under `~/.hermes/scripts/`.
74
+ # Omitted when `nil`. **Note:** the reference gateway's create
75
+ # handler silently drops this field (it stays `null`); kept as a
76
+ # parameter for forward-compatibility and other deployments.
77
+ # @param no_agent [Boolean, nil] Whether to skip the LLM and deliver the
78
+ # script's stdout verbatim. Omitted when `nil` (so `false` is sent).
79
+ # **Note:** like `script`, silently dropped by the reference
80
+ # gateway's create handler (stays `false`).
81
+ # @param extra [Hash] Additional request-body fields merged in as-is.
82
+ # @return [Entities::Job] The created job.
83
+ # @raise [BadRequestError] On a missing `name`/`schedule` (`400`).
84
+ # @raise [ServerError] On an **unparseable `schedule`** — the server
85
+ # returns `500`, not `400`, even though it is really invalid input.
86
+ # @raise [APIError] If the server returns another non-2xx response.
87
+ #
88
+ def create(name:, schedule:, prompt: nil, repeat: nil, deliver: nil,
89
+ skills: nil, script: nil, no_agent: nil, **extra)
90
+ body = {name: name, schedule: schedule, **extra}
91
+ body[:prompt] = prompt unless prompt.nil?
92
+ body[:repeat] = repeat unless repeat.nil?
93
+ body[:deliver] = deliver unless deliver.nil?
94
+ body[:skills] = skills unless skills.nil?
95
+ body[:script] = script unless script.nil?
96
+ body[:no_agent] = no_agent unless no_agent.nil?
97
+ Entities::Job.new(@transport.post("/api/jobs", body).body["job"])
98
+ end
99
+
100
+ ##
101
+ # Retrieve a job by id.
102
+ #
103
+ # @param job_id [String] The job id (12 hex characters).
104
+ # @return [Entities::Job] The current job state.
105
+ # @raise [NotFoundError] If no such job exists — including a job the
106
+ # server already deleted because it was exhausted (a `once` job after
107
+ # its run, or a capped job after its final run).
108
+ # @raise [APIError] If the server returns another non-2xx response.
109
+ #
110
+ def get(job_id)
111
+ Entities::Job.new(@transport.get("/api/jobs/#{job_id}")["job"])
112
+ end
113
+
114
+ ##
115
+ # Update a job (a partial merge over the writable fields). Sent fields
116
+ # are merged onto the existing job; a sent `schedule` is re-parsed and
117
+ # `next_run_at` recomputed. The override slots are **not** writable here
118
+ # either (silently ignored), so they are not parameters.
119
+ #
120
+ # @param job_id [String] The job id (12 hex characters).
121
+ # @param name [String, nil] A new name. Omitted when `nil`.
122
+ # @param schedule [String, nil] A new schedule string (re-parsed).
123
+ # Omitted when `nil`.
124
+ # @param prompt [String, nil] A new prompt. Omitted when `nil`.
125
+ # @param repeat [Integer, nil] A new run cap. Omitted when `nil`.
126
+ # @param deliver [String, nil] A new delivery target. Omitted when `nil`.
127
+ # @param skills [Array<String>, nil] New skill names. Omitted when `nil`.
128
+ # @param script [String, nil] A new script path. Omitted when `nil`.
129
+ # **Note:** silently dropped by the reference gateway (see {#create}).
130
+ # @param no_agent [Boolean, nil] A new `no_agent` flag. Omitted when
131
+ # `nil`. **Note:** silently dropped by the reference gateway (see
132
+ # {#create}).
133
+ # @param extra [Hash] Additional request-body fields merged in as-is.
134
+ # @return [Entities::Job] The updated job.
135
+ # @raise [NotFoundError] If no such job exists.
136
+ # @raise [ServerError] On an **unparseable `schedule`** (`500`, not
137
+ # `400`; see {#create}).
138
+ # @raise [APIError] If the server returns another non-2xx response.
139
+ #
140
+ def update(job_id, name: nil, schedule: nil, prompt: nil, repeat: nil,
141
+ deliver: nil, skills: nil, script: nil, no_agent: nil, **extra)
142
+ body = {**extra}
143
+ body[:name] = name unless name.nil?
144
+ body[:schedule] = schedule unless schedule.nil?
145
+ body[:prompt] = prompt unless prompt.nil?
146
+ body[:repeat] = repeat unless repeat.nil?
147
+ body[:deliver] = deliver unless deliver.nil?
148
+ body[:skills] = skills unless skills.nil?
149
+ body[:script] = script unless script.nil?
150
+ body[:no_agent] = no_agent unless no_agent.nil?
151
+ Entities::Job.new(@transport.patch("/api/jobs/#{job_id}", body)["job"])
152
+ end
153
+
154
+ ##
155
+ # Delete a job (also cancels any in-flight run).
156
+ #
157
+ # @param job_id [String] The job id (12 hex characters).
158
+ # @return [Boolean] `true` (mapped from the server's `{"ok": true}`).
159
+ # @raise [NotFoundError] If no such job exists.
160
+ # @raise [APIError] If the server returns another non-2xx response.
161
+ #
162
+ def delete(job_id)
163
+ @transport.delete("/api/jobs/#{job_id}")["ok"] == true
164
+ end
165
+
166
+ ##
167
+ # Pause a job without deleting it (sets `enabled: false`). Idempotent:
168
+ # pausing an already-paused job is not an error.
169
+ #
170
+ # @param job_id [String] The job id (12 hex characters).
171
+ # @return [Entities::Job] The paused job.
172
+ # @raise [NotFoundError] If no such job exists.
173
+ # @raise [APIError] If the server returns another non-2xx response.
174
+ #
175
+ def pause(job_id)
176
+ Entities::Job.new(@transport.post("/api/jobs/#{job_id}/pause", {}).body["job"])
177
+ end
178
+
179
+ ##
180
+ # Resume a paused job (recomputes `next_run_at` from the resume time).
181
+ # Idempotent: resuming an already-scheduled job is not an error.
182
+ #
183
+ # @param job_id [String] The job id (12 hex characters).
184
+ # @return [Entities::Job] The resumed job.
185
+ # @raise [NotFoundError] If no such job exists.
186
+ # @raise [APIError] If the server returns another non-2xx response.
187
+ #
188
+ def resume(job_id)
189
+ Entities::Job.new(@transport.post("/api/jobs/#{job_id}/resume", {}).body["job"])
190
+ end
191
+
192
+ ##
193
+ # Trigger a job to run out of schedule.
194
+ #
195
+ # This is **asynchronous**: it advances the job's `next_run_at` to "now"
196
+ # so the scheduler picks it up on its next tick, then returns the job
197
+ # immediately — it does **not** block on or return the run's result, and
198
+ # `last_run_at` / `last_status` / `repeat.completed` are not yet updated
199
+ # when it returns. (For a one-shot or final-run job, the job may be
200
+ # deleted once it fires, so it cannot be polled afterward — see {#get}.)
201
+ #
202
+ # @param job_id [String] The job id (12 hex characters).
203
+ # @return [Entities::Job] The job, with `next_run_at` advanced.
204
+ # @raise [NotFoundError] If no such job exists.
205
+ # @raise [APIError] If the server returns another non-2xx response.
206
+ #
207
+ def trigger(job_id)
208
+ Entities::Job.new(@transport.post("/api/jobs/#{job_id}/run", {}).body["job"])
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end