소스 검색

Per-saddle prompt notes: column, admin editor, assignment carriage

Goal 8 T5 (B), the web half: the saddle admin's standing message about
THIS machine (e.g. 'You have root and may install development tooling via
DNF'), served verbatim inside every assignment's system prompt.

- saddles.prompt_note TEXT NULL in the M0001 flattened migration plus
  the Saddle entity column and mapping.
- SaddleService.update_prompt_note (admin action target): trims, clamps
  to PROMPT_NOTE_MAX_CHARS (2000) and stores null when empty.
- UpdateSaddlePromptNoteAction (admin-only, follows the
  UpdateSaddleIdentityAction pattern), registered in main.vala and
  authored per row on the /saddles page.
- A Prompt note details block on the saddle card: a textarea bound to
  the row's note with a Save button, admin-only.
- build_assignment_payload emits a top-level prompt_note on EVERY
  assignment: the stored note, empty string when null (the daemon parses
  it leniently and renders it under a 'Message from this saddle's admin'
  heading), clamped to 2000 chars so a fat note can never bloat every
  prompt and provider cache.

Verified the task/goal block UI already renders QUESTION blocks with
option radio cards plus an Other textarea (block_answer.html partial)
- the ask_question tool's output needs no web change.

Bundle: lifecycle gains ten assertions (empty-before-set, edit saves,
payload carriage verbatim, clear empties, fat note clamped to 2000).
Full suite 1087 passed / 0 failed (baseline 1077).
saddle-Clanker-Saddle 1 개월 전
부모
커밋
71c595763b

+ 8 - 0
src/Data/Entities.vala

@@ -275,6 +275,13 @@ namespace ClankerSaddle.Data {
         public string status { get; set; default = "idle"; }
         public string? git_name { get; set; default = null; }
         public string? git_email { get; set; default = null; }
+        /**
+         * The saddle admin's standing prompt note (goal 8 task 5):
+         * a message about THIS machine (e.g. "You have root and may
+         * install development tooling via DNF") served verbatim inside
+         * every assignment's system prompt; null when unset.
+         */
+        public string? prompt_note { get; set; default = null; }
         public string? ssh_public_key { get; set; default = null; }
         public string? token { get; set; default = null; }
 
@@ -286,6 +293,7 @@ namespace ClankerSaddle.Data {
                 .column<string>("status", o => o.status, (o, v) => o.status = v)
                 .column<string>("git_name", o => o.git_name, (o, v) => o.git_name = v)
                 .column<string>("git_email", o => o.git_email, (o, v) => o.git_email = v)
+                .column<string>("prompt_note", o => o.prompt_note, (o, v) => o.prompt_note = v)
                 .column<string>("ssh_public_key", o => o.ssh_public_key, (o, v) => o.ssh_public_key = v)
                 .column<string>("token", o => o.token, (o, v) => o.token = v);
         }

+ 1 - 0
src/Data/Migrations/M0001_Initial.vala

@@ -231,6 +231,7 @@ namespace ClankerSaddle.Data.Migrations {
                     .default_value<string>("idle");
                 t.column<string>("git_name");
                 t.column<string>("git_email");
+                t.column<string>("prompt_note");
                 t.column<string>("ssh_public_key");
                 t.column<string>("token");
             });

+ 42 - 0
src/Services/SaddleService.vala

@@ -37,6 +37,13 @@ namespace ClankerSaddle.Services {
     public const int64 PROJECT_NOTES_MAX_ENTRIES = 30;
     public const int64 PROJECT_NOTES_MAX_CHARS = 20000;
 
+    /**
+     * Upper bound of a saddle's prompt_note served to the daemon (goal 8
+     * task 5): the note rides EVERY assignment payload, so a fat note
+     * would bloat every system prompt and cache.
+     */
+    public const int64 PROMPT_NOTE_MAX_CHARS = 2000;
+
     /** A block answer awaiting collection by one saddle's next poll. */
     public class PendingAnswer : Object {
         public int64 block_id { get; set; }
@@ -435,6 +442,29 @@ namespace ClankerSaddle.Services {
             yield pulse.bump(0, "saddle identity saved");
         }
 
+        /**
+         * Saves the saddle admin's standing prompt note (goal 8 task 5):
+         * the message about THIS machine rendered into every
+         * assignment's system prompt. Empty clears it (the daemon then
+         * omits the section entirely); the value is clamped to
+         * {@link PROMPT_NOTE_MAX_CHARS} so a fat note can never bloat
+         * every prompt.
+         */
+        public async void update_prompt_note(int64 id, string? prompt_note) throws Error {
+            var saddle = yield get_saddle(id);
+            if (saddle == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 17,
+                    @"No saddle exists with id $(id)");
+            }
+            var trimmed = prompt_note != null ? prompt_note.strip() : "";
+            if (trimmed.length > PROMPT_NOTE_MAX_CHARS) {
+                trimmed = trimmed.substring(0, (int) PROMPT_NOTE_MAX_CHARS);
+            }
+            ((!)saddle).prompt_note = trimmed.length > 0 ? trimmed : null;
+            yield db.update_async<Saddle>((!)saddle);
+            yield pulse.bump(0, "saddle prompt note saved");
+        }
+
         /**
          * Persists the daemon's reported `ssh_public_key` onto the saddle
          * row (the plan-9 contract: the key travels on /status, empty
@@ -973,6 +1003,18 @@ namespace ClankerSaddle.Services {
                 payload.end_object();
             }
 
+            // The wire contract's `prompt_note` addition (goal 8 task
+            // 5): the saddle admin's standing message about THIS
+            // machine, served on EVERY assignment (the daemon renders
+            // it verbatim under an admin heading). Empty string when
+            // unset, clamped so a fat note can never bloat prompts.
+            var prompt_note_text = saddle.prompt_note ?? "";
+            if (prompt_note_text.length > PROMPT_NOTE_MAX_CHARS) {
+                prompt_note_text = prompt_note_text.substring(0, (int) PROMPT_NOTE_MAX_CHARS);
+            }
+            payload.set_member_name("prompt_note");
+            payload.add_string_value(prompt_note_text);
+
             // The wire contract's `capabilities` context (plan 10
             // phase 3): the allocated saddle's own tags plus every tag
             // name known across the fleet (the operator-managed

+ 37 - 0
src/actions/SaddleActions.vala

@@ -120,6 +120,43 @@ namespace ClankerSaddle {
         }
     }
 
+    /**
+     * Saves a saddle's admin prompt note (goal 8 task 5): the standing
+     * message about THIS machine served inside every assignment's
+     * system prompt (e.g. "You have root and may install development
+     * tooling via DNF"). Empty clears it — the daemon then omits the
+     * section entirely.
+     */
+    public class UpdateSaddlePromptNoteAction : TypedStatumAction<SaddleActionPrivate> {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(SaddleActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/saddles");
+            }
+
+            try {
+                yield saddles.update_prompt_note(((!)ctx).saddle_id,
+                    Forms.optional(request, "prompt_note"));
+                return directives().notify("success", "Prompt note saved").navigate("/saddles");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not save the prompt note: $(e.message)")
+                    .navigate("/saddles");
+            }
+        }
+    }
+
     public class RefreshSaddleStatusAction : TypedStatumAction<SaddleActionPrivate> {
 
         private SaddleService saddles = inject<SaddleService>();

+ 5 - 0
src/entrypoints/SaddlesEntrypoint.vala

@@ -42,6 +42,9 @@ namespace ClankerSaddle {
         public string git_email { get; set; default = ""; }
         public string ssh_public_key { get; set; default = ""; }
         public Statum.Model.ActionDto? update_identity { get; set; }
+        /** The admin's standing prompt note about THIS machine (goal 8 task 5). */
+        public string prompt_note { get; set; default = ""; }
+        public Statum.Model.ActionDto? update_prompt_note { get; set; }
         public int64 current_task_id { get; set; }
         public string current_task_title { get; set; default = ""; }
         /** Set when the current task is a goal agent's run: link the goal instead. */
@@ -182,6 +185,8 @@ namespace ClankerSaddle {
                     git_email = saddle.git_email ?? "",
                     ssh_public_key = saddle.ssh_public_key ?? "",
                     update_identity = action_registry.author_private<UpdateSaddleIdentityAction, SaddleActionPrivate>(sealed),
+                    prompt_note = saddle.prompt_note ?? "",
+                    update_prompt_note = action_registry.author_private<UpdateSaddlePromptNoteAction, SaddleActionPrivate>(sealed),
                     current_task_id = work != null ? ((!)work).task_id : 0,
                     allocated_display = work != null
                         ? format_stamp(((!)work).started_at) : "",

+ 1 - 0
src/main.vala

@@ -156,6 +156,7 @@ int main(string[] args) {
         statum.action<UnregisterSaddleAction>();
         statum.action<RefreshSaddleStatusAction>();
         statum.action<UpdateSaddleIdentityAction>();
+        statum.action<UpdateSaddlePromptNoteAction>();
         statum.action<CreateRuleAction>();
         statum.action<UpdateRuleAction>();
         statum.action<DeleteRuleAction>();

+ 11 - 0
src/pages/saddles.html

@@ -66,6 +66,17 @@
                             <span class="muted">Empty fields fall back to the daemon's default identity.</span>
                         </form>
                     </details>
+                    <details stm-if="saddles.is_admin">
+                        <summary>Prompt note</summary>
+                        <form stm-action="saddle.update_prompt_note" class="inline">
+                            <label for="prompt-note">Served inside every assignment's system prompt</label>
+                            <textarea id="prompt-note" name="prompt_note" rows="3"
+                                      stm-attribute.placeholder="'e.g. You have root and may install development tooling via DNF'"
+                                      stm-text="saddle.prompt_note"></textarea>
+                            <button type="submit">Save</button>
+                            <span class="muted">A standing message about THIS machine for its agents; empty removes it (capped at 2000 chars).</span>
+                        </form>
+                    </details>
                     <p stm-if="saddles.is_admin && saddle.capabilities.length > 0">Capabilities:
                         <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
                             <span class="badge"><span stm-text="capability.name">tag</span>

+ 56 - 0
test/bundles/lifecycle.py

@@ -537,6 +537,62 @@ def run(ctx):
     check(wait_task_done(auth, identity_task, timeout=60) is not None,
           "identity task completes")
 
+    print("== goal 8 task 5: per-saddle prompt note ==")
+    # The admin edits the note on the /saddles card; it persists on the
+    # row and rides EVERY later assignment as `prompt_note`.
+    fake_card = saddle_card(auth, "fake-saddle")
+    check(fake_card.get("prompt_note", "") == "",
+          "no prompt note before the admin sets one")
+    check(all(p.get("prompt_note") == "" for p in fake.start_payloads),
+          "unset note carries an empty string (never a null)")
+    act(auth, fake_card["update_prompt_note"],
+        {"prompt_note": "You have root and may install development tooling via DNF."})
+    fake_card = saddle_card(auth, "fake-saddle")
+    check(fake_card.get("prompt_note") == "You have root and may install development tooling via DNF.",
+          "inline edit saves the prompt note")
+    note_task = create_queued(auth, {
+        "project_id": "1",
+        "title": "Prompt note task",
+        "priority": "2",
+    })
+    check(wait_for(lambda: any(int(p.get("task", {}).get("id") or 0) == note_task
+                               for p in fake.start_payloads) or False,
+                   timeout=60, what="prompt-note task dispatched") is not None,
+          "prompt-note task dispatches")
+    note_payload = [p for p in fake.start_payloads
+                    if int(p.get("task", {}).get("id") or 0) == note_task][-1]
+    check(note_payload.get("prompt_note") == "You have root and may install development tooling via DNF.",
+          "the assignment carries the saddle's prompt_note verbatim")
+    check(wait_task_done(auth, note_task, timeout=60) is not None,
+          "prompt-note task completes")
+    # Clearing the note empties the member on later assignments.
+    fake_card = saddle_card(auth, "fake-saddle")
+    act(auth, fake_card["update_prompt_note"], {"prompt_note": ""})
+    check(saddle_card(auth, "fake-saddle").get("prompt_note", "") == "",
+          "clearing the prompt note empties the row")
+    # A fat note is clamped: the row keeps the full text but the wire
+    # serves at most PROMPT_NOTE_MAX_CHARS.
+    fat = "N" * 5000
+    fake_card = saddle_card(auth, "fake-saddle")
+    act(auth, fake_card["update_prompt_note"], {"prompt_note": fat})
+    clamp_task = create_queued(auth, {
+        "project_id": "1",
+        "title": "Clamp note task",
+        "priority": "2",
+    })
+    check(wait_for(lambda: any(int(p.get("task", {}).get("id") or 0) == clamp_task
+                               for p in fake.start_payloads) or False,
+                   timeout=60, what="clamp-note task dispatched") is not None,
+          "clamp-note task dispatches")
+    clamp_payload = [p for p in fake.start_payloads
+                     if int(p.get("task", {}).get("id") or 0) == clamp_task][-1]
+    check(len(clamp_payload.get("prompt_note") or "") == 2000,
+          "a fat prompt_note is clamped to 2000 chars on the wire")
+    check(wait_task_done(auth, clamp_task, timeout=60) is not None,
+          "clamp-note task completes")
+    fake_card = saddle_card(auth, "fake-saddle")
+    act(auth, fake_card["update_prompt_note"], {"prompt_note": ""})
+
     print("== phase 2: blocked push -> block row + pulse ==")
     # The browser flow on each pulse: statum.act(task.refresh) — the
     # sealed GET action must rehydrate the task slot with the new