|
|
@@ -14,6 +14,20 @@ namespace ClankerSaddle.Services {
|
|
|
public string answer { get; set; default = ""; }
|
|
|
}
|
|
|
|
|
|
+ /** A human comment awaiting delivery to one saddle's conversation. */
|
|
|
+ public class PendingComment : Object {
|
|
|
+ public int64 comment_id { get; set; }
|
|
|
+ public string author { get; set; default = ""; }
|
|
|
+ public string body { get; set; default = ""; }
|
|
|
+ public string quote { get; set; default = ""; }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** A saddle registration plus the API token generated for it. */
|
|
|
+ public class SaddleRegistration : Object {
|
|
|
+ public Saddle saddle { get; set; }
|
|
|
+ public string token { get; set; default = ""; }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* The OpenAI-compatible endpoint every saddle assignment is sent to,
|
|
|
* read from the environment: `SADDLE_LLM_BASE_URL` (required — dispatch
|
|
|
@@ -52,16 +66,72 @@ namespace ClankerSaddle.Services {
|
|
|
private OrmSession db = inject<OrmSession>();
|
|
|
private Soup.Session http = new Soup.Session();
|
|
|
private SaddlePollState poll_state = inject<SaddlePollState>();
|
|
|
+ private StatePulse pulse = inject<StatePulse>();
|
|
|
|
|
|
- /** Registers a saddle container; `host` is host:port of its control server. */
|
|
|
- public async Saddle register_saddle(string name, string host) throws Error {
|
|
|
+ /**
|
|
|
+ * Registers a saddle container and generates its API token.
|
|
|
+ * `host` is host:port of its control server; the returned
|
|
|
+ * {@link SaddleRegistration} carries the token the daemon must
|
|
|
+ * present as `Authorization: Bearer …` on `/api/saddle/*` calls.
|
|
|
+ */
|
|
|
+ public async SaddleRegistration register_saddle(string name, string host) throws Error {
|
|
|
var saddle = new Saddle() {
|
|
|
name = name,
|
|
|
host = host,
|
|
|
status = "idle"
|
|
|
};
|
|
|
yield db.insert_async<Saddle>(saddle);
|
|
|
- return saddle;
|
|
|
+ string token = yield generate_token(saddle.id);
|
|
|
+ yield pulse.bump(0, "saddle registered");
|
|
|
+ return new SaddleRegistration() { saddle = saddle, token = token };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Generates and stores a fresh API token for `saddle_id`,
|
|
|
+ * replacing any earlier one on the wire (older rows remain valid
|
|
|
+ * until removed; in practice each saddle holds one).
|
|
|
+ */
|
|
|
+ public async string generate_token(int64 saddle_id) throws Error {
|
|
|
+ var row = new ApiToken() {
|
|
|
+ saddle_id = saddle_id,
|
|
|
+ token = @"saddle-$(Uuid.string_random().replace("-", ""))",
|
|
|
+ created_at = new DateTime.now_local()
|
|
|
+ };
|
|
|
+ yield db.insert_async<ApiToken>(row);
|
|
|
+ return row.token;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * True when `token` matches a stored {@link ApiToken} row of
|
|
|
+ * `saddle_id` — the credential check for the `/api/saddle/*`
|
|
|
+ * endpoints.
|
|
|
+ */
|
|
|
+ public async bool validate_token(int64 saddle_id, string token) throws Error {
|
|
|
+ if (token.length == 0) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ var rows = yield db.query<ApiToken>()
|
|
|
+ .where(expr("saddle_id == $0", elem<int64?>(saddle_id)))
|
|
|
+ .materialise_async();
|
|
|
+ foreach (var row in rows) {
|
|
|
+ if (row.token == token) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** The most recent API token issued for `saddle_id`, or null. */
|
|
|
+ public async string? token_for_saddle(int64 saddle_id) throws Error {
|
|
|
+ ApiToken? latest = null;
|
|
|
+ var rows = yield db.query<ApiToken>()
|
|
|
+ .where(expr("saddle_id == $0", elem<int64?>(saddle_id)))
|
|
|
+ .order_by(expr("id"))
|
|
|
+ .materialise_async();
|
|
|
+ foreach (var row in rows) {
|
|
|
+ latest = row;
|
|
|
+ }
|
|
|
+ return latest != null ? ((!)latest).token : null;
|
|
|
}
|
|
|
|
|
|
/** Removes a saddle registration; tasks it was running are re-queued. */
|
|
|
@@ -75,12 +145,14 @@ namespace ClankerSaddle.Services {
|
|
|
.where(expr("id == $0", elem<int64?>(((!)saddle).current_task_id)))
|
|
|
.first_async();
|
|
|
if (task != null && (((!)task).status == STATUS_ASSIGNED
|
|
|
- || ((!)task).status == STATUS_WORKING || ((!)task).status == STATUS_BLOCKED)) {
|
|
|
+ || ((!)task).status == STATUS_WORKING || ((!)task).status == STATUS_BLOCKED
|
|
|
+ || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
|
|
|
((!)task).status = STATUS_QUEUED;
|
|
|
yield db.update_async<TaskEntity>((!)task);
|
|
|
}
|
|
|
}
|
|
|
yield db.delete_async<Saddle>((!)saddle);
|
|
|
+ yield pulse.bump(0, "saddle removed");
|
|
|
}
|
|
|
|
|
|
/** Loads the saddle identified by `id`, or null. */
|
|
|
@@ -121,6 +193,7 @@ namespace ClankerSaddle.Services {
|
|
|
((!)saddle).current_task_id = current_task_id != null ? ((!)current_task_id) : 0;
|
|
|
((!)saddle).allocated_at = current_task_id != null ? new DateTime.now_local() : null;
|
|
|
yield db.update_async<Saddle>((!)saddle);
|
|
|
+ yield pulse.bump(current_task_id ?? 0, @"saddle $(status)");
|
|
|
}
|
|
|
|
|
|
/** The first saddle with status 'idle', or null when none is free. */
|
|
|
@@ -147,7 +220,8 @@ namespace ClankerSaddle.Services {
|
|
|
.where(expr("id == $0", elem<int64?>(task_id)))
|
|
|
.first_async();
|
|
|
if (task != null && (((!)task).status == STATUS_ASSIGNED
|
|
|
- || ((!)task).status == STATUS_WORKING || ((!)task).status == STATUS_BLOCKED)) {
|
|
|
+ || ((!)task).status == STATUS_WORKING || ((!)task).status == STATUS_BLOCKED
|
|
|
+ || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
|
|
|
((!)task).status = STATUS_QUEUED;
|
|
|
yield db.update_async<TaskEntity>((!)task);
|
|
|
}
|
|
|
@@ -168,16 +242,19 @@ namespace ClankerSaddle.Services {
|
|
|
((!)saddle).current_task_id = 0;
|
|
|
((!)saddle).allocated_at = null;
|
|
|
yield db.update_async<Saddle>((!)saddle);
|
|
|
+ yield pulse.bump(0, "saddle unreachable");
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Pushes a task assignment to the saddle daemon's `POST /start`
|
|
|
* endpoint in its wire shape — `{task, llm_config}` — with the
|
|
|
- * project, co-developed/reference links and recent comments inside
|
|
|
- * the task object. Project checkouts are addressed by the
|
|
|
- * `SADDLE_PROJECTS_ROOT` convention (`root/name`), the LLM endpoint
|
|
|
- * by the {@link LlmEndpoint} environment. Returns the parsed JSON
|
|
|
- * response body.
|
|
|
+ * project, co-developed/reference links, branch fields, plan and
|
|
|
+ * recent comments inside the task object, plus any block answers
|
|
|
+ * not yet handed over (a resumed workspace picks its pending
|
|
|
+ * answers up from the assignment itself). Project checkouts are
|
|
|
+ * addressed by the `SADDLE_PROJECTS_ROOT` convention
|
|
|
+ * (`root/name`), the LLM endpoint by the {@link LlmEndpoint}
|
|
|
+ * environment. Returns the parsed JSON response body.
|
|
|
*/
|
|
|
public async Json.Object push_task_to_saddle(int64 saddle_id, TaskEntity task, int64 session_id,
|
|
|
ProjectService projects) throws Error {
|
|
|
@@ -216,6 +293,12 @@ namespace ClankerSaddle.Services {
|
|
|
payload.add_string_value(((!)project).name);
|
|
|
payload.set_member_name("project_branch");
|
|
|
payload.add_string_value(((!)project).default_branch);
|
|
|
+ payload.set_member_name("task_branch");
|
|
|
+ payload.add_string_value(task.task_branch ?? "");
|
|
|
+ payload.set_member_name("branch_from");
|
|
|
+ payload.add_string_value(task.branch_from ?? ((!)project).default_branch);
|
|
|
+ payload.set_member_name("plan");
|
|
|
+ payload.add_string_value(task.plan ?? "");
|
|
|
|
|
|
var co_developed = new Vector<Project>();
|
|
|
var references = new Vector<Project>();
|
|
|
@@ -259,6 +342,22 @@ namespace ClankerSaddle.Services {
|
|
|
payload.add_string_value(comment.author);
|
|
|
payload.set_member_name("body");
|
|
|
payload.add_string_value(comment.body);
|
|
|
+ if (comment.quote != null) {
|
|
|
+ payload.set_member_name("quote");
|
|
|
+ payload.add_string_value(((!)comment.quote));
|
|
|
+ }
|
|
|
+ payload.end_object();
|
|
|
+ }
|
|
|
+ payload.end_array();
|
|
|
+
|
|
|
+ payload.set_member_name("answers");
|
|
|
+ payload.begin_array();
|
|
|
+ foreach (var pending in yield take_pending_answers_for_task(task.id)) {
|
|
|
+ payload.begin_object();
|
|
|
+ payload.set_member_name("block_id");
|
|
|
+ payload.add_int_value(pending.block_id);
|
|
|
+ payload.set_member_name("answer");
|
|
|
+ payload.add_string_value(pending.answer);
|
|
|
payload.end_object();
|
|
|
}
|
|
|
payload.end_array();
|
|
|
@@ -302,13 +401,14 @@ namespace ClankerSaddle.Services {
|
|
|
/**
|
|
|
* Polls a busy saddle: stores new entries reported by the daemon's
|
|
|
* `GET /output` as `session_commands` rows of the open session and
|
|
|
- * projects the agent's `saddle comment/subtask/done` events into
|
|
|
- * their domain tables, then applies status changes from
|
|
|
- * `GET /status` — done/failed complete or fail the task, blocked
|
|
|
- * raises the daemon's question as an answerable block and delivers
|
|
|
- * any human answers to the daemon's `POST /answer`. Returns true
|
|
|
- * when the saddle reported the task finished; the status pass is
|
|
|
- * skipped once the entries already finished the task.
|
|
|
+ * projects the agent's conversation-level events into their domain
|
|
|
+ * tables, then applies status changes from `GET /status` —
|
|
|
+ * done/failed complete or fail the task, blocked raises the
|
|
|
+ * daemon's question as an answerable block and parks the saddle
|
|
|
+ * (idle, available for other work) until the dispatcher resumes
|
|
|
+ * the task. Returns true when the saddle reported the task
|
|
|
+ * finished; the status pass is skipped once the entries already
|
|
|
+ * finished the task.
|
|
|
*/
|
|
|
public async bool poll_saddle_updates(int64 saddle_id, TaskService tasks) throws Error {
|
|
|
var saddle = yield get_saddle(saddle_id);
|
|
|
@@ -337,12 +437,13 @@ namespace ClankerSaddle.Services {
|
|
|
yield tasks.fail_task(task_id, null);
|
|
|
return true;
|
|
|
case "blocked":
|
|
|
- string question = string_member(status, "block_question");
|
|
|
- if (question.length > 0) {
|
|
|
- yield ensure_block(tasks, task_id, question);
|
|
|
- yield deliver_answered_blocks((!)saddle, task_id, question);
|
|
|
- }
|
|
|
+ yield ensure_block(tasks, task_id, status);
|
|
|
yield tasks.update_task(task_id, null, null, null, STATUS_BLOCKED);
|
|
|
+ // The daemon parks the task's workspace while blocked,
|
|
|
+ // freeing the saddle for other work. The blocked task
|
|
|
+ // plus its open session is the pause record the
|
|
|
+ // dispatcher resumes from once the block is answered.
|
|
|
+ yield update_saddle_status(saddle_id, "idle", null);
|
|
|
break;
|
|
|
case "working":
|
|
|
var task = yield tasks.update_task(task_id, null, null, null, STATUS_WORKING);
|
|
|
@@ -382,6 +483,7 @@ namespace ClankerSaddle.Services {
|
|
|
((!)saddle).status = "offline";
|
|
|
}
|
|
|
yield db.update_async<Saddle>((!)saddle);
|
|
|
+ yield pulse.bump(0, "saddle status refreshed");
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -393,9 +495,18 @@ namespace ClankerSaddle.Services {
|
|
|
if (saddle == null || ((!)saddle).current_task_id == 0) {
|
|
|
return new Vector<PendingAnswer>().to_immutable_buffer();
|
|
|
}
|
|
|
+ return yield take_pending_answers_for_task(((!)saddle).current_task_id);
|
|
|
+ }
|
|
|
|
|
|
+ /**
|
|
|
+ * Answered blocks of `task_id` not yet handed over anywhere;
|
|
|
+ * marks them delivered. Used both by the poll endpoint and by
|
|
|
+ * {@link push_task_to_saddle} (a resumed assignment carries its
|
|
|
+ * pending answers in the payload).
|
|
|
+ */
|
|
|
+ public async ImmutableLot<PendingAnswer> take_pending_answers_for_task(int64 task_id) throws Error {
|
|
|
var blocks = yield db.query<TaskBlock>()
|
|
|
- .where(expr("task_id == $0", elem<int64?>(((!)saddle).current_task_id)))
|
|
|
+ .where(expr("task_id == $0", elem<int64?>(task_id)))
|
|
|
.materialise_async();
|
|
|
var rows = new Vector<PendingAnswer>();
|
|
|
foreach (var block in blocks) {
|
|
|
@@ -413,6 +524,142 @@ namespace ClankerSaddle.Services {
|
|
|
return rows.to_immutable_buffer();
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Human comments of the saddle's current task not yet delivered to
|
|
|
+ * the daemon; marks them delivered (the poll response hands them
|
|
|
+ * over exactly once).
|
|
|
+ */
|
|
|
+ public async ImmutableLot<PendingComment> take_pending_comments(int64 saddle_id) throws Error {
|
|
|
+ var saddle = yield get_saddle(saddle_id);
|
|
|
+ if (saddle == null || ((!)saddle).current_task_id == 0) {
|
|
|
+ return new Vector<PendingComment>().to_immutable_buffer();
|
|
|
+ }
|
|
|
+ var comments = yield db.query<TaskComment>()
|
|
|
+ .where(expr("task_id == $0", elem<int64?>(((!)saddle).current_task_id)))
|
|
|
+ .order_by(expr("id"))
|
|
|
+ .materialise_async();
|
|
|
+ var rows = new Vector<PendingComment>();
|
|
|
+ foreach (var comment in comments) {
|
|
|
+ if (!comment.author.has_prefix("user:")
|
|
|
+ || poll_state.is_comment_delivered(comment.id)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ poll_state.mark_comment_delivered(comment.id);
|
|
|
+ rows.add(new PendingComment() {
|
|
|
+ comment_id = comment.id,
|
|
|
+ author = comment.author,
|
|
|
+ body = comment.body,
|
|
|
+ quote = comment.quote ?? ""
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return rows.to_immutable_buffer();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Pushes the task's undelivered human comments to the daemon's
|
|
|
+ * `POST /notify` endpoint so they land in the live conversation
|
|
|
+ * without waiting for its next poll. Best effort: a daemon
|
|
|
+ * without the route (HTTP 404/405) keeps the comments pending for
|
|
|
+ * the poll endpoint, and other failures are logged and retried on
|
|
|
+ * the next dispatcher tick.
|
|
|
+ */
|
|
|
+ public async void deliver_pending_comments(int64 saddle_id, int64 task_id) throws Error {
|
|
|
+ var saddle = yield get_saddle(saddle_id);
|
|
|
+ if (saddle == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ var comments = yield db.query<TaskComment>()
|
|
|
+ .where(expr("task_id == $0", elem<int64?>(task_id)))
|
|
|
+ .order_by(expr("id"))
|
|
|
+ .materialise_async();
|
|
|
+ foreach (var comment in comments) {
|
|
|
+ if (!comment.author.has_prefix("user:")
|
|
|
+ || poll_state.is_comment_delivered(comment.id)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ var payload = new Json.Builder();
|
|
|
+ payload.begin_object();
|
|
|
+ payload.set_member_name("type");
|
|
|
+ payload.add_string_value("comment");
|
|
|
+ payload.set_member_name("author");
|
|
|
+ payload.add_string_value(comment.author);
|
|
|
+ payload.set_member_name("body");
|
|
|
+ payload.add_string_value(comment.body);
|
|
|
+ if (comment.quote != null) {
|
|
|
+ payload.set_member_name("quote");
|
|
|
+ payload.add_string_value(((!)comment.quote));
|
|
|
+ }
|
|
|
+ payload.end_object();
|
|
|
+
|
|
|
+ Json.Node? root = null;
|
|
|
+ int status_code = yield request_saddle(((!)saddle).host, "POST", "/notify",
|
|
|
+ (owned) payload, out root);
|
|
|
+ if (status_code == 200 || status_code == 204) {
|
|
|
+ poll_state.mark_comment_delivered(comment.id);
|
|
|
+ } else if (status_code != 404 && status_code != 405) {
|
|
|
+ warning(@"[ClankerSaddle] Comment notify to saddle $(saddle_id) returned HTTP $(status_code)");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Marks the task's human comments as delivered — they were handed
|
|
|
+ * over inside an assignment payload, so neither the poll endpoint
|
|
|
+ * nor `/notify` should repeat them.
|
|
|
+ */
|
|
|
+ public async void mark_task_comments_delivered(int64 task_id) throws Error {
|
|
|
+ var comments = yield db.query<TaskComment>()
|
|
|
+ .where(expr("task_id == $0", elem<int64?>(task_id)))
|
|
|
+ .materialise_async();
|
|
|
+ foreach (var comment in comments) {
|
|
|
+ if (comment.author.has_prefix("user:")) {
|
|
|
+ poll_state.mark_comment_delivered(comment.id);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Resumes a task whose block has been answered on the saddle that
|
|
|
+ * holds its open (parked) session. A daemon still waiting in its
|
|
|
+ * answer loop (`state == blocked`) receives the answer via
|
|
|
+ * `POST /answer` and carries on; a parked workspace is re-pushed
|
|
|
+ * via `POST /start` — the assignment carries the pending answers —
|
|
|
+ * and the saddle returns to busy. Returns true when the task was
|
|
|
+ * resumed; false when the saddle is unavailable (busy with other
|
|
|
+ * work or offline).
|
|
|
+ */
|
|
|
+ public async bool resume_answered_task(TaskEntity task, ClankerSaddle.Data.Session session,
|
|
|
+ TaskService tasks, ProjectService projects) throws Error {
|
|
|
+ var saddle = yield get_saddle(session.saddle_id);
|
|
|
+ if (saddle == null || (((!)saddle).status == "offline"
|
|
|
+ || (((!)saddle).status != "idle" && ((!)saddle).current_task_id != task.id))) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ Json.Object status;
|
|
|
+ try {
|
|
|
+ status = yield request_saddle_json(((!)saddle).host, "GET", "/status", null);
|
|
|
+ } catch (Error e) {
|
|
|
+ warning(@"[ClankerSaddle] Resume probe of saddle $(session.saddle_id) failed: $(e.message)");
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ string state = status.has_member("state") ? status.get_string_member("state") : "";
|
|
|
+ if (state == "blocked") {
|
|
|
+ yield deliver_answered_blocks((!)saddle, task.id, string_member(status, "block_question"));
|
|
|
+ yield tasks.update_task(task.id, null, null, null, STATUS_WORKING);
|
|
|
+ yield update_saddle_status(((!)saddle).id, "busy", task.id);
|
|
|
+ } else {
|
|
|
+ yield update_saddle_status(((!)saddle).id, "busy", task.id);
|
|
|
+ yield seed_session_cursor(((!)saddle).id, session.id);
|
|
|
+ yield push_task_to_saddle(((!)saddle).id, task, session.id, projects);
|
|
|
+ yield mark_task_comments_delivered(task.id);
|
|
|
+ yield tasks.update_task(task.id, null, null, null, STATUS_ASSIGNED);
|
|
|
+ }
|
|
|
+ print(@"[ClankerSaddle] Resumed task $(task.id) on saddle '$(((!)saddle).name)'\n");
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Stores entries newly reported by the daemon's `GET /output`
|
|
|
* (a bare JSON array of `{id, command, output, exit_code, type,
|
|
|
@@ -452,6 +699,8 @@ namespace ClankerSaddle.Services {
|
|
|
? entry.get_string_member("output") : null,
|
|
|
exit_code = entry.has_member("exit_code") && entry.get_member("exit_code").get_value_type() == typeof(int64)
|
|
|
? entry.get_int_member("exit_code") : 0,
|
|
|
+ entry_type = string_member(entry, "type").length > 0
|
|
|
+ ? string_member(entry, "type") : "command",
|
|
|
started_at = parse_iso(entry, "started_at") ?? parse_iso(entry, "timestamp"),
|
|
|
completed_at = parse_iso(entry, "completed_at"),
|
|
|
duration_ms = entry.has_member("duration_ms") && entry.get_member("duration_ms").get_value_type() == typeof(int64)
|
|
|
@@ -468,17 +717,23 @@ namespace ClankerSaddle.Services {
|
|
|
/**
|
|
|
* Projects the agent's conversation-level events onto the task:
|
|
|
* `saddle comment` lines become bot comments, `saddle subtask`
|
|
|
- * lines create child tasks, and the `saddle done` entry completes
|
|
|
- * the task with the summary the agent gave. Returns true once a
|
|
|
- * done event finished the task.
|
|
|
+ * lines create child tasks, `saddle plan`/`saddle
|
|
|
+ * progress-update`/`saddle review` update the plan, append
|
|
|
+ * progress and raise the review banner, and the `saddle done`
|
|
|
+ * entry completes the task with the summary the agent gave.
|
|
|
+ * Returns true once a done event finished the task.
|
|
|
*/
|
|
|
private async bool apply_entry_events(int64 task_id, Vector<Json.Object> entries,
|
|
|
TaskService tasks) throws Error {
|
|
|
foreach (var entry in entries) {
|
|
|
string command = string_member(entry, "command");
|
|
|
+ string output = string_member(entry, "output");
|
|
|
switch (string_member(entry, "type")) {
|
|
|
case "comment":
|
|
|
string body = strip_quotes(strip_verb(command, "saddle comment"));
|
|
|
+ if (body.length == 0) {
|
|
|
+ body = output;
|
|
|
+ }
|
|
|
if (body.length > 0) {
|
|
|
yield tasks.add_comment(task_id, "bot", body);
|
|
|
}
|
|
|
@@ -490,6 +745,26 @@ namespace ClankerSaddle.Services {
|
|
|
operands.length >= 2 ? operands[1] : null);
|
|
|
}
|
|
|
break;
|
|
|
+ case "plan":
|
|
|
+ string markdown = output.length > 0 ? output
|
|
|
+ : strip_quotes(strip_verb(command, "saddle plan"));
|
|
|
+ if (markdown.length > 0) {
|
|
|
+ yield tasks.update_plan(task_id, markdown);
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ case "progress":
|
|
|
+ string summary = strip_verb(command, "saddle progress-update");
|
|
|
+ if (summary.length == 0) {
|
|
|
+ summary = output;
|
|
|
+ }
|
|
|
+ if (summary.length > 0) {
|
|
|
+ yield tasks.add_progress(task_id, strip_quotes(summary));
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ case "review":
|
|
|
+ yield tasks.request_review(task_id, output.length > 0 ? output
|
|
|
+ : strip_quotes(strip_verb(command, "saddle review")));
|
|
|
+ break;
|
|
|
case "done":
|
|
|
yield tasks.complete_task(task_id,
|
|
|
strip_quotes(strip_verb(command, "saddle done")), null, "done");
|
|
|
@@ -502,12 +777,18 @@ namespace ClankerSaddle.Services {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Raises the daemon's current blocking question as a `task_blocks`
|
|
|
+ * Raises the daemon's current blocking prompt as a `task_blocks`
|
|
|
* row exactly once: a not-yet-answered row, or an answered row
|
|
|
* whose answer has not been delivered to the daemon yet, means the
|
|
|
- * question is already represented.
|
|
|
+ * prompt is already represented. The status object carries the
|
|
|
+ * question plus (from newer daemons) the block type, options and
|
|
|
+ * markdown body.
|
|
|
*/
|
|
|
- private async void ensure_block(TaskService tasks, int64 task_id, string question) throws Error {
|
|
|
+ private async void ensure_block(TaskService tasks, int64 task_id, Json.Object status) throws Error {
|
|
|
+ string question = string_member(status, "block_question");
|
|
|
+ if (question.length == 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
foreach (var block in yield tasks.list_blocks(task_id)) {
|
|
|
if (block.question != question) {
|
|
|
continue;
|
|
|
@@ -516,7 +797,10 @@ namespace ClankerSaddle.Services {
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
- yield tasks.add_block(task_id, question);
|
|
|
+ yield tasks.add_block(task_id, question,
|
|
|
+ string_member(status, "block_type"),
|
|
|
+ string_array_member(status, "block_options"),
|
|
|
+ optional_string_member(status, "block_body"));
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -720,6 +1004,55 @@ namespace ClankerSaddle.Services {
|
|
|
? obj.get_string_member(name) : "";
|
|
|
}
|
|
|
|
|
|
+ /** A nullable string member: null when absent or empty. */
|
|
|
+ private static string? optional_string_member(Json.Object obj, string name) {
|
|
|
+ string value = string_member(obj, name);
|
|
|
+ return value.length > 0 ? value : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * A string-array member: native JSON arrays are read element-wise;
|
|
|
+ * a JSON-encoded string is parsed; anything else is empty.
|
|
|
+ */
|
|
|
+ private static string[] string_array_member(Json.Object obj, string name) {
|
|
|
+ if (!obj.has_member(name)) {
|
|
|
+ return new string[0];
|
|
|
+ }
|
|
|
+ var member = obj.get_member(name);
|
|
|
+ if (member.get_node_type() == Json.NodeType.ARRAY) {
|
|
|
+ var values = new Vector<string>();
|
|
|
+ foreach (var element in member.get_array().get_elements()) {
|
|
|
+ if (element.get_node_type() == Json.NodeType.VALUE
|
|
|
+ && element.get_string() != null) {
|
|
|
+ values.add(element.get_string());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return values.to_array();
|
|
|
+ }
|
|
|
+ string raw = string_member(obj, name);
|
|
|
+ if (raw.length == 0) {
|
|
|
+ return new string[0];
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ var parser = new Json.Parser();
|
|
|
+ parser.load_from_data(raw);
|
|
|
+ var root = parser.get_root();
|
|
|
+ if (root != null && root.get_node_type() == Json.NodeType.ARRAY) {
|
|
|
+ var values = new Vector<string>();
|
|
|
+ foreach (var element in root.get_array().get_elements()) {
|
|
|
+ if (element.get_node_type() == Json.NodeType.VALUE
|
|
|
+ && element.get_string() != null) {
|
|
|
+ values.add(element.get_string());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return values.to_array();
|
|
|
+ }
|
|
|
+ } catch (Error e) {
|
|
|
+ warning("[ClankerSaddle] Ignoring malformed options array: %s", e.message);
|
|
|
+ }
|
|
|
+ return new string[0];
|
|
|
+ }
|
|
|
+
|
|
|
private static DateTime? parse_iso(Json.Object obj, string name) {
|
|
|
if (!obj.has_member(name)) {
|
|
|
return null;
|