Przeglądaj źródła

Add the project Understanding storage, callback API and assignment injection

Goal 24 task 2 (web side): a project's Understanding is a markdown
document summarising its structure and unusual corners, stamped with
the commit hash it was written against, kept as immutable history
rows whose newest revision is the project's current document.

- M0004 creates project_understandings (id, project_id, body,
  commit_hash default '', task_id default 0, created_at), same
  up()-before-transaction idempotence contract as M0002/M0003.
- ProjectUnderstanding entity; task_id maps as a plain integer
  (NOT NULL column, so 0 binds 0 rather than SQL NULL).
- UnderstandingService: current_understanding (newest row),
  history (newest first), get, insert (appends a revision),
  delete (human-side curation only).
- SaddleUnderstandingEndpoint on the per-saddle bearer token:
  POST /api/saddle/understanding appends a revision and answers
  {ok, understanding_id}; GET /api/saddle/understanding?project=
  reads the current revision back (or null), feeding the daemon's
  read_linked_understanding tool.
- build_assignment_payload ships the primary project's current
  understanding body head-capped at 16000 chars
  (UNDERSTANDING_MAX_CHARS), present but empty when none.
- test/bundles/understanding.py: migration shape on a fresh DB,
  401/400 paths, history ordering, GET newest/null, the payload
  member with the cap and the empty cases (24 checks; notes 34/34
  and dispatch 97/97 re-run green as regressions).
saddle-Clanker-Saddle 1 miesiąc temu
rodzic
commit
2a9f9cc504

+ 2 - 0
meson.build

