Prechádzať zdrojové kódy

Add the project Understanding section and its Refresh action (goal 24)

The project page now shows the project's understanding above its
notes: the current markdown document with a commit/date stamp, a
Refresh button, and the older revisions below it newest first. An
absent understanding shows the empty-state hint instead.

Refresh is a new sealed project action (RefreshUnderstandingAction).
It refuses with an error notify while a non-terminal task whose title
starts with "Understanding refresh:" exists for the project;
otherwise it creates a QUEUED investigate task (kind task, creator
human, priority 5) and stamps a system comment on it. The task's
brief embeds, in order: the explorer mandate (explore the structure,
produce a markdown document covering the layout and anything unusual;
when a previous document exists, review the git history since the old
commit - git log old..HEAD plus diffs - and produce an UPDATED
document rather than starting from scratch; finish by calling
save_understanding exactly once with the new document and the HEAD
hash from git rev-parse HEAD, then finish done; investigate mode -
no file edits), the previous commit hash (or "none - first refresh"),
the previous document fenced in a markdown block, and every project
note of the project oldest first.

Supporting changes: the project slot gained understanding_current (a
single row object, always present, empty body meaning none yet) and
understanding_history (older rows, current excluded) via a new
StateJson.element helper; the action is registered in main.vala next
to the other project actions.

The understanding bundle grew the UI coverage: the page slot shape,
the first refresh (queued/investigate/task, brief contents, system
comment), the live-refusal, the against-a-document brief (old hash,
fenced old document, git-history direction, project notes), and the
completion cycle - the fake saddle runs the task to done, the
understanding lands via the callback API, the page shows the new
current document, and a fresh refresh is allowed again with the new
hash embedded. One pre-existing fixture bug was fixed along the way:
the giant-revision row inserted its created_at as text, which the
DateTime materialiser rejects once the page actually reads the table
(every other bundle inserts raw integer epochs).
saddle-Clanker-Saddle 4 týždňov pred
rodič
commit
407ba40a03

+ 13 - 0
src/Json.vala

@@ -150,6 +150,19 @@ namespace ClankerSaddle {
             return new JsonElement.from_elements(arr);
         }
 
