|
|
@@ -23,11 +23,14 @@ namespace ClankerSaddle.Api {
|
|
|
* Handled types: `output` (a single entry or an `entries` array; both
|
|
|
* `data` shapes store `session_commands` rows), `comment`, `block`
|
|
|
* (question/rfc/review with optional `options` and markdown `body`;
|
|
|
- * returns the new `block_id`), `subtask`, `plan`, `progress`,
|
|
|
- * `review`, `done` (completes the task), `failed` (fails the task
|
|
|
- * with the explanation) and `status` (working|blocked).
|
|
|
- * Conversation-level types also append a typed entry to the session's
|
|
|
- * terminal log.
|
|
|
+ * returns the new `block_id`), `progress`, `review`, `done`
|
|
|
+ * (completes the task), `failed` (fails the task with the
|
|
|
+ * explanation) and `status` (working|blocked). Conversation-level
|
|
|
+ * types also append a typed entry to the session's terminal log.
|
|
|
+ * The plan-12 deletions: `subtask` and `plan` are GONE (the goal
|
|
|
+ * agent's task fan-out lives on the task callbacks and its plan on
|
|
|
+ * the goal) — a legacy daemon posting them gets a 400 naming the
|
|
|
+ * removed verb.
|
|
|
*/
|
|
|
public class SaddleUpdateEndpoint : Object, Endpoint {
|
|
|
|
|
|
@@ -96,32 +99,19 @@ namespace ClankerSaddle.Api {
|
|
|
get_string_or(data, "body", null));
|
|
|
yield tasks.log_session_entry(task_id, "block",
|
|
|
@"block: $(block.question)", block.body);
|
|
|
- // The instant-approve levers answer covered review
|
|
|
- // and plan blocks web-side the moment they arrive.
|
|
|
- yield tasks.auto_answer_if_covered(block);
|
|
|
var block_response = new Json.Object();
|
|
|
block_response.set_int_member("block_id", block.id);
|
|
|
return block_response;
|
|
|
|
|
|
case "subtask":
|
|
|
- // Dedup against the synchronous callback: a subtask
|
|
|
- // row already created for this parent/title (the
|
|
|
- // callback path) is not duplicated — the entry log
|
|
|
- // is the fallback, not a second source of truth.
|
|
|
- string subtask_title = get_string_or(data, "title", "Untitled subtask");
|
|
|
- var existing_subtask = yield tasks.find_subtask_by_title(task_id, subtask_title);
|
|
|
- if (existing_subtask == null) {
|
|
|
- yield tasks.create_subtask(task_id, subtask_title,
|
|
|
- get_string_or(data, "description", null));
|
|
|
- }
|
|
|
- break;
|
|
|
-
|
|
|
case "plan":
|
|
|
- yield tasks.update_plan(task_id, get_string_or(data, "plan",
|
|
|
- get_string_or(data, "markdown", "")));
|
|
|
- yield tasks.log_session_entry(task_id, "plan", "saddle plan",
|
|
|
- get_string_or(data, "plan", get_string_or(data, "markdown", "")));
|
|
|
- break;
|
|
|
+ // Plan 12: the subtask and task-plan machinery is
|
|
|
+ // deleted — the daemon's goal agent fans tasks out
|
|
|
+ // through the /api/saddle/task/* callbacks and
|
|
|
+ // plans ride the goal. Surface the removal so a
|
|
|
+ // legacy daemon fails loudly instead of silently.
|
|
|
+ throw new Error(Quark.from_string("clanker-saddle"), 90,
|
|
|
+ @"The '$(type)' update type is gone (plan 12) — use the task callbacks / the goal's plan flow");
|
|
|
|
|
|
case "progress":
|
|
|
string summary = get_string_or(data, "summary",
|
|
|
@@ -134,7 +124,6 @@ namespace ClankerSaddle.Api {
|
|
|
string review_summary = get_string_or(data, "summary", "");
|
|
|
var review_block = yield tasks.request_review(task_id, review_summary);
|
|
|
yield tasks.log_session_entry(task_id, "review", "saddle review", review_summary);
|
|
|
- yield tasks.auto_answer_if_covered(review_block);
|
|
|
break;
|
|
|
|
|
|
case "done":
|
|
|
@@ -411,8 +400,7 @@ namespace ClankerSaddle.Api {
|
|
|
foreach (var task in rows) {
|
|
|
if (q.length > 0) {
|
|
|
string haystack = (task.title + "\n"
|
|
|
- + (task.description ?? "") + "\n"
|
|
|
- + (task.plan ?? "")).down();
|
|
|
+ + (task.description ?? "")).down();
|
|
|
if (!(q in haystack)) {
|
|
|
continue;
|
|
|
}
|
|
|
@@ -421,7 +409,7 @@ namespace ClankerSaddle.Api {
|
|
|
element.set_int_member("id", task.id);
|
|
|
element.set_string_member("title", task.title);
|
|
|
element.set_string_member("status", task.status);
|
|
|
- element.set_int_member("parent_task_id", task.parent_task_id);
|
|
|
+ element.set_int_member("goal_id", task.goal_id);
|
|
|
array.add_object_element(element);
|
|
|
}
|
|
|
var body = new Json.Object();
|
|
|
@@ -436,13 +424,15 @@ namespace ClankerSaddle.Api {
|
|
|
/**
|
|
|
* `GET /api/saddle/task/{id}` — the single-task view behind the
|
|
|
* daemon's `saddle task` CLI (wire contract): per-saddle-token
|
|
|
- * authenticated (plan 10), joining the parent's title for lineage
|
|
|
- * context. 404 when the task does not exist.
|
|
|
+ * authenticated (plan 10), joining the goal's title for context
|
|
|
+ * (plan 12 — the parent/plan members are gone). 404 when the
|
|
|
+ * task does not exist.
|
|
|
*/
|
|
|
public class SaddleTaskDetailEndpoint : Object, Endpoint {
|
|
|
|
|
|
private SaddleService saddles = inject<SaddleService>();
|
|
|
private TaskService tasks = inject<TaskService>();
|
|
|
+ private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
|
|
|
|
|
|
public virtual async HttpResult handle_request(HttpContext http_context,
|
|
|
RouteContext route_context) throws Error {
|
|
|
@@ -461,20 +451,22 @@ namespace ClankerSaddle.Api {
|
|
|
@"No task exists with id $(task_id)");
|
|
|
}
|
|
|
var task = ((!)detail).task;
|
|
|
- string parent_title = "";
|
|
|
- if (task.parent_task_id > 0) {
|
|
|
- var parent = yield tasks.get_task(task.parent_task_id);
|
|
|
- parent_title = parent != null ? ((!)parent).task.title : "";
|
|
|
+ string goal_title = "";
|
|
|
+ if (task.goal_id > 0) {
|
|
|
+ var goal_row = yield db.query<Goal>()
|
|
|
+ .where(Invercargill.Expressions.expr("id == $0",
|
|
|
+ Invercargill.Expressions.elem<int64?>(task.goal_id)))
|
|
|
+ .first_async();
|
|
|
+ goal_title = goal_row != null ? ((!)goal_row).title : "";
|
|
|
}
|
|
|
var element = new Json.Object();
|
|
|
element.set_int_member("id", task.id);
|
|
|
element.set_string_member("title", task.title);
|
|
|
element.set_string_member("description", task.description ?? "");
|
|
|
element.set_string_member("status", task.status);
|
|
|
- element.set_string_member("plan", task.plan ?? "");
|
|
|
element.set_string_member("summary", task.summary ?? "");
|
|
|
- element.set_int_member("parent_task_id", task.parent_task_id);
|
|
|
- element.set_string_member("parent_title", parent_title);
|
|
|
+ element.set_int_member("goal_id", task.goal_id);
|
|
|
+ element.set_string_member("goal_title", goal_title);
|
|
|
var body = new Json.Object();
|
|
|
body.set_object_member("task", element);
|
|
|
return SaddleUpdateEndpoint.json_ok(body);
|
|
|
@@ -536,183 +528,6 @@ namespace ClankerSaddle.Api {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * `POST /api/saddle/subtask` — the synchronous subtask-creation
|
|
|
- * callback a daemon's `saddle subtask` CLI posts to (wire contract):
|
|
|
- * authenticated by the per-saddle bearer token (plan 10), creating
|
|
|
- * the task row immediately — same project, branch and priority as
|
|
|
- * the parent — with `task_dependencies` rows for every `depends_on`
|
|
|
- * id and the timeline entry on the parent's session. Idempotent on
|
|
|
- * (parent, title): a retried callback or the later entry-poll
|
|
|
- * fallback returns the existing task's id instead of duplicating.
|
|
|
- *
|
|
|
- * `capabilities: [tags]` (plan 10 phase 3, `saddle subtask
|
|
|
- * --capability`) attaches required tags to the new task — explicit
|
|
|
- * only, never inherited from the parent — and is validated against
|
|
|
- * the operator tag set first: an unknown tag rejects the call with
|
|
|
- * an error naming the valid tags (the agent reads it verbatim in
|
|
|
- * the CLI output).
|
|
|
- *
|
|
|
- * ```json
|
|
|
- * {"parent_task_id": 1, "title": "…", "description": "…",
|
|
|
- * "depends_on": [3, 4], "capabilities": ["gpu"], "session_id": 2}
|
|
|
- * ```
|
|
|
- *
|
|
|
- * → `{"ok": true, "task_id": 7}`.
|
|
|
- */
|
|
|
- public class SaddleSubtaskEndpoint : Object, Endpoint {
|
|
|
-
|
|
|
- private SaddleService saddles = inject<SaddleService>();
|
|
|
- private TaskService tasks = inject<TaskService>();
|
|
|
- private CapabilityService capabilities = inject<CapabilityService>();
|
|
|
-
|
|
|
- public virtual async HttpResult handle_request(HttpContext http_context,
|
|
|
- RouteContext route_context) throws Error {
|
|
|
- if (!(yield validate_saddle_bearer(http_context, saddles))) {
|
|
|
- return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
|
|
|
- }
|
|
|
-
|
|
|
- Json.Object body;
|
|
|
- try {
|
|
|
- body = yield SaddleUpdateEndpoint.read_json_body(http_context);
|
|
|
- } catch (Error e) {
|
|
|
- return SaddleUpdateEndpoint.json_error(400, e.message);
|
|
|
- }
|
|
|
-
|
|
|
- int64 parent_task_id = body.has_member("parent_task_id")
|
|
|
- && body.get_member("parent_task_id").get_value_type() == typeof(int64)
|
|
|
- ? body.get_int_member("parent_task_id") : 0;
|
|
|
- string title = SaddleUpdateEndpoint.get_string_or(body, "title", "");
|
|
|
- if (parent_task_id <= 0 || title.length == 0) {
|
|
|
- return SaddleUpdateEndpoint.json_error(400,
|
|
|
- "parent_task_id and title are required");
|
|
|
- }
|
|
|
- var parent = yield tasks.get_task(parent_task_id);
|
|
|
- if (parent == null) {
|
|
|
- return SaddleUpdateEndpoint.json_error(400,
|
|
|
- @"No task exists with id $(parent_task_id)");
|
|
|
- }
|
|
|
-
|
|
|
- string[] requested_tags = {};
|
|
|
- try {
|
|
|
- requested_tags = capability_tags_of(body);
|
|
|
- } catch (Error e) {
|
|
|
- return SaddleUpdateEndpoint.json_error(400, e.message);
|
|
|
- }
|
|
|
- var resolved_capabilities = new Vector<Capability>();
|
|
|
- if (requested_tags.length > 0) {
|
|
|
- var unknown = new Vector<string>();
|
|
|
- foreach (string tag in requested_tags) {
|
|
|
- string trimmed = tag.strip();
|
|
|
- if (trimmed.length == 0) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- var capability = yield capabilities.find_by_name(trimmed);
|
|
|
- if (capability == null) {
|
|
|
- unknown.add(trimmed);
|
|
|
- } else {
|
|
|
- resolved_capabilities.add((!)capability);
|
|
|
- }
|
|
|
- }
|
|
|
- if (unknown.length > 0) {
|
|
|
- var valid = new Vector<string>();
|
|
|
- foreach (var capability in yield capabilities.list_capabilities()) {
|
|
|
- valid.add(capability.name);
|
|
|
- }
|
|
|
- return SaddleUpdateEndpoint.json_error(400,
|
|
|
- @"Unknown capability tag(s): $(string.joinv(", ", unknown.to_array()))"
|
|
|
- + @" — valid tags: $(string.joinv(", ", valid.to_array()))");
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- int64[] depends_on = {};
|
|
|
- if (body.has_member("depends_on")
|
|
|
- && body.get_member("depends_on").get_node_type() == Json.NodeType.ARRAY) {
|
|
|
- foreach (var element in body.get_array_member("depends_on").get_elements()) {
|
|
|
- if (element.get_node_type() == Json.NodeType.VALUE) {
|
|
|
- int64 parsed = 0;
|
|
|
- if (element.get_value_type() == typeof(int64)) {
|
|
|
- parsed = element.get_int();
|
|
|
- } else {
|
|
|
- parsed = int64.parse(element.get_string());
|
|
|
- }
|
|
|
- if (parsed > 0) {
|
|
|
- depends_on += parsed;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- try {
|
|
|
- var existing = yield tasks.find_subtask_by_title(parent_task_id, title);
|
|
|
- TaskEntity task;
|
|
|
- bool created = false;
|
|
|
- if (existing != null) {
|
|
|
- task = (!)existing;
|
|
|
- } else {
|
|
|
- // enqueue=false keeps the row in draft until the
|
|
|
- // explicit capabilities are attached below — the
|
|
|
- // dispatcher must never see the subtask with a
|
|
|
- // partial requirement set.
|
|
|
- task = yield tasks.create_subtask_with_dependencies(parent_task_id,
|
|
|
- title, SaddleUpdateEndpoint.get_string_or(body, "description", null),
|
|
|
- depends_on, false);
|
|
|
- created = true;
|
|
|
- }
|
|
|
- if (created) {
|
|
|
- yield tasks.log_session_entry(parent_task_id, "subtask",
|
|
|
- @"saddle subtask: $(title)", @"created task #$(task.id)");
|
|
|
- }
|
|
|
- // Explicit-only capability attachment (never inherited):
|
|
|
- // idempotent, so a retried callback re-attaches nothing.
|
|
|
- foreach (var capability in resolved_capabilities) {
|
|
|
- yield capabilities.attach_to_task(capability.id, task.id);
|
|
|
- }
|
|
|
- // The parent's auto-enqueue option (plan 11): a subtask
|
|
|
- // created under a parent carrying it enqueues immediately
|
|
|
- // — after its capabilities landed, so the queue only ever
|
|
|
- // sees the full requirement set. Otherwise the subtask
|
|
|
- // stays in draft for the human Enqueue action (and, when
|
|
|
- // the parent is parked, (re)arms the waiting-on-drafts
|
|
|
- // push).
|
|
|
- if (created && ((!)parent).task.auto_enqueue_subtasks) {
|
|
|
- yield tasks.enqueue_task(task.id);
|
|
|
- } else if (created) {
|
|
|
- yield tasks.notify_waiting_on_drafts_if_parked(parent_task_id);
|
|
|
- }
|
|
|
- var response = new Json.Object();
|
|
|
- response.set_int_member("task_id", task.id);
|
|
|
- return SaddleUpdateEndpoint.json_ok(response);
|
|
|
- } catch (Error e) {
|
|
|
- return SaddleUpdateEndpoint.json_error(400, e.message);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * The `capabilities` member as a tag array: absent → empty; a
|
|
|
- * native JSON array of strings → its elements; anything else →
|
|
|
- * an error (the daemon always sends the array form).
|
|
|
- */
|
|
|
- private static string[] capability_tags_of(Json.Object body) throws Error {
|
|
|
- if (!body.has_member("capabilities")) {
|
|
|
- return new string[0];
|
|
|
- }
|
|
|
- var member = body.get_member("capabilities");
|
|
|
- if (member.get_node_type() != Json.NodeType.ARRAY) {
|
|
|
- throw new Error(Quark.from_string("clanker-saddle"), 46,
|
|
|
- "capabilities must be a JSON array of tag names");
|
|
|
- }
|
|
|
- var tags = new Vector<string>();
|
|
|
- foreach (var element in member.get_array().get_elements()) {
|
|
|
- if (element.get_node_type() == Json.NodeType.VALUE
|
|
|
- && element.get_string() != null) {
|
|
|
- tags.add(element.get_string());
|
|
|
- }
|
|
|
- }
|
|
|
- return tags.to_array();
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
/**
|
|
|
* `POST /api/saddle/note` — the callback endpoint a daemon's
|
|
|
* `saddle note` CLI posts to (wire contract): authenticated by the
|
|
|
@@ -921,7 +736,7 @@ namespace ClankerSaddle.Api {
|
|
|
}
|
|
|
|
|
|
var task = yield tasks.create_task(((!)goal).project_id, title, mandate,
|
|
|
- 5, null, null, null, false, false, false, STATUS_DRAFT,
|
|
|
+ 5, null, null, STATUS_DRAFT,
|
|
|
TASK_KIND_TASK, mode, CREATOR_GOAL, goal_id, 0);
|
|
|
foreach (var capability in resolved) {
|
|
|
yield capabilities.attach_to_task(capability.id, task.id);
|