@@ -185,6 +185,7 @@ app_sources = files(
     'src/Data/Migrations/M0001_Initial.vala',
     'src/Data/Migrations/M0002_BatchImprovements.vala',
     'src/Data/Migrations/M0003_EventIndexes.vala',
+    'src/Data/Migrations/M0004_ProjectUnderstandings.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/GoalService.vala',
@@ -198,6 +199,7 @@ app_sources = files(
     'src/Services/RuleService.vala',
     'src/Services/StackService.vala',
     'src/Services/NoteService.vala',
+    'src/Services/UnderstandingService.vala',
     'src/Services/CapabilityService.vala',
     'src/Services/TimelineService.vala',
     'src/Services/PushService.vala',

+ 119 - 0
src/Api/SaddleApi.vala

@@ -414,6 +414,125 @@ namespace ClankerSaddle.Api {
         }
     }
 
+    /**
+     * The understanding callback pair (goal 24):
+     *
+     * `POST /api/saddle/understanding` — what an understanding task's
+     * daemon POSTs after exploring a project (wire contract),
+     * authenticated by the per-saddle bearer token like
+     * {@link SaddleNoteEndpoint}:
+     *
+     * ```json
+     * {"task_id": 7, "project": "ZL4GQ", "body": "…",
+     *  "commit_hash": "abc1234"}
+     * ```
+     *
+     * `task_id` and `commit_hash` are optional (stored as the row's
+     * source task and stamp); the project is resolved by name and
+     * must exist, the body must be non-empty. The insert APPENDS a
+     * history row — the project's current understanding is its
+     * newest. Response `{"ok": true, "understanding_id": N}`.
+     *
+     * `GET /api/saddle/understanding?project=NAME` — reads the
+     * project's CURRENT understanding back:
+     * `{"ok": true, "understanding": {"body": "…", "commit_hash":
+     * "…", "created": "…"} }`, or `"understanding": null` when the
+     * project has none (the daemon's read_linked_understanding tool
+     * consumes this for linked projects).
+     */
+    public class SaddleUnderstandingEndpoint : Object, Endpoint {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private ProjectService projects = inject<ProjectService>();
+        private UnderstandingService understandings = inject<UnderstandingService>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            if ((yield resolve_bearer(http_context, saddles)) == null) {
+                return SaddleWire.json_error(401, "Missing or invalid saddle token");
+            }
+            if (http_context.request.method == Method.GET) {
+                return yield handle_read(http_context);
+            }
+            return yield handle_write(http_context);
+        }
+
+        /** The GET branch: the project's current understanding, or null. */
+        private async HttpResult handle_read(HttpContext http_context) throws Error {
+            string project_name = "";
+            if (!http_context.request.query_params.try_get_any("project", out project_name)
+                    || project_name == null) {
+                project_name = "";
+            }
+            project_name = project_name.strip();
+            if (project_name.length == 0) {
+                return SaddleWire.json_error(400,
+                    "A project query parameter is required");
+            }
+            try {
+                var project = yield projects.get_project_by_name(project_name);
+                if (project == null) {
+                    return SaddleWire.json_error(400,
+                        @"No project exists named '$(project_name)'");
+                }
+                var current = yield understandings.current_understanding(
+                    ((!)project).id);
+                var body = new Json.Object();
+                if (current != null) {
+                    var element = new Json.Object();
+                    element.set_string_member("body", ((!)current).body);
+                    element.set_string_member("commit_hash", ((!)current).commit_hash);
+                    if (((!)current).created_at != null) {
+                        element.set_string_member("created",
+                            (((!)current).created_at).to_timezone(new TimeZone.utc())
+                                .format("%Y-%m-%dT%H:%M:%SZ"));
+                    }
+                    body.set_object_member("understanding", element);
+                } else {
+                    body.set_null_member("understanding");
+                }
+                return SaddleWire.json_ok(body);
+            } catch (Error e) {
+                return SaddleWire.json_error(400, e.message);
+            }
+        }
+
+        /** The POST branch: append a history row. */
+        private async HttpResult handle_write(HttpContext http_context) throws Error {
+            Json.Object body;
+            try {
+                body = yield SaddleWire.read_json_body(http_context);
+            } catch (Error e) {
+                return SaddleWire.json_error(400, e.message);
+            }
+
+            string project_name = EntryJson.get_string_or(body, "project", "");
+            string understanding_body = EntryJson.get_string_or(body, "body", "");
+            string commit_hash = EntryJson.get_string_or(body, "commit_hash", "");
+            int64 task_id = body.has_member("task_id")
+                && body.get_member("task_id").get_value_type() == typeof(int64)
+                ? body.get_int_member("task_id") : 0;
+            if (understanding_body.strip().length == 0) {
+                return SaddleWire.json_error(400, "An understanding body is required");
+            }
+
+            try {
+                var project = yield projects.get_project_by_name(project_name);
+                if (project == null) {
+                    return SaddleWire.json_error(400,
+                        @"No project exists named '$(project_name)'");
+                }
+                var row = yield understandings.insert(((!)project).id,
+                    understanding_body, commit_hash, task_id);
+                var response = new Json.Object();
+                response.set_int_member("understanding_id", row.id);
+                return SaddleWire.json_ok(response);
+            } catch (Error e) {
+                return SaddleWire.json_error(400, e.message);
+            }
+        }
+    }
+
     /**
      * The plan-12 task CLI callbacks (`task create/delete/enqueue`):
      * the goal agent's synchronous task operations against its own

+ 32 - 0
src/Data/Entities.vala

@@ -419,6 +419,38 @@ namespace ClankerSaddle.Data {
         }
     }
 
+    /**
+     * One historical revision of a project's Understanding (goal 24):
+     * a markdown document summarising the project's structure and
+     * anything unusual worth knowing, stamped with the commit hash it
+     * was written against. Rows are immutable — a refresh INSERTS a
+     * new revision — and the project's "current" understanding is its
+     * newest row (see {@link ClankerSaddle.Services.UnderstandingService}).
+     * Stored in the `project_understandings` table.
+     */
+    public class ProjectUnderstanding : Object {
+
+        public int64 id { get; set; }
+        public int64 project_id { get; set; }
+        public string body { get; set; default = ""; }
+        /** The commit the document was written against ("" when unknown). */
+        public string commit_hash { get; set; default = ""; }
+        /** The understanding task that wrote the row (0 = human/unknown). */
+        public int64 task_id { get; set; default = 0; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<ProjectUnderstanding> cfg) {
+            cfg.table("project_understandings")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("project_id", o => o.project_id, (o, v) => o.project_id = v)
+                .column<string>("body", o => o.body, (o, v) => o.body = v)
+                .column<string>("commit_hash", o => o.commit_hash, (o, v) => o.commit_hash = v)
+                .column<int64?>("task_id", o => (int64?) o.task_id,
+                    (o, v) => o.task_id = v ?? 0)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
     /**
      * A dashboard scratchpad note (goal 8 task 3): a personal
      * todo/idea jotted down before a batch of changes is ready to be

+ 103 - 0
src/Data/Migrations/M0004_ProjectUnderstandings.vala

@@ -0,0 +1,103 @@
+using InvercargillSql.Migrations;
+using Sqlite;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Goal 24 (project knowledge): the project Understanding ledger.
+     *
+     * A project's Understanding is a markdown document summarising the
+     * structure of the project and anything unusual worth knowing
+     * before working in it, stamped with the commit hash it was
+     * written against. The Refresh flow spins up a special
+     * understanding task; its agent POSTs the fresh document to
+     * `/api/saddle/understanding`, which INSERTS a row here — rows
+     * are immutable history, and a project's "current" understanding
+     * is simply its newest row (the refresh agent also receives the
+     * previous commit hash and document so it can diff the Git
+     * history between the two).
+     *
+     * The current understanding rides every assignment payload for
+     * the project (capped), and the daemon renders it into the system
+     * prompt next to the project notes.
+     *
+     * IDEMPOTENCE: same contract as M0002/M0003 — the runner invokes
+     * up() BEFORE opening the apply transaction (the builder only
+     * queues), so up() introspects sqlite_master through a separate
+     * read-only handle and queues the table only when the schema
+     * still lacks it. A fresh database queues it; a database that
+     * already has it (defensive: this build's DB handed back to an
+     * older serial, then forward again) queues nothing and records
+     * as a no-op.
+     */
+    public class M0004_ProjectUnderstandings : Migration {
+
+        public override string name { get { return "Project understanding history"; } }
+        public override uint64 serial { get { return 4; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        // The database this migration is upgrading — same resolution
+        // as M0002/M0003 (the runner never hands up() a connection,
+        // so the schema is read through a separate read-only handle
+        // on the same SPRY_DB_PATH main.vala connects).
+        private string db_path() {
+            var path = Environment.get_variable("SPRY_DB_PATH") ?? "";
+            if (path.strip().length == 0) {
+                return "./clanker-saddle.sqlite";
+            }
+            return path;
+        }
+
+        private bool table_exists(string name) {
+            Database db;
+            int rc = Database.open_v2(db_path(), out db, Sqlite.OPEN_READONLY);
+            if (rc != Sqlite.OK) {
+                // Unopenable (locked, or missing — a database the
+                // runner is about to create): treat as absent and let
+                // the queued operation fail loudly rather than
+                // silently skipping an upgrade.
+                return false;
+            }
+            Statement statement;
+            rc = db.prepare_v2(
+                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
+                -1, out statement);
+            if (rc != Sqlite.OK) {
+                return false;
+            }
+            statement.bind_text(1, name, -1);
+            if (statement.step() != Sqlite.ROW) {
+                return false;
+            }
+            return statement.column_int64(0) > 0;
+        }
+
+        public override void up(MigrationBuilder b) {
+            if (table_exists("project_understandings")) {
+                return;
+            }
+            b.create_table("project_understandings", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("project_id")
+                    .not_null()
+                    .references("projects", "id")
+                    .indexed();
+                t.column<string>("body")
+                    .not_null();
+                t.column<string>("commit_hash")
+                    .not_null()
+                    .default_value<string>("");
+                t.column<int64?>("task_id")
+                    .not_null()
+                    .default_value<int64?>(0);
+                t.column<DateTime?>("created_at");
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            b.drop_table("project_understandings");
+        }
+    }
+}

+ 31 - 0
src/Services/SaddleService.vala

@@ -37,6 +37,15 @@ namespace ClankerSaddle.Services {
     public const int64 PROJECT_NOTES_MAX_ENTRIES = 30;
     public const int64 PROJECT_NOTES_MAX_CHARS = 20000;
 
+    /**
+     * Cap of the `understanding` member in the /start payload: the
+     * primary project's current understanding document (goal 24).
+     * The whole document rides the payload and renders into the
+     * system prompt next to the project notes, so an over-grown
+     * revision is head-clamped rather than bloating every prompt.
+     */
+    public const int64 UNDERSTANDING_MAX_CHARS = 16000;
+
     /**
      * 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
@@ -145,6 +154,7 @@ namespace ClankerSaddle.Services {
         private TaskService task_service = inject<TaskService>();
         private GoalService goal_service = inject<GoalService>();
         private EventService event_service = inject<EventService>();
+        private UnderstandingService understandings = inject<UnderstandingService>();
         private Astralis.WebConfig config = inject<Astralis.WebConfig>();
 
         /**
@@ -1118,6 +1128,27 @@ namespace ClankerSaddle.Services {
             }
             payload.end_array();
 
+            // The wire contract's `understanding` addition (goal 24):
+            // the PRIMARY project's current understanding document —
+            // a markdown summary of the project's structure and its
+            // unusual corners, stamped with the commit it was written
+            // against. Present but empty when the project has none;
+            // head-clamped at UNDERSTANDING_MAX_CHARS.
+            string understanding_body = "";
+            if (project != null) {
+                var current_understanding = yield understandings
+                    .current_understanding(((!)project).id);
+                if (current_understanding != null) {
+                    understanding_body = ((!)current_understanding).body;
+                    if (understanding_body.length > UNDERSTANDING_MAX_CHARS) {
+                        understanding_body = understanding_body.substring(0,
+                            (int) UNDERSTANDING_MAX_CHARS);
+                    }
+                }
+            }
+            payload.set_member_name("understanding");
+            payload.add_string_value(understanding_body);
+
             // The wire contract's `callback` addition: this app's
             // externally reachable base URL plus the bearer token the
             // daemon presents when posting back — its own per-saddle

+ 93 - 0
src/Services/UnderstandingService.vala

@@ -0,0 +1,93 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * The project Understanding ledger (goal 24): a project's
+     * Understanding is a markdown document summarising its structure
+     * and anything unusual worth knowing before working in it,
+     * stamped with the commit hash it was written against. The
+     * Refresh flow spins up a special understanding task whose agent
+     * explores the project and POSTs the fresh document; each POST
+     * INSERTS an immutable history row, so the "current"
+     * understanding is simply the newest revision. There is no
+     * agent-side delete — curation of the history stays human.
+     */
+    public class UnderstandingService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+
+        /**
+         * The project's CURRENT understanding — its newest revision —
+         * or null when the project has none yet. The current body is
+         * what rides the assignment payload and the page renders.
+         */
+        public async ProjectUnderstanding? current_understanding(int64 project_id) throws Error {
+            return yield db.query<ProjectUnderstanding>()
+                .where(expr("project_id == $0", elem<int64?>(project_id)))
+                .order_by_desc(expr("id"))
+                .first_async();
+        }
+
+        /**
+         * The project's full revision history, newest first — the
+         * page's history list (each row a past Refresh).
+         */
+        public async ImmutableLot<ProjectUnderstanding> history(int64 project_id) throws Error {
+            return yield db.query<ProjectUnderstanding>()
+                .where(expr("project_id == $0", elem<int64?>(project_id)))
+                .order_by_desc(expr("id"))
+                .materialise_async();
+        }
+
+        /**
+         * Loads the revision identified by `id`, or null.
+         */
+        public async ProjectUnderstanding? get_understanding(int64 id) throws Error {
+            return yield db.query<ProjectUnderstanding>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+        }
+
+        /**
+         * Appends a new revision. The body is trimmed and must be
+         * non-empty; `commit_hash` may be "" (unknown) and `task_id`
+         * 0 (no understanding task sourced it). Inserting makes the
+         * row the project's current understanding by recency.
+         */
+        public async ProjectUnderstanding insert(int64 project_id, string body,
+                string commit_hash, int64 task_id) throws Error {
+            string trimmed = body.strip();
+            if (trimmed.length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 99,
+                    "An understanding body is required");
+            }
+            var row = new ProjectUnderstanding() {
+                project_id = project_id,
+                body = trimmed,
+                commit_hash = commit_hash.strip(),
+                task_id = task_id,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<ProjectUnderstanding>(row);
+            return row;
+        }
+
+        /**
+         * Deletes the revision (idempotent: an absent row is fine).
+         * Only the web UI calls this — the agent-side API has no
+         * delete, so history curation stays human.
+         */
+        public async void delete(int64 id) throws Error {
+            var row = yield get_understanding(id);
+            if (row != null) {
+                yield db.delete_async<ProjectUnderstanding>((!)row);
+            }
+        }
+    }
+}

+ 7 - 0
src/main.vala

@@ -45,6 +45,7 @@ int main(string[] args) {
         db.add_migration<ClankerSaddle.Data.Migrations.M0001_Initial>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0002_BatchImprovements>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0003_EventIndexes>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0004_ProjectUnderstandings>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -57,6 +58,7 @@ int main(string[] args) {
         db.add_entity<RuleProject>(RuleProject.entity_mapping);
         db.add_entity<TaskRule>(TaskRule.entity_mapping);
         db.add_entity<ProjectNote>(ProjectNote.entity_mapping);
+        db.add_entity<ProjectUnderstanding>(ProjectUnderstanding.entity_mapping);
         db.add_entity<Capability>(Capability.entity_mapping);
         db.add_entity<SaddleCapability>(SaddleCapability.entity_mapping);
         db.add_entity<TaskCapability>(TaskCapability.entity_mapping);
@@ -75,6 +77,7 @@ int main(string[] args) {
         application.add_scoped<GoalService>();
         application.add_scoped<StackService>();
         application.add_scoped<NoteService>();
+        application.add_scoped<UnderstandingService>();
         application.add_scoped<SaddleService>();
         application.add_scoped<LifecycleService>();
         application.add_scoped<RuleService>();
@@ -212,6 +215,10 @@ int main(string[] args) {
             new EndpointRoute("/api/saddle/results", Method.POST));
         application.add_endpoint<SaddleNoteEndpoint>(
             new EndpointRoute("/api/saddle/note", Method.POST));
+        application.add_endpoint<SaddleUnderstandingEndpoint>(
+            new EndpointRoute("/api/saddle/understanding", Method.POST));
+        application.add_endpoint<SaddleUnderstandingEndpoint>(
+            new EndpointRoute("/api/saddle/understanding", Method.GET));
         application.add_endpoint<SaddleTaskCreateEndpoint>(
             new EndpointRoute("/api/saddle/task/create", Method.POST));
         application.add_endpoint<SaddleTaskDeleteEndpoint>(

+ 151 - 0
test/bundles/understanding.py

@@ -0,0 +1,151 @@
+"""The understanding bundle (goal 24 task 2): the project Understanding
+ledger — the /api/saddle/understanding insert/read callback pair, the
+immutable history rows, and the current document riding the assignment
+payload."""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def run(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    def db_rows(sql, params=()):
+        conn = db()
+        try:
+            return conn.execute(sql, params).fetchall()
+        finally:
+            conn.close()
+
+    print("== understanding: the migration ==")
+    # A fresh database: M0001..M0004 ran at startup, so the table
+    # exists with the goal-24 shape.
+    tables = [r[0] for r in db_rows(
+        "select name from sqlite_master where type = 'table'")]
+    check("project_understandings" in tables,
+          "M0004 creates the project_understandings table on a fresh DB")
+    columns = [r[1] for r in db_rows("pragma table_info(project_understandings)")]
+    check(columns == ["id", "project_id", "body", "commit_hash", "task_id",
+                      "created_at"],
+          "the table carries the goal-24 columns (%s)" % columns)
+
+    print("== understanding: POST auth + validation ==")
+    status, _ = saddle_api("POST", "/api/saddle/understanding", None,
+                           {"project": "demo", "body": "x"})
+    check(status == 401, "POST rejects a missing token")
+    status, _ = saddle_api("POST", "/api/saddle/understanding", "saddle-bogus",
+                           {"project": "demo", "body": "x"})
+    check(status == 401, "POST rejects a wrong token")
+    status, body = saddle_api("POST", "/api/saddle/understanding", fake_token,
+                              {"project": "no-such-project", "body": "x"})
+    check(status == 400 and body.get("ok") is False,
+          "POST rejects an unknown project")
+    status, body = saddle_api("POST", "/api/saddle/understanding", fake_token,
+                              {"project": "demo", "body": "   "})
+    check(status == 400 and body.get("ok") is False,
+          "POST rejects an empty body")
+
+    print("== understanding: inserts append history ==")
+    status, body = saddle_api("POST", "/api/saddle/understanding", fake_token,
+                              {"project": "demo",
+                               "body": "# Demo understanding\n\nFirst revision.",
+                               "commit_hash": "abc1000", "task_id": 11})
+    check(status == 200 and body.get("ok") is True
+          and int(body.get("understanding_id", 0)) > 0,
+          "POST inserts and returns the understanding id")
+    first_id = int(body["understanding_id"])
+    status, body = saddle_api("POST", "/api/saddle/understanding", fake_token,
+                              {"project": "demo",
+                               "body": "# Demo understanding\n\nSecond revision.",
+                               "commit_hash": "def2000"})
+    check(status == 200 and int(body["understanding_id"]) > first_id,
+          "a refresh appends a LATER history row")
+    second_id = int(body["understanding_id"])
+    rows = db_rows("select id, body, commit_hash, task_id"
+                   " from project_understandings order by id")
+    check([r[0] for r in rows] == [first_id, second_id],
+          "history rows are immutable appends in insert order")
+    check(rows[0][2] == "abc1000" and rows[0][3] == 11
+          and rows[1][2] == "def2000" and rows[1][3] == 0,
+          "rows keep the commit hash and the optional task id")
+
+    print("== understanding: GET reads the current revision ==")
+    status, _ = saddle_api("GET", "/api/saddle/understanding?project=demo", None)
+    check(status == 401, "GET rejects a missing token")
+    status, body = saddle_api(
+        "GET", "/api/saddle/understanding?project=no-such-project", fake_token)
+    check(status == 400, "GET rejects an unknown project")
+    status, body = saddle_api("GET", "/api/saddle/understanding", fake_token)
+    check(status == 400, "GET requires the project query parameter")
+    status, body = saddle_api("GET", "/api/saddle/understanding?project=demo",
+                              fake_token)
+    check(status == 200 and body.get("ok") is True,
+          "GET answers with the current understanding")
+    current = body.get("understanding") or {}
+    check(current.get("body") == "# Demo understanding\n\nSecond revision.",
+          "GET returns the NEWEST revision (%r)" % current.get("body", "")[:40])
+    check(current.get("commit_hash") == "def2000"
+          and (current.get("created") or "").endswith("Z"),
+          "GET carries the commit hash and an ISO-8601 stamp")
+    # A project without any understanding answers null, not an error.
+    state = slot_public(auth, "/projects", "projects")
+    act(auth, state["create"], {"name": "plain",
+                                "git_url": "https://example.com/plain.git"})
+    status, body = saddle_api("GET", "/api/saddle/understanding?project=plain",
+                              fake_token)
+    check(status == 200 and body.get("ok") is True
+          and body.get("understanding") is None,
+          "GET answers understanding null for a project with none")
+
+    print("== understanding: rides the assignment payload ==")
+    payload_count = len(fake.start_payloads)
+    create_queued(auth, {
+        "project_id": "1",
+        "title": "Understanding dispatch",
+        "description": "The current understanding rides the /start payload.",
+        "priority": "2",
+    })
+    check(wait_for(lambda: len(fake.start_payloads) > payload_count or False,
+                   timeout=30, what="understanding /start push") is not None,
+          "dispatcher pushes the understanding task")
+    payload = fake.start_payloads[-1]
+    check("understanding" in payload,
+          "the assignment payload carries an understanding member")
+    check(payload.get("understanding") == "# Demo understanding\n\nSecond revision.",
+          "the payload's understanding is the project's CURRENT revision")
+
+    # A giant revision is head-clamped at 16000 chars (the payload cap).
+    conn = db()
+    conn.execute("insert into project_understandings"
+                 " (project_id, body, commit_hash, task_id, created_at)"
+                 " values (1, ?, 'big0000', 0, strftime('%s','now'))",
+                 ("giant!" + "y" * 25000,))
+    conn.commit()
+    conn.close()
+    payload_count = len(fake.start_payloads)
+    create_queued(auth, {
+        "project_id": "1",
+        "title": "Understanding cap dispatch",
+        "priority": "2",
+    })
+    check(wait_for(lambda: len(fake.start_payloads) > payload_count or False,
+                   timeout=30, what="cap /start push") is not None,
+          "dispatcher pushes the cap task")
+    shipped = fake.start_payloads[-1].get("understanding", "")
+    check(len(shipped) == 16000 and shipped.startswith("giant!"),
+          "the payload caps the understanding at 16000 chars, head kept (%d)"
+          % len(shipped))
+
+    # No understanding yet: the member is present but empty.
+    plain_id = int([p["id"] for p in
+                    slot_public(auth, "/projects", "projects")["projects"]
+                    if p["name"] == "plain"][0])
+    payload_count = len(fake.start_payloads)
+    create_queued(auth, {
+        "project_id": str(plain_id),
+        "title": "No-understanding dispatch",
+        "priority": "2",
+    })
+    check(wait_for(lambda: len(fake.start_payloads) > payload_count or False,
+                   timeout=30, what="empty /start push") is not None,
+          "dispatcher pushes the no-understanding task")
+    check(fake.start_payloads[-1].get("understanding") == "",
+          "a project without an understanding ships an empty string")

+ 1 - 1
test/harness.py

@@ -1317,7 +1317,7 @@ def start_bundle(name):
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals",
                  "replay", "goalui", "forcewake", "users", "stacks", "notes",
-                 "resurrect", "origins", "parity")
+                 "resurrect", "origins", "parity", "understanding")
 
 
 def run_bundle(name):