+        /**
+         * A single row model as a slot public-data value (the object
+         * twin of {@link list}): `dict[\"current\"] = StateJson.element(row);`
+         * — null-safe, a null row maps to a JSON null.
+         */
+        public static JsonElement? element(Object? item) throws GLib.Error {
+            if (item == null) {
+                return null;
+            }
+            var props = GObjectMapping.to_properties((!)item);
+            return new JsonElement.from_properties(props);
+        }
+
         /**
          * A JSON array of plain strings (e.g. a block's multiple-choice
          * options).

+ 110 - 0
src/actions/ProjectActions.vala

@@ -23,6 +23,13 @@ namespace ClankerSaddle {
         public int64 note_id { get; set; }
     }
 
+    /**
+     * The title prefix of every understanding refresh task (goal 24):
+     * {@link RefreshUnderstandingAction} creates them and refuses a
+     * second refresh while one is still live.
+     */
+    public const string UNDERSTANDING_REFRESH_PREFIX = "Understanding refresh:";
+
     /**
      * Creates a project from the projects form (name and git URL
      * required); default_branch falls back to 'main'.
@@ -191,4 +198,107 @@ namespace ClankerSaddle {
             }
         }
     }
+
+    /**
+     * Spins up the project's understanding refresh task (goal 24): a
+     * QUEUED investigate task whose agent explores the project, walks
+     * the git history since the current document's commit when one
+     * exists, and saves an UPDATED markdown understanding via the
+     * `save_understanding` tool. Refuses while another refresh task
+     * for the project is still live — one explorer at a time.
+     */
+    public class RefreshUnderstandingAction : TypedStatumAction<ProjectActionPrivate> {
+
+        private ProjectService projects = inject<ProjectService>();
+        private UnderstandingService understandings = inject<UnderstandingService>();
+        private TaskService tasks = inject<TaskService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(ProjectActionPrivate); } }
+
+        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;
+            int64 project_id = ctx != null ? ((!)ctx).project_id : Forms.integer(request, "project_id", 0);
+            if (project_id <= 0) {
+                return directives().navigate("/projects");
+            }
+            var project = yield projects.get_project(project_id);
+            if (project == null) {
+                return directives().notify("error", "That project no longer exists").navigate("/projects");
+            }
+
+            // One live explorer at a time: a non-terminal task whose
+            // title carries the refresh prefix blocks a new refresh.
+            foreach (var task in yield tasks.list_tasks(null, project_id)) {
+                if (!task.title.has_prefix(UNDERSTANDING_REFRESH_PREFIX)) {
+                    continue;
+                }
+                if (task.status == STATUS_DONE || task.status == STATUS_FAILED) {
+                    continue;
+                }
+                return directives().notify("error",
+                        @"An understanding refresh (#$(task.id)) is already running for this project")
+                    .navigate(@"/projects/$(project_id)");
+            }
+
+            var current = yield understandings.current_understanding(project_id);
+            string old_hash = current != null ? ((!)current).commit_hash : "";
+            string old_body = current != null ? ((!)current).body : "";
+
+            var brief = new StringBuilder();
+            brief.append("MANDATE — you are the project's understanding explorer.\n\n");
+            brief.append("Explore the project's structure and produce a markdown understanding ");
+            brief.append("document covering the project layout and anything unusual that would ");
+            brief.append("be useful to know for anyone working on it.\n");
+            if (old_body.length > 0) {
+                brief.append("\nA previous understanding document exists. Do NOT start from ");
+                brief.append("scratch: review the git history since the old commit hash ");
+                brief.append("(git log <old>..HEAD, plus diffs of the significant changes) and ");
+                brief.append("produce an UPDATED document that keeps what still holds and ");
+                brief.append("rewrites what changed.\n");
+            }
+            brief.append("\nThe finishing move: call the save_understanding tool exactly ONCE ");
+            brief.append("with the new document and the git HEAD hash of the explored checkout ");
+            brief.append("(run `git rev-parse HEAD`), then finish(status=done) with a short ");
+            brief.append("summary. You are in investigate mode — do not edit any files.\n");
+            brief.append("\nPrevious commit hash: ");
+            brief.append(old_hash.length > 0 ? old_hash : "none — first refresh");
+            if (old_body.length > 0) {
+                brief.append("\n\nPrevious document:\n\n```markdown\n");
+                brief.append(old_body);
+                brief.append("\n```\n");
+            }
+            var notes = yield projects.list_notes(project_id);
+            brief.append("\nProject notes from other agents:\n");
+            if (notes.length == 0) {
+                brief.append("(none)\n");
+            } else {
+                foreach (var note in notes) {
+                    brief.append("\n---\n");
+                    brief.append(note.body);
+                    brief.append("\n");
+                }
+            }
+
+            try {
+                var task = yield tasks.create_task(project_id,
+                    @"$(UNDERSTANDING_REFRESH_PREFIX) $(((!)project).name)",
+                    brief.str, 5, null, null, STATUS_QUEUED,
+                    TASK_KIND_TASK, "investigate", CREATOR_HUMAN);
+                yield tasks.add_system_comment(task.id,
+                    @"Understanding refresh queued against commit $(old_hash.length > 0 ? old_hash : "(none)")");
+                return directives().notify("success",
+                        @"Refresh queued — the explorer task is #$(task.id)")
+                    .navigate(@"/projects/$(project_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not queue the refresh: $(e.message)")
+                    .navigate(@"/projects/$(project_id)");
+            }
+        }
+    }
 }

+ 56 - 1
src/entrypoints/ProjectsEntrypoint.vala

@@ -139,6 +139,22 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? delete { get; set; }
     }
 
