001/*
002 * Copyright 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet;
018
019import org.jspecify.annotations.NonNull;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.NotThreadSafe;
023import javax.annotation.concurrent.ThreadSafe;
024import java.time.Duration;
025import java.time.Instant;
026import java.util.Collections;
027import java.util.LinkedHashMap;
028import java.util.Map;
029import java.util.Objects;
030import java.util.Optional;
031
032import static com.soklet.internal.mcp.protocol.McpApplicationMetadata.requireApplicationMetadata;
033import static java.util.Objects.requireNonNull;
034
035/**
036 * Immutable authoritative snapshot of one MCP task.
037 *
038 * <p>The configured {@link McpTaskManager} returns this value after atomically
039 * authorizing the current request. The origin is application-persisted
040 * framework data and is never rendered on the wire. Task status determines
041 * the one permitted status-specific payload: outstanding input requests for
042 * {@link McpTaskStatus#INPUT_REQUIRED}, a complete result for
043 * {@link McpTaskStatus#COMPLETED}, or a JSON-RPC failure for
044 * {@link McpTaskStatus#FAILED}. A tool result whose {@code isError} value is
045 * true is still a completed task.
046 *
047 * <p>This value does not contain task ownership or an authorization decision.
048 * A manager must independently bind the task to the admitted principal,
049 * tenant, endpoint, and other application authorization context, and must not
050 * return a snapshot to an unauthorized request.
051 *
052 * <p>Construction validates the status-specific payload shape, not whether
053 * that payload can be delivered under the origin tool's contract. Soklet
054 * checks outstanding input declarations and completed output during delivery,
055 * using the current server's access policy and result sanitizer. A failed
056 * delivery does not change this authoritative snapshot or synthesize a
057 * {@link McpTaskStatus#FAILED} state.
058 *
059 * @author <a href="https://www.revetkn.com">Mark Allen</a>
060 */
061@ThreadSafe
062public final class McpTask {
063        @NonNull
064        private final String taskId;
065        @NonNull
066        private final McpTaskOrigin taskOrigin;
067        @NonNull
068        private final McpTaskStatus taskStatus;
069        @Nullable
070        private final String taskStatusMessage;
071        @NonNull
072        private final Instant createdAt;
073        @NonNull
074        private final Instant lastUpdatedAt;
075        @Nullable
076        private final Duration timeToLive;
077        @Nullable
078        private final Duration pollInterval;
079        @NonNull
080        private final Map<@NonNull String, @NonNull McpInputRequest> inputRequests;
081        @Nullable
082        private final McpCompleteResult completedResult;
083        @Nullable
084        private final McpJsonRpcError failure;
085        @NonNull
086        private final McpJsonObject metadata;
087
088        /**
089         * Vends a builder primed with every required task property.
090         *
091         * @param taskId nonblank task identifier without carriage-return or newline
092         *               characters
093         * @param taskOrigin persisted framework origin
094         * @param taskStatus current task status
095         * @param createdAt task creation time
096         * @param lastUpdatedAt most recent state-update time, not before creation
097         * @return task builder
098         * @throws NullPointerException if an argument is null
099         * @throws IllegalArgumentException if the task ID or timestamps are invalid
100         */
101        @NonNull
102        public static Builder withTaskId(@NonNull String taskId,
103                        @NonNull McpTaskOrigin taskOrigin,
104                        @NonNull McpTaskStatus taskStatus,
105                        @NonNull Instant createdAt,
106                        @NonNull Instant lastUpdatedAt) {
107                return new Builder(taskId, taskOrigin, taskStatus, createdAt,
108                                lastUpdatedAt);
109        }
110
111        private McpTask(@NonNull Builder builder) {
112                this.taskId = requireTaskId(builder.taskId);
113                this.taskOrigin = requireNonNull(builder.taskOrigin);
114                this.taskStatus = requireNonNull(builder.taskStatus);
115                this.taskStatusMessage = builder.taskStatusMessage;
116                this.createdAt = requireNonNull(builder.createdAt);
117                this.lastUpdatedAt = requireNonNull(builder.lastUpdatedAt);
118                if (this.lastUpdatedAt.isBefore(this.createdAt))
119                        throw new IllegalArgumentException(
120                                        "Task last-updated time must not precede its creation time.");
121                this.timeToLive = requireWholeMilliseconds(builder.timeToLive,
122                                "Task time to live");
123                this.pollInterval = requireWholeMilliseconds(builder.pollInterval,
124                                "Task poll interval");
125                this.inputRequests = Collections.unmodifiableMap(
126                                new LinkedHashMap<>(builder.inputRequests));
127                this.completedResult = builder.completedResult;
128                this.failure = builder.failure;
129                this.metadata = requireApplicationMetadata(builder.metadata);
130                validateStatusPayload();
131        }
132
133        /** @return nonblank durable task identifier */
134        @NonNull
135        public String getTaskId() {
136                return this.taskId;
137        }
138
139        /**
140         * Returns the non-wire origin persisted with this task.
141         *
142         * @return framework-derived task origin
143         */
144        @NonNull
145        public McpTaskOrigin getTaskOrigin() {
146                return this.taskOrigin;
147        }
148
149        /** @return current task status */
150        @NonNull
151        public McpTaskStatus getTaskStatus() {
152                return this.taskStatus;
153        }
154
155        /** @return client-visible status message, if supplied */
156        @NonNull
157        public Optional<@NonNull String> getTaskStatusMessage() {
158                return Optional.ofNullable(this.taskStatusMessage);
159        }
160
161        /** @return task creation time */
162        @NonNull
163        public Instant getCreatedAt() {
164                return this.createdAt;
165        }
166
167        /** @return most recent task state-update time */
168        @NonNull
169        public Instant getLastUpdatedAt() {
170                return this.lastUpdatedAt;
171        }
172
173        /**
174         * Returns the duration after creation for which the task may be retained.
175         *
176         * @return positive whole-millisecond duration, or empty for unlimited
177         */
178        @NonNull
179        public Optional<@NonNull Duration> getTimeToLive() {
180                return Optional.ofNullable(this.timeToLive);
181        }
182
183        /** @return positive whole-millisecond suggested polling interval, if any */
184        @NonNull
185        public Optional<@NonNull Duration> getPollInterval() {
186                return Optional.ofNullable(this.pollInterval);
187        }
188
189        /**
190         * Returns outstanding task input requests in insertion order.
191         *
192         * @return immutable input-request map; nonempty only for
193         * {@link McpTaskStatus#INPUT_REQUIRED}
194         */
195        @NonNull
196        public Map<@NonNull String, @NonNull McpInputRequest> getInputRequests() {
197                return this.inputRequests;
198        }
199
200        /**
201         * Returns the operation result of a completed task.
202         *
203         * @return complete result only for {@link McpTaskStatus#COMPLETED}
204         */
205        @NonNull
206        public Optional<@NonNull McpCompleteResult> getCompletedResult() {
207                return Optional.ofNullable(this.completedResult);
208        }
209
210        /**
211         * Returns the JSON-RPC error that failed task execution.
212         *
213         * @return failure only for {@link McpTaskStatus#FAILED}
214         */
215        @NonNull
216        public Optional<@NonNull McpJsonRpcError> getFailure() {
217                return Optional.ofNullable(this.failure);
218        }
219
220        /** @return immutable task-result protocol extension metadata */
221        @NonNull
222        public McpJsonObject getMetadata() {
223                return this.metadata;
224        }
225
226        /** @return whether every persisted task property is structurally equal */
227        @Override
228        public boolean equals(@Nullable Object other) {
229                if (this == other)
230                        return true;
231                if (!(other instanceof McpTask task))
232                        return false;
233                return this.taskId.equals(task.taskId)
234                                && this.taskOrigin.equals(task.taskOrigin)
235                                && this.taskStatus == task.taskStatus
236                                && Objects.equals(this.taskStatusMessage,
237                                        task.taskStatusMessage)
238                                && this.createdAt.equals(task.createdAt)
239                                && this.lastUpdatedAt.equals(task.lastUpdatedAt)
240                                && Objects.equals(this.timeToLive, task.timeToLive)
241                                && Objects.equals(this.pollInterval, task.pollInterval)
242                                && this.inputRequests.equals(task.inputRequests)
243                                && Objects.equals(this.completedResult, task.completedResult)
244                                && Objects.equals(this.failure, task.failure)
245                                && this.metadata.equals(task.metadata);
246        }
247
248        /** @return structural persisted-task hash code */
249        @Override
250        public int hashCode() {
251                return Objects.hash(this.taskId, this.taskOrigin, this.taskStatus,
252                                this.taskStatusMessage, this.createdAt, this.lastUpdatedAt,
253                                this.timeToLive, this.pollInterval, this.inputRequests,
254                                this.completedResult, this.failure, this.metadata);
255        }
256
257        /** @return a diagnostic rendering that redacts task and application data */
258        @Override
259        @NonNull
260        public String toString() {
261                return "McpTask{taskId=<redacted>, taskStatus=" + this.taskStatus + "}";
262        }
263
264        @NonNull
265        static String requireTaskId(@NonNull String taskId) {
266                McpContentValueSupport.requireWellFormedString(taskId);
267                if (taskId.isBlank())
268                        throw new IllegalArgumentException("MCP task IDs must not be blank.");
269                if (taskId.indexOf('\r') >= 0 || taskId.indexOf('\n') >= 0)
270                        throw new IllegalArgumentException(
271                                        "MCP task IDs must not contain carriage-return or newline characters.");
272                return taskId;
273        }
274
275        @Nullable
276        private static Duration requireWholeMilliseconds(
277                        @Nullable Duration duration, @NonNull String description) {
278                if (duration == null)
279                        return null;
280                requireNonNull(description);
281                if (duration.isNegative() || duration.isZero())
282                        throw new IllegalArgumentException(description + " must be positive.");
283                long milliseconds;
284                try {
285                        milliseconds = duration.toMillis();
286                } catch (ArithmeticException exception) {
287                        throw new IllegalArgumentException(
288                                        description + " must fit in a signed 64-bit millisecond count.",
289                                        exception);
290                }
291                if (!duration.equals(Duration.ofMillis(milliseconds)))
292                        throw new IllegalArgumentException(
293                                        description + " must have whole-millisecond precision.");
294                return duration;
295        }
296
297        private void validateStatusPayload() {
298                boolean hasInputRequests = !this.inputRequests.isEmpty();
299                boolean hasCompletedResult = this.completedResult != null;
300                boolean hasFailure = this.failure != null;
301                switch (this.taskStatus) {
302                        case INPUT_REQUIRED -> {
303                                if (!hasInputRequests || hasCompletedResult || hasFailure)
304                                        throw invalidStatusPayload();
305                        }
306                        case COMPLETED -> {
307                                if (hasInputRequests || !hasCompletedResult || hasFailure)
308                                        throw invalidStatusPayload();
309                        }
310                        case FAILED -> {
311                                if (hasInputRequests || hasCompletedResult || !hasFailure)
312                                        throw invalidStatusPayload();
313                        }
314                        case WORKING, CANCELED -> {
315                                if (hasInputRequests || hasCompletedResult || hasFailure)
316                                        throw invalidStatusPayload();
317                        }
318                }
319        }
320
321        @NonNull
322        private IllegalStateException invalidStatusPayload() {
323                return new IllegalStateException(
324                                "Task payload does not match task status " + this.taskStatus + ".");
325        }
326
327        /**
328         * Mutable builder for an immutable task snapshot.
329         *
330         * <p>The builder is intended for use by a single thread. Its
331         * status-specific setters do not silently change the configured status;
332         * {@link #build()} rejects every mismatched payload combination.
333         *
334         * @author <a href="https://www.revetkn.com">Mark Allen</a>
335         */
336        @NotThreadSafe
337        public static final class Builder {
338                @NonNull
339                private final String taskId;
340                @NonNull
341                private final McpTaskOrigin taskOrigin;
342                @NonNull
343                private final McpTaskStatus taskStatus;
344                @NonNull
345                private final Instant createdAt;
346                @NonNull
347                private final Instant lastUpdatedAt;
348                @Nullable
349                private String taskStatusMessage;
350                @Nullable
351                private Duration timeToLive;
352                @Nullable
353                private Duration pollInterval;
354                @NonNull
355                private final Map<@NonNull String, @NonNull McpInputRequest>
356                                inputRequests;
357                @Nullable
358                private McpCompleteResult completedResult;
359                @Nullable
360                private McpJsonRpcError failure;
361                @NonNull
362                private McpJsonObject metadata;
363
364                private Builder(@NonNull String taskId,
365                                @NonNull McpTaskOrigin taskOrigin,
366                                @NonNull McpTaskStatus taskStatus,
367                                @NonNull Instant createdAt,
368                                @NonNull Instant lastUpdatedAt) {
369                        this.taskId = requireTaskId(taskId);
370                        this.taskOrigin = requireNonNull(taskOrigin);
371                        this.taskStatus = requireNonNull(taskStatus);
372                        this.createdAt = requireNonNull(createdAt);
373                        this.lastUpdatedAt = requireNonNull(lastUpdatedAt);
374                        if (lastUpdatedAt.isBefore(createdAt))
375                                throw new IllegalArgumentException(
376                                                "Task last-updated time must not precede its creation time.");
377                        this.inputRequests = new LinkedHashMap<>();
378                        this.metadata = McpJsonObject.emptyInstance();
379                }
380
381                /**
382                 * Sets or clears the client-visible status message.
383                 *
384                 * @param taskStatusMessage status message, or null to clear
385                 * @return this builder
386                 */
387                @NonNull
388                public Builder taskStatusMessage(
389                                @Nullable String taskStatusMessage) {
390                        this.taskStatusMessage = taskStatusMessage == null ? null : McpContentValueSupport.requireWellFormedString(taskStatusMessage);
391                        return this;
392                }
393
394                /**
395                 * Sets the task time to live or selects unlimited retention.
396                 *
397                 * @param timeToLive positive whole-millisecond duration, or null for
398                 *                   the wire-level unlimited value
399                 * @return this builder
400                 * @throws IllegalArgumentException if the duration is not positive, has
401                 * sub-millisecond precision, or does not fit in a signed 64-bit
402                 * millisecond count
403                 */
404                @NonNull
405                public Builder timeToLive(@Nullable Duration timeToLive) {
406                        this.timeToLive = requireWholeMilliseconds(timeToLive,
407                                        "Task time to live");
408                        return this;
409                }
410
411                /**
412                 * Sets or clears the suggested polling interval.
413                 *
414                 * @param pollInterval positive whole-millisecond duration, or null to
415                 *                     omit the suggestion
416                 * @return this builder
417                 * @throws IllegalArgumentException if the duration is not positive, has
418                 * sub-millisecond precision, or does not fit in a signed 64-bit
419                 * millisecond count
420                 */
421                @NonNull
422                public Builder pollInterval(@Nullable Duration pollInterval) {
423                        this.pollInterval = requireWholeMilliseconds(pollInterval,
424                                        "Task poll interval");
425                        return this;
426                }
427
428                /**
429                 * Appends one uniquely keyed outstanding input request.
430                 *
431                 * @param key task-lifetime-unique input-request key
432                 * @param inputRequest outstanding input request
433                 * @return this builder
434                 * @throws NullPointerException if an argument is null
435                 * @throws IllegalArgumentException if the key is already present or contains malformed UTF-16
436                 */
437                @NonNull
438                public Builder addInputRequest(@NonNull String key,
439                                @NonNull McpInputRequest inputRequest) {
440                        McpContentValueSupport.requireWellFormedString(requireNonNull(key));
441                        requireNonNull(inputRequest);
442                        if (this.inputRequests.putIfAbsent(key, inputRequest) != null)
443                                throw new IllegalArgumentException(
444                                                "Task input-request keys must be unique.");
445                        return this;
446                }
447
448                /**
449                 * Appends outstanding input requests in iteration order.
450                 *
451                 * <p>The call fails without mutation if an entry is null or any key is
452                 * already present in this builder.
453                 *
454                 * @param inputRequests input requests to append
455                 * @return this builder
456                 * @throws NullPointerException if the map, a key, or a value is null
457                 * @throws IllegalArgumentException if a key is already present or contains malformed UTF-16
458                 */
459                @NonNull
460                public Builder addInputRequests(
461                                @NonNull Map<@NonNull String,
462                                                ? extends @NonNull McpInputRequest> inputRequests) {
463                        requireNonNull(inputRequests);
464                        Map<@NonNull String, @NonNull McpInputRequest> copied =
465                                        new LinkedHashMap<>();
466                        for (Map.Entry<@NonNull String,
467                                        ? extends @NonNull McpInputRequest> entry
468                                        : inputRequests.entrySet()) {
469                                String key = McpContentValueSupport.requireWellFormedString(requireNonNull(entry.getKey()));
470                                McpInputRequest inputRequest = requireNonNull(entry.getValue());
471                                if (this.inputRequests.containsKey(key)
472                                                || copied.putIfAbsent(key, inputRequest) != null)
473                                        throw new IllegalArgumentException(
474                                                        "Task input-request keys must be unique.");
475                        }
476                        this.inputRequests.putAll(copied);
477                        return this;
478                }
479
480                /**
481                 * Supplies the required completed operation result.
482                 *
483                 * @param completedResult completed operation result
484                 * @return this builder
485                 */
486                @NonNull
487                public Builder completedResult(
488                                @NonNull McpCompleteResult completedResult) {
489                        this.completedResult = requireNonNull(completedResult);
490                        return this;
491                }
492
493                /**
494                 * Supplies the required task-execution JSON-RPC failure.
495                 *
496                 * @param failure client-visible JSON-RPC failure
497                 * @return this builder
498                 */
499                @NonNull
500                public Builder failure(@NonNull McpJsonRpcError failure) {
501                        this.failure = requireNonNull(failure);
502                        return this;
503                }
504
505                /**
506                 * Sets task-result protocol extension metadata.
507                 *
508                 * @param metadata immutable metadata, or null to restore the empty default
509                 * @return this builder
510                 * @throws IllegalArgumentException when {@link #build()} is called if the
511                 * metadata uses a reserved MCP key
512                 */
513                @NonNull
514                public Builder metadata(@Nullable McpJsonObject metadata) {
515                        this.metadata = metadata == null
516                                        ? McpJsonObject.emptyInstance() : metadata;
517                        return this;
518                }
519
520                /**
521                 * Builds and validates the immutable task snapshot.
522                 *
523                 * @return immutable task
524                 * @throws IllegalStateException if the status-specific payload is absent
525                 * or does not match the task status
526                 */
527                @NonNull
528                public McpTask build() {
529                        return new McpTask(this);
530                }
531        }
532}