using Inversion; using Invercargill; using Invercargill.Expressions; using Spry; using Spry.Authentication; using Statum; using ClankerSaddle.Data; using ClankerSaddle.Services; namespace ClankerSaddle { /** Sealed chat id carried by the advisor chat actions. */ public class AdvisorChatActionPrivate : Object { public int64 chat_id { get; set; } } /** * Sealed suggestion + chat ids carried by the suggestion decision * action (goal 27 task 3 owns its handler). */ public class AdvisorSuggestionActionPrivate : Object { public int64 suggestion_id { get; set; } public int64 chat_id { get; set; } } /** * Starts a new advisor chat (goal 27) from the dashboard's "Ask * the advisor" composer: creates the chat titled from the first * line of the message (clamped ~80 chars), records the message as * the opening user row and kicks the background * {@link AdvisorService} turn. The POST returns before the turn * finishes — the chat page's pulse-driven refresh streams the * appends in — and the browser navigates to /chats/{id}. */ public class StartAdvisorChatAction : StatumAction { private ChatService chat_service = inject(); private AdvisorService advisor_service = inject(); private UserService users = inject(); public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var me = SpryAuth.current(request); if (me == null) { return directives().navigate("/"); } int64 viewer_id = ((!)me).user_id; string message = Forms.field_raw(request, "message"); if (message.strip().length == 0) { return directives().notify("error", "Type a question for the advisor first").navigate("/"); } var chat = yield chat_service.create_chat(viewer_id, chat_title(message)); yield chat_service.record_user_message(chat.id, message.strip()); // The turn runs in the service's own scope; this request // finishes (and navigates) immediately. advisor_service.run_turn.begin(chat.id, viewer_id); return directives().navigate(@"/chats/$(chat.id)"); } /** * The chat's title: the message's first non-empty line, * clamped to ~80 characters on a word boundary. */ private static string chat_title(string message) { string first = ""; foreach (string line in message.strip().split("\n")) { if (line.strip().length > 0) { first = line.strip(); break; } } if (first.length <= 80) { return first; } string cut = first.substring(0, 80); int last_space = cut.last_index_of(" "); if (last_space > 40) { cut = cut.substring(0, last_space); } return cut.strip() + "…"; } } /** * Appends the composer's message to a chat and kicks the next * background advisor turn, answering with the rebuilt `chat` slot * (the user's own row appears at once; the turn's appends stream * in through the pulse refresh). The chat id is sealed. */ public class SendAdvisorMessageAction : TypedStatumAction { private ChatService chat_service = inject(); private AdvisorService advisor_service = inject(); private UserService users = inject(); protected override Type private_type { get { return typeof(AdvisorChatActionPrivate); } } public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var ctx = request_private; if (ctx == null) { return directives().navigate("/chats"); } var me = SpryAuth.current(request); if (me == null) { return directives().navigate("/chats"); } int64 viewer_id = ((!)me).user_id; int64 chat_id = ((!)ctx).chat_id; var chat = yield chat_service.get_chat(chat_id, viewer_id); if (chat == null) { return directives().notify("error", "That chat no longer exists") .navigate("/chats"); } string message = Forms.field_raw(request, "message"); // The question card answers through this action too (the // page's one sealed send DTO serves the composer, the // option buttons and the "Other" box): an answer resumes // a PARKED question, a message starts a normal turn. string answer = Forms.field_raw(request, "answer"); if (answer.strip().length > 0) { bool accepted = false; try { accepted = yield advisor_service.answer_question(chat_id, viewer_id, answer); } catch (Error e) { return directives().notify("error", @"Could not record the answer: $(e.message)"); } if (!accepted) { return directives().notify("error", "That question has already been answered"); } var answered = yield chat_service.get_chat(chat_id, viewer_id); if (answered == null) { return directives().navigate("/chats"); } var resumed = yield ChatState.build_from(state_service, chat_service, advisor_service, action_registry, viewer_id, answered); return directives().set(resumed); } if (message.strip().length == 0) { var empty = yield ChatState.build_from(state_service, chat_service, advisor_service, action_registry, viewer_id, chat); return directives().set(empty); } yield chat_service.record_user_message(chat_id, message.strip()); advisor_service.run_turn.begin(chat_id, viewer_id); var updated = yield chat_service.get_chat(chat_id, viewer_id); var state = yield ChatState.build_from(state_service, chat_service, advisor_service, action_registry, viewer_id, updated); return directives().set(state); } } /** * Records the human's answer to the chat's pending question (the * question card's option buttons and "Other" box submit here via * the JSON transport) and resumes the turn; answers with the * rebuilt `chat` slot. The chat id is sealed. */ public class AnswerAdvisorQuestionAction : TypedStatumAction { private ChatService chat_service = inject(); private AdvisorService advisor_service = inject(); private UserService users = inject(); protected override Type private_type { get { return typeof(AdvisorChatActionPrivate); } } public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var ctx = request_private; if (ctx == null) { return directives().navigate("/chats"); } var me = SpryAuth.current(request); if (me == null) { return directives().navigate("/chats"); } int64 viewer_id = ((!)me).user_id; int64 chat_id = ((!)ctx).chat_id; var chat = yield chat_service.get_chat(chat_id, viewer_id); if (chat == null) { return directives().notify("error", "That chat no longer exists") .navigate("/chats"); } string answer = Forms.field_raw(request, "answer"); // answer_question records the reply and runs the next // turn in its own scope; the answer arrives as this // action's reply. bool accepted = false; try { accepted = yield advisor_service.answer_question(chat_id, viewer_id, answer); } catch (Error e) { return directives().notify("error", @"Could not record the answer: $(e.message)"); } if (!accepted) { return directives().notify("error", "That question has already been answered").navigate( @"/chats/$(chat_id)"); } var updated = yield chat_service.get_chat(chat_id, viewer_id); if (updated == null) { return directives().navigate("/chats"); } var state = yield ChatState.build_from(state_service, chat_service, advisor_service, action_registry, viewer_id, updated); return directives().set(state); } } /** * Deletes a chat (its messages and suggestions cascade) and sends * the browser to /chats. The chat id is sealed; deleting someone * else's id is a silent no-op (chats are private per user). */ public class DeleteAdvisorChatAction : TypedStatumAction { private ChatService chat_service = inject(); private UserService users = inject(); protected override Type private_type { get { return typeof(AdvisorChatActionPrivate); } } public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var ctx = request_private; if (ctx == null) { return directives().navigate("/chats"); } var me = SpryAuth.current(request); if (me == null) { return directives().navigate("/chats"); } try { yield chat_service.delete_chat(((!)ctx).chat_id, ((!)me).user_id); return directives().notify("success", "Chat deleted") .navigate("/chats"); } catch (Error e) { return directives().notify("error", @"Could not delete the chat: $(e.message)") .navigate("/chats"); } } } /** * The chat page's pulse-driven refresh (goal 27): rebuilds the * `chat` slot from current rows so a running turn's appends — * tool calls, the assistant reply, a parked question, a * suggestion — stream in without a reload. The chat id is SEALED * (author_private, exactly like send/delete): statum.act carries * GET parameters as a QUERY STRING, which request.form never * sees, so an id read from a form field would parse 0 on every * pulse bump and bounce the page to /chats — the redirect loop. * Navigates only when the chat genuinely no longer exists (or * never was the viewer's). */ public class RefreshAdvisorChatAction : TypedStatumAction { private ChatService chat_service = inject(); private AdvisorService advisor_service = inject(); private UserService users = inject(); protected override Type private_type { get { return typeof(AdvisorChatActionPrivate); } } public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var ctx = request_private; if (ctx == null) { return directives().navigate("/chats"); } var me = SpryAuth.current(request); if (me == null) { return directives().navigate("/chats"); } var chat = yield chat_service.get_chat(((!)ctx).chat_id, ((!)me).user_id); if (chat == null) { // Deleted mid-turn (or never the viewer's): nothing to // refresh — the page's own navigation takes over. return directives().navigate("/chats"); } var state = yield ChatState.build_from(state_service, chat_service, advisor_service, action_registry, ((!)me).user_id, chat); return directives().set(state); } } /** * Decides a pending suggestion (goal 27): 'approve' creates the * real row — a DRAFT task (creator human, so the creator-only * enqueue rule holds by construction) or a DRAFT goal (never * started: goals are unqueued until the human starts them) — * 'approve_enqueue' additionally queues the created task for a * saddle, and 'decline' dismisses the row. Every path appends a * short system message to the chat and kicks a background advisor * turn so the advisor can acknowledge the outcome; the action * answers with the refreshed `chat` slot. The suggestion and chat * ids are sealed; only the chat's owner may decide, and only * while the row is still pending. */ public class DecideAdvisorSuggestionAction : TypedStatumAction { private ChatService chat_service = inject(); private AdvisorService advisor_service = inject(); private TaskService task_service = inject(); private GoalService goal_service = inject(); private InvercargillSql.Orm.OrmSession db = inject(); private UserService users = inject(); protected override Type private_type { get { return typeof(AdvisorSuggestionActionPrivate); } } public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var ctx = request_private; var me = SpryAuth.current(request); if (ctx == null || me == null) { return directives().navigate("/chats"); } int64 viewer_id = ((!)me).user_id; int64 suggestion_id = ((!)ctx).suggestion_id; int64 chat_id = ((!)ctx).chat_id; string decision = Forms.field(request, "decision"); if (decision != "approve" && decision != "approve_enqueue" && decision != "decline") { return directives().notify("error", "Unknown suggestion decision").navigate("/chats"); } // Owner-checked and status-checked: the row must belong to // this user's chat and still be pending (double clicks and // stale cards land here, not in two tasks). var suggestion = yield db.query() .where(expr("id == $0 && chat_id == $1 && user_id == $2", elem(suggestion_id), elem(chat_id), elem(viewer_id))) .first_async(); if (suggestion == null) { return directives().notify("error", "That suggestion does not exist").navigate("/chats"); } if (((!)suggestion).status != "pending") { return notify_chat(chat_id, "That suggestion has already been decided"); } var chat = yield chat_service.get_chat(chat_id, viewer_id); if (chat == null) { return directives().notify("error", "That chat no longer exists").navigate("/chats"); } string notice; try { if (decision == "decline") { ((!)suggestion).status = "declined"; ((!)suggestion).decided_at = new DateTime.now_local(); yield db.update_async((!)suggestion); notice = "Declined."; } else if (((!)suggestion).kind == "task") { var payload = parse_payload(((!)suggestion).payload); string title = member_string(payload, "title"); string mandate = member_string(payload, "mandate"); string mode = member_string(payload, "mode"); int64 project_id = member_int(payload, "project_id"); int64 priority = member_int(payload, "priority"); if (title.length == 0 || mandate.length == 0) { return notify_chat(chat_id, "That suggestion's payload is incomplete"); } if (mode.length == 0) { mode = "implement"; } if (!is_valid_mode(mode)) { return notify_chat(chat_id, @"Suggested mode '$(mode)' is not a valid work mode"); } if (project_id < 0) { return notify_chat(chat_id, @"Invalid project id $(project_id)"); } if (project_id > 0 && (yield project_exists(project_id)) == null) { return notify_chat(chat_id, @"No project exists with id $(project_id)"); } // Creator is HUMAN — the creator-only enqueue rule // holds by construction and the approve_enqueue // path below enqueues as the human too. var task = yield task_service.create_task( project_id, title, mandate, priority, null, null, STATUS_DRAFT, TASK_KIND_TASK, mode, CREATOR_HUMAN, 0, 0); ((!)suggestion).task_id = task.id; ((!)suggestion).status = "approved"; ((!)suggestion).decided_at = new DateTime.now_local(); yield db.update_async((!)suggestion); if (decision == "approve_enqueue") { yield task_service.enqueue_task(task.id, CREATOR_HUMAN); notice = @"Approved & enqueued — task #$(task.id) queued"; } else { notice = @"Approved — task #$(task.id) created (draft)"; } } else { var payload = parse_payload(((!)suggestion).payload); string title = member_string(payload, "title"); string brief = member_string(payload, "brief"); string branch_name = member_string(payload, "branch_name"); int64 project_id = member_int(payload, "project_id"); if (title.length == 0 || brief.length == 0) { return notify_chat(chat_id, "That suggestion's payload is incomplete"); } if (branch_name.length == 0) { branch_name = @"goal/advisor-$(suggestion_id)"; } var project = yield project_exists(project_id); if (project_id <= 0 || project == null) { return notify_chat(chat_id, @"The goal needs an existing project (got $(project_id))"); } // Draft only: the advisor NEVER starts a goal — // the human starts it from the goal page. var goal = yield goal_service.create_goal( project_id, title, brief, branch_name, null, 3, CREATOR_HUMAN); ((!)suggestion).goal_id = goal.id; ((!)suggestion).status = "approved"; ((!)suggestion).decided_at = new DateTime.now_local(); yield db.update_async((!)suggestion); notice = @"Approved — goal #$(goal.id) created in draft (not started)"; } } catch (Error e) { return notify_chat(chat_id, @"Could not apply the decision: $(e.message)"); } // The outcome lands in the chat and wakes the advisor so // it can acknowledge (and, on completion, follow up). try { yield chat_service.record_system_message(chat_id, notice); } catch (Error e) { warning("[ClankerSaddle] Advisor decision notice failed: %s", e.message); } advisor_service.run_turn.begin(chat_id, viewer_id); var updated = yield chat_service.get_chat(chat_id, viewer_id); if (updated == null) { return directives().navigate("/chats"); } var state = yield ChatState.build_from(state_service, chat_service, advisor_service, action_registry, viewer_id, updated); return directives().set(state); } /** An error notify that stays on the chat page. */ private DirectiveBuilder notify_chat(int64 chat_id, string message) { return directives().notify("error", message) .navigate(@"/chats/$(chat_id)"); } /** The project row for `id`, or null (0 = standalone tasks). */ private async ClankerSaddle.Data.Project? project_exists(int64 id) throws GLib.Error { if (id <= 0) { return null; } return yield db.query() .where(expr("id == $0", elem(id))) .first_async(); } private static Json.Object parse_payload(string payload) { var parser = new Json.Parser(); try { parser.load_from_data(payload); } catch (Error e) { return new Json.Object(); } var root = parser.get_root(); if (root == null || root.get_node_type() != Json.NodeType.OBJECT) { return new Json.Object(); } return ((!)root).get_object(); } private static string member_string(Json.Object body, string member) { if (!body.has_member(member)) { return ""; } var node = body.get_member(member); if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null) { return (!)node.get_string(); } return ""; } private static int64 member_int(Json.Object body, string member) { if (!body.has_member(member)) { return 0; } var node = body.get_member(member); return node.get_node_type() == Json.NodeType.VALUE ? node.get_int() : 0; } } }