+    /**
+     * One understanding revision on the project page. The current row
+     * is rendered above; the history rows below it are the OLDER
+     * revisions (newest first). An absent current understanding is an
+     * all-empty row (bodies are never empty when stored), which the
+     * page's empty state keys off.
+     */
+    public class UnderstandingRowView : Object {
+        public int64 id { get; set; }
+        public string body { get; set; default = ""; }
+        public string commit_hash { get; set; default = ""; }
+        public bool has_commit { get; set; }
+        public int64 source_task_id { get; set; }
+        public string created_display { get; set; default = ""; }
+    }
+
     /** Public data of the `project` slot. */
     public class ProjectDetailPublic : Object {
         public bool exists { get; set; }
@@ -152,6 +168,7 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? add_link { get; set; }
         public Statum.Model.ActionDto? attach_rule { get; set; }
         public Statum.Model.ActionDto? apply_stack { get; set; }
+        public Statum.Model.ActionDto? refresh_understanding { get; set; }
     }
 
     /**
@@ -164,6 +181,7 @@ namespace ClankerSaddle {
 
         private ProjectService project_service = Inversion.inject<ProjectService>();
         private TaskService task_service = Inversion.inject<TaskService>();
+        private UnderstandingService understanding_service = Inversion.inject<UnderstandingService>();
         private RuleService rule_service = Inversion.inject<RuleService>();
         private StackService stack_service = Inversion.inject<StackService>();
         private UserService users = Inversion.inject<UserService>();
@@ -193,7 +211,8 @@ namespace ClankerSaddle {
                 update = action_registry.author_private<UpdateProjectAction, ProjectActionPrivate>(sealed),
                 add_link = action_registry.author_private<AddProjectLinkAction, ProjectActionPrivate>(sealed),
                 attach_rule = action_registry.author_private<AttachProjectRuleAction, ProjectActionPrivate>(sealed),
-                apply_stack = action_registry.author_private<ApplyStackToProjectAction, ProjectActionPrivate>(sealed)
+                apply_stack = action_registry.author_private<ApplyStackToProjectAction, ProjectActionPrivate>(sealed),
+                refresh_understanding = action_registry.author_private<RefreshUnderstandingAction, ProjectActionPrivate>(sealed)
             };
 
             var links = new Vector<ProjectLinkView>();
@@ -292,12 +311,48 @@ namespace ClankerSaddle {
                 });
             }
 
+            // The understanding ledger read: the current (newest)
+            // revision renders above; the history below it lists the
+            // OLDER revisions newest-first, so the current row is not
+            // shown twice. No understanding yet is an empty row — the
+            // page's empty state keys off it.
+            var understanding_rows = new Vector<UnderstandingRowView>();
+            bool first_understanding = true;
+            foreach (var revision in yield understanding_service.history(project_id)) {
+                if (first_understanding) {
+                    first_understanding = false;
+                    continue;
+                }
+                understanding_rows.add(new UnderstandingRowView() {
+                    id = revision.id,
+                    body = revision.body,
+                    commit_hash = revision.commit_hash,
+                    has_commit = revision.commit_hash.length > 0,
+                    source_task_id = revision.task_id,
+                    created_display = format_stamp(revision.created_at)
+                });
+            }
+            var current_understanding_row = new UnderstandingRowView();
+            var current_revision = yield understanding_service.current_understanding(project_id);
+            if (current_revision != null) {
+                current_understanding_row = new UnderstandingRowView() {
+                    id = ((!)current_revision).id,
+                    body = ((!)current_revision).body,
+                    commit_hash = ((!)current_revision).commit_hash,
+                    has_commit = ((!)current_revision).commit_hash.length > 0,
+                    source_task_id = ((!)current_revision).task_id,
+                    created_display = format_stamp(((!)current_revision).created_at)
+                };
+            }
+
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
             props["links"] = StateJson.list<ProjectLinkView>(links.to_immutable_buffer());
             props["linkable"] = StateJson.list<ProjectRowView>(linkable.to_immutable_buffer());
             props["tasks"] = StateJson.list<ProjectTaskRowView>(task_rows.to_immutable_buffer());
             props["rules"] = StateJson.list<ProjectRuleRowView>(rule_rows.to_immutable_buffer());
             props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
+            props["understanding_current"] = StateJson.element(current_understanding_row);
+            props["understanding_history"] = StateJson.list<UnderstandingRowView>(understanding_rows.to_immutable_buffer());
             var stack_options = new Vector<StackOptionView>();
             foreach (var stack in yield stack_service.list_stacks()) {
                 stack_options.add(new StackOptionView() {

+ 1 - 0
src/main.vala

@@ -170,6 +170,7 @@ int main(string[] args) {
         statum.action<AttachTaskRuleAction>();
         statum.action<DetachTaskRuleAction>();
         statum.action<DeleteProjectNoteAction>();
+        statum.action<RefreshUnderstandingAction>();
         statum.action<CreateStackAction>();
         statum.action<UpdateStackAction>();
         statum.action<DeleteStackAction>();

+ 27 - 0
src/pages/project_detail.html

@@ -93,6 +93,33 @@
             </form>
             <p class="muted" stm-if="project.rule_options.length == 0">Create rules on the <a href="/rules">rules page</a>.</p>
 
+            <h3>Understanding</h3>
+            <p class="muted">A markdown map of the project's structure and anything
+                unusual, written by an agent against a commit and refreshed on demand.</p>
+            <div class="card" stm-if="project.understanding_current.body.length > 0">
+                <p class="muted"><span stm-if="project.understanding_current.has_commit">commit
+                        <code stm-text="project.understanding_current.commit_hash">hash</code> · </span>refreshed
+                    <span stm-text="project.understanding_current.created_display">when</span><span
+                        stm-if="project.understanding_current.source_task_id > 0"> · from task
+                        <a stm-attribute.href="'/tasks/' + project.understanding_current.source_task_id"
+                           stm-text="'#' + project.understanding_current.source_task_id">task</a></span></p>
+                <div class="md" stm-markdown="project.understanding_current.body">body</div>
+            </div>
+            <p class="muted" stm-if="project.understanding_current.body.length == 0">No understanding yet —
+                press Refresh to have an agent explore the project.</p>
+            <form class="inline" stm-action="project.refresh_understanding">
+                <button type="submit" stm-confirm="Spin up an agent task to refresh the understanding?">Refresh</button>
+            </form>
+            <div stm-for-revision-in="project.understanding_history" stm-key="revision.id" class="card">
+                <p class="muted"><span stm-if="revision.has_commit">commit
+                        <code stm-text="revision.commit_hash">hash</code> · </span>refreshed
+                    <span stm-text="revision.created_display">when</span><span
+                        stm-if="revision.source_task_id > 0"> · from task
+                        <a stm-attribute.href="'/tasks/' + revision.source_task_id"
+                           stm-text="'#' + revision.source_task_id">task</a></span></p>
+                <div class="md" stm-markdown="revision.body">body</div>
+            </div>
+
             <h3>Project notes</h3>
             <p class="muted">Written by agents via <code>saddle note</code>; curated here — delete what
                 stops being useful. There is no manual create.</p>

+ 147 - 0
test/bundles/understanding.py

@@ -113,6 +113,10 @@ def run(ctx):
           "the payload's understanding is the project's CURRENT revision")
 
     # A giant revision is head-clamped at 16000 chars (the payload cap).
+    # created_at goes in as a raw epoch INTEGER like every other bundle
+    # fixture: the ORM materialises DateTime columns from int64 (a text
+    # value in a DATETIME column fails the element conversion, which
+    # the project page read surfaces as a 500).
     conn = db()
     conn.execute("insert into project_understandings"
                  " (project_id, body, commit_hash, task_id, created_at)"
@@ -149,3 +153,146 @@ def run(ctx):
           "dispatcher pushes the no-understanding task")
     check(fake.start_payloads[-1].get("understanding") == "",
           "a project without an understanding ships an empty string")
+
+    print("== understanding: the project page section ==")
+    # 'plain' has no understanding: empty current row, empty history,
+    # but the refresh action is authored.
+    plain_state = slot_public(auth, "/projects/%d" % plain_id, "project")
+    check(plain_state.get("refresh_understanding") is not None,
+          "the project slot authors the refresh action")
+    check((plain_state.get("understanding_current") or {}).get("body", "x") == "",
+          "a project without an understanding shows an empty current row")
+    check(plain_state.get("understanding_history") == [],
+          "a project without an understanding shows no history rows")
+    # 'demo' (project 1) has three revisions by now (two API inserts +
+    # the giant direct row): current is the giant, history is the two
+    # API revisions newest first.
+    demo_state = slot_public(auth, "/projects/1", "project")
+    current_row = demo_state.get("understanding_current") or {}
+    check(current_row.get("body", "").startswith("giant!"),
+          "the page's current understanding is the newest revision")
+    check(current_row.get("commit_hash") == "big0000"
+          and current_row.get("has_commit") is True,
+          "the current row carries the commit hash stamp flag")
+    history = demo_state.get("understanding_history") or []
+    check([h["commit_hash"] for h in history] == ["def2000", "abc1000"],
+          "the history lists the older revisions newest first (current not repeated)")
+    check(history[0]["body"].endswith("Second revision.")
+          and history[1]["body"].endswith("First revision."),
+          "history rows keep their markdown bodies")
+
+    print("== understanding: Refresh spins up the special task ==")
+    # The 'plain' project: no notes, no understanding — a first refresh.
+    result = act(auth, plain_state["refresh_understanding"], {})
+    check(any(d.get("type") == "notify" and d.get("kind") == "success" for d in result),
+          "the first refresh notifies success")
+    rows = db_rows("select id, title, status, mode, kind, description from tasks"
+                   " where title like 'Understanding refresh: plain'")
+    check(len(rows) == 1, "refresh created exactly one task")
+    refresh_id = rows[0][0]
+    check(rows[0][2] == "queued" and rows[0][3] == "investigate"
+          and rows[0][4] == "task",
+          "the refresh task is queued, investigate mode, kind task (%s)" % (rows[0][2:5],))
+    brief = rows[0][5] or ""
+    check("none — first refresh" in brief,
+          "a first refresh embeds 'none — first refresh' as the previous hash")
+    check("Previous document:" not in brief,
+          "a first refresh carries no previous document")
+    check("save_understanding" in brief and "git rev-parse HEAD" in brief,
+          "the brief mandates the save_understanding finishing move")
+    check("do not edit any files" in brief,
+          "the brief states the investigate-mode no-edit constraint")
+    # A system comment marks the refresh on the task thread.
+    conn = db()
+    marker = conn.execute(
+        "select count(*) from task_comments where task_id = ? and author = 'system'"
+        " and body like 'Understanding refresh queued%'",
+        (refresh_id,)).fetchone()[0]
+    conn.close()
+    check(marker == 1, "the refresh task carries the system comment marker")
+
+    print("== understanding: a live refresh refuses a second ==")
+    plain_state = slot_public(auth, "/projects/%d" % plain_id, "project")
+    refused = act(auth, plain_state["refresh_understanding"], {})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              and "already running" in (d.get("message") or "") for d in refused),
+          "a second refresh while the task is live refuses")
+    rows = db_rows("select count(*) from tasks"
+                   " where title like 'Understanding refresh: plain'")
+    check(rows[0][0] == 1, "the refused refresh created no second task")
+
+    print("== understanding: refresh against an existing document ==")
+    # The demo project has a current understanding + project notes:
+    # the brief must embed the old hash, the old document and the notes.
+    conn = db()
+    conn.execute("insert into project_notes (project_id, source_task_id, body,"
+                 " created_at) values (1, 0, ?, strftime('%s','now'))",
+                 ("Demo note one: the test harness lives in test/.",))
+    conn.execute("insert into project_notes (project_id, source_task_id, body,"
+                 " created_at) values (1, 0, ?, strftime('%s','now') + 1)",
+                 ("Demo note two: meson builds everything.",))
+    conn.commit()
+    conn.close()
+    demo_state = slot_public(auth, "/projects/1", "project")
+    result = act(auth, demo_state["refresh_understanding"], {})
+    check(any(d.get("type") == "notify" and d.get("kind") == "success" for d in result),
+          "refreshing a project with an understanding succeeds")
+    row = db_rows("select id, description from tasks"
+                  " where title like 'Understanding refresh: demo'")[0]
+    demo_brief = row[1]
+    check("Previous commit hash: big0000" in demo_brief,
+          "the brief embeds the previous document's commit hash")
+    check("```markdown" in demo_brief and "giant!" in demo_brief,
+          "the brief embeds the previous document in a markdown fence")
+    check("git log" in demo_brief and "..HEAD" in demo_brief,
+          "the brief directs the git-history review since the old hash")
+    check("Project notes from other agents:" in demo_brief
+          and "Demo note one: the test harness lives in test/." in demo_brief
+          and "Demo note two: meson builds everything." in demo_brief,
+          "the brief embeds every project note")
+
+    print("== understanding: the refresh task runs, saves, refresh works again ==")
+    # The fake saddle picks the queued plain refresh up (it asks when
+    # idle), runs it to done, and the understanding lands via the API.
+    check(wait_for(lambda: any(
+        int(p.get("task", {}).get("id") or 0) == refresh_id
+        for p in fake.start_payloads) or False,
+        timeout=30, what="refresh task assignment") is not None,
+          "the dispatcher assigns the refresh task to the saddle")
+    fake.push_results("done", summary="explored and saved the understanding")
+    check(wait_task_done(auth, refresh_id, timeout=30) is not None,
+          "the refresh task completes")
+    status, body = saddle_api("POST", "/api/saddle/understanding", fake_token,
+                              {"project": "plain",
+                               "body": "# Plain understanding\n\nFirst map.",
+                               "commit_hash": "fff3000",
+                               "task_id": refresh_id})
+    check(status == 200 and body.get("ok") is True,
+          "the completed refresh saves its understanding via the API")
+    plain_state = slot_public(auth, "/projects/%d" % plain_id, "project")
+    current_row = plain_state.get("understanding_current") or {}
+    check(current_row.get("body") == "# Plain understanding\n\nFirst map."
+          and current_row.get("commit_hash") == "fff3000",
+          "the page shows the new current understanding after the save")
+    check(plain_state.get("understanding_history") == [],
+          "the saved revision is current, not duplicated into history")
+    # Terminal refresh task: a new refresh is allowed again.
+    result = act(auth, plain_state["refresh_understanding"], {})
+    check(any(d.get("type") == "notify" and d.get("kind") == "success" for d in result),
+          "after the refresh task completes a new refresh is allowed")
+    rows = db_rows("select count(*) from tasks"
+                   " where title like 'Understanding refresh: plain'")
+    check(rows[0][0] == 2, "the second refresh created its task")
+    # The new live task blocks again, and the new current doc is now
+    # the previous one in the fresh brief.
+    plain_state = slot_public(auth, "/projects/%d" % plain_id, "project")
+    refused = act(auth, plain_state["refresh_understanding"], {})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              and "already running" in (d.get("message") or "") for d in refused),
+          "the new live refresh blocks a third")
+    row = db_rows("select description from tasks"
+                  " where title like 'Understanding refresh: plain'"
+                  " order by id desc")[0]
+    check("Previous commit hash: fff3000" in (row[0] or "")
+          and "First map." in (row[0] or ""),
+          "the refreshed brief embeds the new previous hash and document")