| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560 |
- 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<ChatService>();
- private AdvisorService advisor_service = inject<AdvisorService>();
- private UserService users = inject<UserService>();
- 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<AdvisorChatActionPrivate> {
- private ChatService chat_service = inject<ChatService>();
- private AdvisorService advisor_service = inject<AdvisorService>();
- private UserService users = inject<UserService>();
- 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<AdvisorChatActionPrivate> {
- private ChatService chat_service = inject<ChatService>();
- private AdvisorService advisor_service = inject<AdvisorService>();
- private UserService users = inject<UserService>();
- 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<AdvisorChatActionPrivate> {
- private ChatService chat_service = inject<ChatService>();
- private UserService users = inject<UserService>();
- 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<AdvisorChatActionPrivate> {
- private ChatService chat_service = inject<ChatService>();
- private AdvisorService advisor_service = inject<AdvisorService>();
- private UserService users = inject<UserService>();
- 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<AdvisorSuggestionActionPrivate> {
- private ChatService chat_service = inject<ChatService>();
- private AdvisorService advisor_service = inject<AdvisorService>();
- private TaskService task_service = inject<TaskService>();
- private GoalService goal_service = inject<GoalService>();
- private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
- private UserService users = inject<UserService>();
- 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<AdvisorSuggestion>()
- .where(expr("id == $0 && chat_id == $1 && user_id == $2",
- elem<int64?>(suggestion_id), elem<int64?>(chat_id),
- elem<int64?>(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<AdvisorSuggestion>((!)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<AdvisorSuggestion>((!)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<AdvisorSuggestion>((!)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<ClankerSaddle.Data.Project>()
- .where(expr("id == $0", elem<int64?>(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;
- }
- }
- }
|