|
@@ -8,6 +8,13 @@ using ClankerSaddle.Data;
|
|
|
|
|
|
|
|
namespace ClankerSaddle.Services {
|
|
namespace ClankerSaddle.Services {
|
|
|
|
|
|
|
|
|
|
+ /** Prefix of the dedicated saddle-callback API token (feature 6). */
|
|
|
|
|
+ public const string CALLBACK_TOKEN_PREFIX = "saddle-callback-";
|
|
|
|
|
+
|
|
|
|
|
+ /** Caps of the `project_notes` array in the /start payload. */
|
|
|
|
|
+ public const int64 PROJECT_NOTES_MAX_ENTRIES = 30;
|
|
|
|
|
+ public const int64 PROJECT_NOTES_MAX_CHARS = 20000;
|
|
|
|
|
+
|
|
|
/** A block answer awaiting collection by one saddle's next poll. */
|
|
/** A block answer awaiting collection by one saddle's next poll. */
|
|
|
public class PendingAnswer : Object {
|
|
public class PendingAnswer : Object {
|
|
|
public int64 block_id { get; set; }
|
|
public int64 block_id { get; set; }
|
|
@@ -68,6 +75,7 @@ namespace ClankerSaddle.Services {
|
|
|
private SaddlePollState poll_state = inject<SaddlePollState>();
|
|
private SaddlePollState poll_state = inject<SaddlePollState>();
|
|
|
private StatePulse pulse = inject<StatePulse>();
|
|
private StatePulse pulse = inject<StatePulse>();
|
|
|
private RuleService rules = inject<RuleService>();
|
|
private RuleService rules = inject<RuleService>();
|
|
|
|
|
+ private Astralis.WebConfig config = inject<Astralis.WebConfig>();
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* Registers a saddle container and generates its API token.
|
|
* Registers a saddle container and generates its API token.
|
|
@@ -135,6 +143,73 @@ namespace ClankerSaddle.Services {
|
|
|
return latest != null ? ((!)latest).token : null;
|
|
return latest != null ? ((!)latest).token : null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * The dedicated callback token authorising daemon calls to
|
|
|
|
|
+ * `POST /api/saddle/note` (`saddle note`): minted on first use
|
|
|
|
|
+ * and at startup, kept stable afterwards. It lives in
|
|
|
|
|
+ * `api_tokens` with `saddle_id` 0, so it matches no registered
|
|
|
|
|
+ * saddle and cannot be used on the per-saddle endpoints.
|
|
|
|
|
+ */
|
|
|
|
|
+ public async string ensure_callback_token() throws Error {
|
|
|
|
|
+ var existing = yield db.query<ApiToken>()
|
|
|
|
|
+ .where(expr("saddle_id == $0", elem<int64?>(0)))
|
|
|
|
|
+ .order_by(expr("id"))
|
|
|
|
|
+ .materialise_async();
|
|
|
|
|
+ foreach (var row in existing) {
|
|
|
|
|
+ if (row.token.has_prefix(CALLBACK_TOKEN_PREFIX)) {
|
|
|
|
|
+ return row.token;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ var token = new ApiToken() {
|
|
|
|
|
+ saddle_id = 0,
|
|
|
|
|
+ token = @"$(CALLBACK_TOKEN_PREFIX)$(Uuid.string_random().replace("-", ""))",
|
|
|
|
|
+ created_at = new DateTime.now_local()
|
|
|
|
|
+ };
|
|
|
|
|
+ yield db.insert_async<ApiToken>(token);
|
|
|
|
|
+ return token.token;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * True when `token` is the dedicated callback token — the
|
|
|
|
|
+ * credential check for `POST /api/saddle/note`.
|
|
|
|
|
+ */
|
|
|
|
|
+ public async bool validate_callback_token(string token) throws Error {
|
|
|
|
|
+ if (token.length == 0 || !token.has_prefix(CALLBACK_TOKEN_PREFIX)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ var rows = yield db.query<ApiToken>()
|
|
|
|
|
+ .where(expr("saddle_id == $0", elem<int64?>(0)))
|
|
|
|
|
+ .materialise_async();
|
|
|
|
|
+ foreach (var row in rows) {
|
|
|
|
|
+ if (row.token == token) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * This app's externally reachable base URL — what daemons use to
|
|
|
|
|
+ * POST notes back — read from the `saddle_callback_base_url` key
|
|
|
|
|
+ * of web-config.json, falling back to the
|
|
|
|
|
+ * `SADDLE_CALLBACK_BASE_URL` environment variable and then to
|
|
|
|
|
+ * `http://localhost:8080`. The daemon typically runs in a
|
|
|
|
|
+ * container or on another host, so its view of this app differs
|
|
|
|
|
+ * from the browser's; set the key to whatever reaches this
|
|
|
|
|
+ * process from there.
|
|
|
|
|
+ */
|
|
|
|
|
+ public string callback_base_url() {
|
|
|
|
|
+ string configured = config.get_string("saddle_callback_base_url", "").strip();
|
|
|
|
|
+ if (configured.length > 0) {
|
|
|
|
|
+ return configured;
|
|
|
|
|
+ }
|
|
|
|
|
+ var from_env = (Environment.get_variable("SADDLE_CALLBACK_BASE_URL") ?? "").strip();
|
|
|
|
|
+ if (from_env.length > 0) {
|
|
|
|
|
+ return from_env;
|
|
|
|
|
+ }
|
|
|
|
|
+ return "http://localhost:8080";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/** Removes a saddle registration; tasks it was running are re-queued. */
|
|
/** Removes a saddle registration; tasks it was running are re-queued. */
|
|
|
public async void unregister_saddle(int64 id) throws Error {
|
|
public async void unregister_saddle(int64 id) throws Error {
|
|
|
var saddle = yield get_saddle(id);
|
|
var saddle = yield get_saddle(id);
|
|
@@ -248,13 +323,16 @@ namespace ClankerSaddle.Services {
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* Pushes a task assignment to the saddle daemon's `POST /start`
|
|
* Pushes a task assignment to the saddle daemon's `POST /start`
|
|
|
- * endpoint in its wire shape — `{task, rules, llm_config}` — with
|
|
|
|
|
- * the project, co-developed/reference links, branch fields, plan
|
|
|
|
|
- * and recent comments inside the task object, plus any block
|
|
|
|
|
- * answers not yet handed over (a resumed workspace picks its
|
|
|
|
|
- * pending answers up from the assignment itself) and the
|
|
|
|
|
- * effective rule set ({@link RuleService.effective_rules}) as the
|
|
|
|
|
- * top-level `rules` array. Project checkouts are addressed by the
|
|
|
|
|
|
|
+ * endpoint in its wire shape — `{task, rules, project_notes,
|
|
|
|
|
+ * callback, llm_config}` — with the project, co-developed/
|
|
|
|
|
+ * reference links, branch fields, plan and recent comments inside
|
|
|
|
|
+ * the task object, plus any block answers not yet handed over (a
|
|
|
|
|
+ * resumed workspace picks its pending answers up from the
|
|
|
|
|
+ * assignment itself), the effective rule set ({@link
|
|
|
|
|
+ * RuleService.effective_rules}) as the top-level `rules` array,
|
|
|
|
|
+ * the capped `project_notes` of the primary and co-developed
|
|
|
|
|
+ * projects, and the note-creation `callback` (base URL + bearer
|
|
|
|
|
+ * token). Project checkouts are addressed by the
|
|
|
* `SADDLE_PROJECTS_ROOT` convention (`root/task-<task id>/name`)
|
|
* `SADDLE_PROJECTS_ROOT` convention (`root/task-<task id>/name`)
|
|
|
* and cloned from each project's `git_url`; the LLM endpoint by
|
|
* and cloned from each project's `git_url`; the LLM endpoint by
|
|
|
* the {@link LlmEndpoint} environment. Returns the parsed JSON
|
|
* the {@link LlmEndpoint} environment. Returns the parsed JSON
|
|
@@ -382,6 +460,58 @@ namespace ClankerSaddle.Services {
|
|
|
}
|
|
}
|
|
|
payload.end_array();
|
|
payload.end_array();
|
|
|
|
|
|
|
|
|
|
+ // The wire contract's `project_notes` addition: notes of the
|
|
|
|
|
+ // primary and co-developed projects, oldest first, capped at
|
|
|
|
|
+ // 30 entries / 20 000 chars (head kept).
|
|
|
|
|
+ var note_project_names = new Dictionary<string, string>();
|
|
|
|
|
+ var project_notes_rows = new Vector<ClankerSaddle.Data.ProjectNote>();
|
|
|
|
|
+ note_project_names.set(((!)project).id.to_string(), ((!)project).name);
|
|
|
|
|
+ foreach (var note in yield projects.list_notes(((!)project).id)) {
|
|
|
|
|
+ project_notes_rows.add(note);
|
|
|
|
|
+ }
|
|
|
|
|
+ foreach (var linked in co_developed) {
|
|
|
|
|
+ note_project_names.set(linked.id.to_string(), linked.name);
|
|
|
|
|
+ foreach (var note in yield projects.list_notes(linked.id)) {
|
|
|
|
|
+ project_notes_rows.add(note);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ var project_notes = project_notes_rows.sort((a, b) => (int)(a.id - b.id));
|
|
|
|
|
+ payload.set_member_name("project_notes");
|
|
|
|
|
+ payload.begin_array();
|
|
|
|
|
+ int64 note_entries = 0;
|
|
|
|
|
+ int64 note_chars = 0;
|
|
|
|
|
+ foreach (var note in project_notes) {
|
|
|
|
|
+ if (note_entries >= PROJECT_NOTES_MAX_ENTRIES
|
|
|
|
|
+ || note_chars >= PROJECT_NOTES_MAX_CHARS) {
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ string note_body = note.body;
|
|
|
|
|
+ if (note_chars + note_body.length > PROJECT_NOTES_MAX_CHARS) {
|
|
|
|
|
+ note_body = note_body.substring(0,
|
|
|
|
|
+ (int)(PROJECT_NOTES_MAX_CHARS - note_chars));
|
|
|
|
|
+ }
|
|
|
|
|
+ payload.begin_object();
|
|
|
|
|
+ payload.set_member_name("project");
|
|
|
|
|
+ payload.add_string_value(note_project_names.get_or_default(note.project_id.to_string()) ?? "");
|
|
|
|
|
+ payload.set_member_name("body");
|
|
|
|
|
+ payload.add_string_value(note_body);
|
|
|
|
|
+ payload.end_object();
|
|
|
|
|
+ note_entries++;
|
|
|
|
|
+ note_chars += note_body.length;
|
|
|
|
|
+ }
|
|
|
|
|
+ payload.end_array();
|
|
|
|
|
+
|
|
|
|
|
+ // The wire contract's `callback` addition: this app's
|
|
|
|
|
+ // externally reachable base URL plus the bearer token the
|
|
|
|
|
+ // daemon presents when posting notes back.
|
|
|
|
|
+ payload.set_member_name("callback");
|
|
|
|
|
+ payload.begin_object();
|
|
|
|
|
+ payload.set_member_name("base_url");
|
|
|
|
|
+ payload.add_string_value(callback_base_url());
|
|
|
|
|
+ payload.set_member_name("token");
|
|
|
|
|
+ payload.add_string_value(yield ensure_callback_token());
|
|
|
|
|
+ payload.end_object();
|
|
|
|
|
+
|
|
|
payload.set_member_name("llm_config");
|
|
payload.set_member_name("llm_config");
|
|
|
payload.begin_object();
|
|
payload.begin_object();
|
|
|
payload.set_member_name("base_url");
|
|
payload.set_member_name("base_url");
|