Row 25, parts 2a and 2b, against the shared-signals contract a5425a2. Model side: eight fixed verbs in the pi extension (record_list, record_get, record_create, record_update, resolve_id, open_approval_request, get_approval_request, create_document), each one HTTP call with arguments checked before any request. Writes carry an idempotency key <principal>:<message id>:<call index> and an audit context. The seat key is read from a 0600 file on every call and never cached, printed or journaled. Connector side: append-only approval ledger, Approve button and exact "approve" reply resolved by the connector against the required approvers, confirmation message posted as button evidence, bind and add_approval through the service under connector keys, retry of unknown entries on start. Evidence: node tests 162 pass, scripts/test-discord.sh 63/63. Review by rev-code-02, round 1 approved (#1509 comment 26467, tree 7872d8c5). Co-Authored-By: Claude Fable 5.1 <[email protected]>
581 lines
29 KiB
JavaScript
581 lines
29 KiB
JavaScript
// SetSpark record client for the Discord Sage (row 25, Jason's decision
|
|
// 2026-09-18: record authority moves from the Git vault to NocoDB plus
|
|
// Outline behind one write service, setspark-api). This module is the
|
|
// contract-independent half: the `setspark` key of the tools config, the
|
|
// seat's API key read from a 0600 file on every call, the idempotency key,
|
|
// and one HTTP core that every verb uses. The verbs themselves (paths,
|
|
// bodies, codes) are added when stack/api/openapi.json lands on
|
|
// shared-signals main; nothing here guesses a path.
|
|
//
|
|
// The fence:
|
|
// - one https base url from the binding, no path, query, user or password;
|
|
// every request goes to `${baseUrl}${path}` with a fixed path per verb
|
|
// - the key file is checked at load (regular, not a symlink, 0600,
|
|
// non-empty) and read on each call, so a rotated key takes effect
|
|
// without a restart; the key is never cached, printed or journaled
|
|
// - JSON in, JSON out; no redirects; the whole call ends within timeoutMs
|
|
// - the response is capped; a body over the cap is a refusal
|
|
// - an error body is `{code, message}` plus `current_revision` and
|
|
// `changed_fields` on 409; the refusal carries `code` and the fixed
|
|
// fields, and the server's message is data cut at MESSAGE_MAX_CHARS
|
|
// - the idempotency key is `<principal>:<turn id>:<call index>`; the turn
|
|
// id is the Discord message id from the envelope, the call index the
|
|
// tool set's counter for that turn; a call outside a turn is refused
|
|
//
|
|
// Refusals are SetsparkRefusal with a fixed `reason` from SETSPARK_REFUSAL
|
|
// and, when the server answered, `status` and `code`.
|
|
|
|
import { lstatSync, readFileSync } from "node:fs";
|
|
import { request as httpsRequest } from "node:https";
|
|
import { request as httpRequest } from "node:http";
|
|
|
|
export const SETSPARK_DEFAULTS = Object.freeze({ timeoutMs: 15000, maxResponseBytes: 262144 });
|
|
export const USER_AGENT = "mosaic-discord-sage/1 (Mosaic Stack Discord connector; setspark client)";
|
|
export const MESSAGE_MAX_CHARS = 400;
|
|
export const KEY_MAX_BYTES = 4096;
|
|
export const IDEMPOTENCY_HEADER = "idempotency-key";
|
|
export const PRINCIPAL = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
const SNOWFLAKE = /^[0-9]{17,20}$/;
|
|
const KEY_SHAPE = /^[!-~]{16,512}$/; // printable ascii, no spaces
|
|
|
|
export const SETSPARK_REFUSAL = Object.freeze({
|
|
NO_TURN: "no turn is running, so no idempotency key can be formed",
|
|
KEY_FILE: "the api key file is missing, not private or empty",
|
|
KEY_SHAPE: "the api key file does not hold one key",
|
|
TIMEOUT: "no complete response from the record service within the time limit",
|
|
NETWORK: "the record service could not be reached",
|
|
TOO_BIG: "the record service answer is over the size cap",
|
|
NOT_JSON: "the record service answered with something other than json",
|
|
CONFLICT: "the record changed since it was read (stale revision)",
|
|
REPLAY: "the same idempotency key was already used with a different request",
|
|
REJECTED: "the record service refused the request",
|
|
UNAUTHORIZED: "the record service did not accept the seat's key",
|
|
NOT_FOUND: "no such record",
|
|
SERVER: "the record service failed",
|
|
BAD_ARGS: "the call's arguments are not valid",
|
|
});
|
|
|
|
export class SetsparkRefusal extends Error {
|
|
constructor(reason, extra = {}) {
|
|
super(reason);
|
|
this.reason = reason;
|
|
Object.assign(this, extra);
|
|
}
|
|
}
|
|
|
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
|
|
// The `setspark` key of the tools config. Fixed at pi start like the roots.
|
|
export function loadSetsparkConfig(raw, where = "setspark") {
|
|
if (!isObject(raw)) throw new Error(`${where}: not an object`);
|
|
for (const k of Object.keys(raw)) {
|
|
if (!["baseUrl", "keyFile", "principal", "timeoutMs"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
|
|
}
|
|
if (typeof raw.baseUrl !== "string") throw new Error(`${where}.baseUrl: must be a url string`);
|
|
let u;
|
|
try {
|
|
u = new URL(raw.baseUrl);
|
|
} catch {
|
|
throw new Error(`${where}.baseUrl: not a valid url`);
|
|
}
|
|
const loopback = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "[::1]";
|
|
if (u.username || u.password || u.search || u.hash || (u.pathname !== "/" && u.pathname !== "")) throw new Error(`${where}.baseUrl: must be a bare origin with no path`);
|
|
if (!(u.protocol === "https:" || (u.protocol === "http:" && loopback))) throw new Error(`${where}.baseUrl: must be https, or http on loopback`);
|
|
if (typeof raw.keyFile !== "string" || !raw.keyFile.startsWith("/") || raw.keyFile.includes("\0")) throw new Error(`${where}.keyFile: must be an absolute path`);
|
|
checkPrivateFile(raw.keyFile, `${where}.keyFile`);
|
|
if (typeof raw.principal !== "string" || !PRINCIPAL.test(raw.principal)) throw new Error(`${where}.principal: must match ${PRINCIPAL}`);
|
|
const timeoutMs = raw.timeoutMs === undefined ? SETSPARK_DEFAULTS.timeoutMs : raw.timeoutMs;
|
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 60000) throw new Error(`${where}.timeoutMs: must be an integer between 1000 and 60000`);
|
|
return Object.freeze({ baseUrl: u.origin, keyFile: raw.keyFile, principal: raw.principal, timeoutMs, maxResponseBytes: SETSPARK_DEFAULTS.maxResponseBytes });
|
|
}
|
|
|
|
function checkPrivateFile(path, what) {
|
|
let st;
|
|
try {
|
|
st = lstatSync(path);
|
|
} catch {
|
|
throw new Error(`${what}: not found: ${path}`);
|
|
}
|
|
if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink: ${path}`);
|
|
if (!st.isFile()) throw new Error(`${what}: not a regular file: ${path}`);
|
|
if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600: ${path}`);
|
|
if (st.size === 0) throw new Error(`${what}: is empty: ${path}`);
|
|
}
|
|
|
|
// Read the key for one call. The file is re-checked every time, so a key
|
|
// that stops being private stops being used. The value never leaves this
|
|
// module except in the Authorization header.
|
|
export function readKey(config) {
|
|
try {
|
|
checkPrivateFile(config.keyFile, "keyFile");
|
|
} catch {
|
|
throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_FILE);
|
|
}
|
|
let st;
|
|
try {
|
|
st = lstatSync(config.keyFile);
|
|
} catch {
|
|
throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_FILE);
|
|
}
|
|
if (st.size > KEY_MAX_BYTES) throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_SHAPE);
|
|
const text = readFileSync(config.keyFile, "utf8");
|
|
// Either one bare key line, or the mint's own JSON output
|
|
// ({"key_id", "key", "note"}) stored as is; only "key" is used.
|
|
let key = text;
|
|
if (text.trimStart().startsWith("{")) {
|
|
try {
|
|
const obj = JSON.parse(text);
|
|
key = isObject(obj) && typeof obj.key === "string" ? obj.key : "";
|
|
} catch {
|
|
throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_SHAPE);
|
|
}
|
|
}
|
|
const lines = key.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
if (lines.length !== 1 || !KEY_SHAPE.test(lines[0])) throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_SHAPE);
|
|
return lines[0];
|
|
}
|
|
|
|
// `<principal>:<turn id>:<call index>`. The turn id is the Discord message
|
|
// id the connector wrote into the envelope; the call index counts this
|
|
// turn's tool calls from 1. A replay of the same key with the same body
|
|
// returns the stored result; a different body is refused by the service.
|
|
export function idempotencyKey(principal, turnId, callIndex) {
|
|
if (typeof principal !== "string" || !PRINCIPAL.test(principal)) throw new Error("idempotencyKey: bad principal");
|
|
if (typeof turnId !== "string" || !SNOWFLAKE.test(turnId)) throw new SetsparkRefusal(SETSPARK_REFUSAL.NO_TURN);
|
|
if (!Number.isInteger(callIndex) || callIndex < 1) throw new Error("idempotencyKey: call index must be a positive integer");
|
|
return `${principal}:${turnId}:${callIndex}`;
|
|
}
|
|
|
|
// A connector-side key for work that is not a model tool call: a message
|
|
// id or interaction id and a fixed step name.
|
|
export function connectorKey(principal, eventId, step) {
|
|
if (typeof principal !== "string" || !PRINCIPAL.test(principal)) throw new Error("connectorKey: bad principal");
|
|
if (typeof eventId !== "string" || !SNOWFLAKE.test(eventId)) throw new Error("connectorKey: bad event id");
|
|
if (typeof step !== "string" || !/^[a-z][a-z-]{0,31}$/.test(step)) throw new Error("connectorKey: bad step");
|
|
return `${principal}:${eventId}:${step}`;
|
|
}
|
|
|
|
const defaultDeps = Object.freeze({ httpsRequest, httpRequest });
|
|
|
|
function cutMessage(v) {
|
|
return typeof v === "string" ? v.replace(/\s+/g, " ").trim().slice(0, MESSAGE_MAX_CHARS) : "";
|
|
}
|
|
|
|
function reasonFor(status, code) {
|
|
if (status === 401 || status === 403) return SETSPARK_REFUSAL.UNAUTHORIZED;
|
|
if (status === 404) return SETSPARK_REFUSAL.NOT_FOUND;
|
|
if (status === 409) return SETSPARK_REFUSAL.CONFLICT;
|
|
if (status === 422 && code === "idempotency_mismatch") return SETSPARK_REFUSAL.REPLAY;
|
|
if (status >= 500) return SETSPARK_REFUSAL.SERVER;
|
|
return SETSPARK_REFUSAL.REJECTED;
|
|
}
|
|
|
|
// One request. Resolves the parsed JSON body of a 2xx. Rejects with
|
|
// SetsparkRefusal for everything else (a bad argument is a plain Error: a
|
|
// bug, not a refusal). The key is read here, per call, and goes into the
|
|
// header and nowhere else; `body` is sent as JSON.
|
|
export async function callApi(config, { method, path, body, idempotencyKey: key = null }, deps = defaultDeps) {
|
|
if (!["GET", "POST", "PATCH", "PUT"].includes(method)) throw new Error(`callApi: bad method ${method}`);
|
|
if (typeof path !== "string" || !path.startsWith("/") || path.includes("..") || /\s/.test(path)) throw new Error("callApi: bad path");
|
|
if (method === "GET" && body !== undefined) throw new Error("callApi: GET takes no body");
|
|
if (method !== "GET" && key === null) throw new Error("callApi: a write needs an idempotency key");
|
|
const secret = readKey(config);
|
|
const u = new URL(`${config.baseUrl}${path}`);
|
|
return new Promise((resolve, reject) => {
|
|
const mod = u.protocol === "https:" ? deps.httpsRequest : deps.httpRequest;
|
|
const payload = body === undefined ? null : Buffer.from(JSON.stringify(body), "utf8");
|
|
const headers = {
|
|
host: u.host,
|
|
"user-agent": USER_AGENT,
|
|
accept: "application/json",
|
|
"accept-encoding": "identity",
|
|
authorization: `Bearer ${secret}`,
|
|
};
|
|
if (key !== null) headers[IDEMPOTENCY_HEADER] = key;
|
|
if (payload) {
|
|
headers["content-type"] = "application/json";
|
|
headers["content-length"] = String(payload.length);
|
|
}
|
|
const opts = {
|
|
method,
|
|
hostname: u.hostname.replace(/^\[|\]$/g, ""),
|
|
port: u.port || (u.protocol === "https:" ? 443 : 80),
|
|
path: `${u.pathname}${u.search}`,
|
|
servername: u.protocol === "https:" ? u.hostname.replace(/^\[|\]$/g, "") : undefined,
|
|
headers,
|
|
};
|
|
let done = false;
|
|
const finish = (fn, v) => {
|
|
if (done) return;
|
|
done = true;
|
|
clearTimeout(timer);
|
|
fn(v);
|
|
};
|
|
const req = mod(opts);
|
|
const timer = setTimeout(() => {
|
|
req.destroy();
|
|
finish(reject, new SetsparkRefusal(SETSPARK_REFUSAL.TIMEOUT));
|
|
}, config.timeoutMs);
|
|
req.on("error", () => finish(reject, new SetsparkRefusal(SETSPARK_REFUSAL.NETWORK)));
|
|
req.on("response", (res) => {
|
|
const chunks = [];
|
|
let size = 0;
|
|
res.on("data", (c) => {
|
|
if (done) return;
|
|
size += c.length;
|
|
if (size > config.maxResponseBytes) {
|
|
res.destroy();
|
|
req.destroy();
|
|
finish(reject, new SetsparkRefusal(SETSPARK_REFUSAL.TOO_BIG, { status: res.statusCode }));
|
|
return;
|
|
}
|
|
chunks.push(c);
|
|
});
|
|
res.on("error", () => finish(reject, new SetsparkRefusal(SETSPARK_REFUSAL.NETWORK)));
|
|
res.on("end", () => {
|
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
let json = null;
|
|
if (text.trim().length > 0) {
|
|
try {
|
|
json = JSON.parse(text);
|
|
} catch {
|
|
json = undefined;
|
|
}
|
|
}
|
|
const status = res.statusCode;
|
|
if (status >= 200 && status < 300) {
|
|
if (json === undefined) return finish(reject, new SetsparkRefusal(SETSPARK_REFUSAL.NOT_JSON, { status }));
|
|
return finish(resolve, { status, body: json });
|
|
}
|
|
const err = isObject(json) ? json : {};
|
|
const code = typeof err.code === "string" ? err.code.slice(0, 64) : null;
|
|
const extra = { status, code, message: cutMessage(err.message) };
|
|
if (status === 409) {
|
|
if (err.current_revision !== undefined) extra.currentRevision = err.current_revision;
|
|
if (Array.isArray(err.changed_fields)) extra.changedFields = err.changed_fields.filter((f) => typeof f === "string").slice(0, 32);
|
|
}
|
|
finish(reject, new SetsparkRefusal(reasonFor(status, code), extra));
|
|
});
|
|
});
|
|
if (payload) req.write(payload);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// How a refusal reads to the model and in the turn record: the fixed
|
|
// reason, the code, and on 409 the fields that changed. Never the raw body.
|
|
export function renderRefusal(err) {
|
|
let s = `refused: ${err.reason}`;
|
|
if (err.code) s += ` (code ${err.code})`;
|
|
if (err.currentRevision !== undefined) s += `; current revision ${err.currentRevision}`;
|
|
if (Array.isArray(err.changedFields) && err.changedFields.length > 0) s += `; changed: ${err.changedFields.join(", ")}`;
|
|
if (err.message && err.message !== err.reason) s += `\n${err.message}`;
|
|
return s;
|
|
}
|
|
|
|
// --- the verbs (contract: shared-signals stack/api/openapi.json at a5425a2) ---
|
|
//
|
|
// Fixed verbs, one HTTP call each, registered by the extension when the
|
|
// tools config carries a setspark key. Names are prefixed so they cannot be
|
|
// confused with the file tools. Every write forms its idempotency key from
|
|
// the running turn and the tool set's call index, and carries the asserted
|
|
// requester in `context` (recorded by the service next to the verified key,
|
|
// never used for authorization). Output is rendered to fixed lines and
|
|
// capped; a record is shown as `key: value` lines.
|
|
|
|
export const SETSPARK_TOOL_NAMES = Object.freeze([
|
|
"record_list", "record_get", "record_create", "record_update", "resolve_id",
|
|
"open_approval_request", "get_approval_request", "create_document",
|
|
]);
|
|
export const RECORD_TYPES = Object.freeze(["business", "project", "work_item", "decision", "reference_note"]);
|
|
export const LIST_DEFAULT = 20;
|
|
export const LIST_MAX = 50;
|
|
export const FILTERS_MAX = 4;
|
|
export const RECORD_MAX_BYTES = 32768;
|
|
export const RENDER_MAX_CHARS = 6000;
|
|
export const DOCUMENT_MAX_CHARS = 20000;
|
|
export const RECORD_ID = /^[A-Z]{2,5}-[0-9]{1,8}$/;
|
|
const PROP_NAME = /^[a-z][a-z0-9_]{0,31}$/;
|
|
const HEX = /^[a-f0-9]{16,128}$/;
|
|
const HIDDEN_PROPS = new Set(["accepted_snapshot", "props", "import_pending"]);
|
|
|
|
function bad(what) {
|
|
return new SetsparkRefusal(SETSPARK_REFUSAL.BAD_ARGS, { message: what });
|
|
}
|
|
|
|
function needString(params, name, max, re = null) {
|
|
const v = params[name];
|
|
if (typeof v !== "string" || v.length === 0 || v.length > max || (re && !re.test(v))) throw bad(`${name} must be a string${re ? ` matching ${re}` : ""} of at most ${max} characters`);
|
|
return v;
|
|
}
|
|
|
|
function needId(params, name = "id") {
|
|
return needString(params, name, 16, RECORD_ID);
|
|
}
|
|
|
|
function needType(params) {
|
|
const t = params.record_type;
|
|
if (!RECORD_TYPES.includes(t)) throw bad(`record_type must be one of ${RECORD_TYPES.join(", ")}`);
|
|
return t;
|
|
}
|
|
|
|
function needObject(params, name) {
|
|
const v = params[name];
|
|
if (!isObject(v)) throw bad(`${name} must be an object`);
|
|
const size = Buffer.byteLength(JSON.stringify(v), "utf8");
|
|
if (size > RECORD_MAX_BYTES) throw bad(`${name} is over ${RECORD_MAX_BYTES} bytes`);
|
|
for (const k of Object.keys(v)) if (!PROP_NAME.test(k)) throw bad(`${name} has a property name that is not allowed: ${k.slice(0, 32)}`);
|
|
return v;
|
|
}
|
|
|
|
function needInt(params, name, min, max) {
|
|
const v = params[name];
|
|
if (!Number.isInteger(v) || v < min || v > max) throw bad(`${name} must be an integer between ${min} and ${max}`);
|
|
return v;
|
|
}
|
|
|
|
// The write key and the asserted requester for this call. The turn id and
|
|
// call index come from the tool set's state; without a running turn the
|
|
// write is refused before any request is formed.
|
|
function writeParts(config, state) {
|
|
const key = idempotencyKey(config.principal, state && state.turnId, state && state.callIndex);
|
|
const context = { turn_id: state.turnId, client_version: USER_AGENT };
|
|
if (state.requester || state.authorId) context.requester = { ...(state.authorId ? { id: state.authorId } : {}), ...(state.requester ? { name: state.requester } : {}) };
|
|
return { key, context };
|
|
}
|
|
|
|
async function write(config, state, method, path, body, deps) {
|
|
const { key, context } = writeParts(config, state);
|
|
const r = await callApi(config, { method, path, body: { ...body, context }, idempotencyKey: key }, deps);
|
|
return { key, status: r.status, body: r.body };
|
|
}
|
|
|
|
function record(body) {
|
|
return isObject(body) ? body : {};
|
|
}
|
|
|
|
export const setsparkVerbs = Object.freeze({
|
|
async record_list(config, params, state, deps) {
|
|
const type = needType(params);
|
|
const limit = params.limit === undefined ? LIST_DEFAULT : needInt(params, "limit", 1, LIST_MAX);
|
|
const offset = params.offset === undefined ? 0 : needInt(params, "offset", 0, 100000);
|
|
const q = new URLSearchParams({ record_type: type, limit: String(limit), offset: String(offset) });
|
|
if (params.filters !== undefined) {
|
|
if (!isObject(params.filters) || Object.keys(params.filters).length > FILTERS_MAX) throw bad(`filters must be an object of at most ${FILTERS_MAX} properties`);
|
|
for (const [k, v] of Object.entries(params.filters)) {
|
|
if (!PROP_NAME.test(k) || ["record_type", "type", "limit", "offset"].includes(k)) throw bad(`filters: property name not allowed: ${k.slice(0, 32)}`);
|
|
if (typeof v !== "string" || v.length === 0 || v.length > 200) throw bad(`filters.${k} must be a short string`);
|
|
q.set(k, v);
|
|
}
|
|
}
|
|
const r = await callApi(config, { method: "GET", path: `/v1/records?${q}` }, deps);
|
|
const items = Array.isArray(record(r.body).items) ? record(r.body).items.filter(isObject) : [];
|
|
return { verb: "record_list", recordType: type, items, limit, offset };
|
|
},
|
|
async record_get(config, params, state, deps) {
|
|
const id = needId(params);
|
|
const r = await callApi(config, { method: "GET", path: `/v1/records/${id}` }, deps);
|
|
return { verb: "record_get", id, record: record(r.body) };
|
|
},
|
|
async record_create(config, params, state, deps) {
|
|
const type = needType(params);
|
|
const rec = needObject(params, "record");
|
|
if (typeof rec.title !== "string" || rec.title.trim().length === 0) throw bad("record.title is required");
|
|
const r = await write(config, state, "POST", "/v1/records", { record_type: type, record: rec }, deps);
|
|
return { verb: "record_create", key: r.key, recordType: type, record: record(r.body) };
|
|
},
|
|
async record_update(config, params, state, deps) {
|
|
const id = needId(params);
|
|
const revision = needInt(params, "revision", 1, 1000000000);
|
|
const fields = needObject(params, "fields");
|
|
if (Object.keys(fields).length === 0) throw bad("fields must name at least one property");
|
|
const r = await write(config, state, "PATCH", `/v1/records/${id}`, { revision, fields }, deps);
|
|
return { verb: "record_update", key: r.key, id, from: revision, record: record(r.body) };
|
|
},
|
|
async resolve_id(config, params, state, deps) {
|
|
const query = needString(params, "query", 200).trim();
|
|
if (query.length === 0) throw bad("query must not be blank");
|
|
const r = await callApi(config, { method: "GET", path: `/v1/resolve?${new URLSearchParams({ q: query })}` }, deps);
|
|
const matches = Array.isArray(record(r.body).matches) ? record(r.body).matches.filter(isObject).slice(0, LIST_MAX) : [];
|
|
return { verb: "resolve_id", query, matches };
|
|
},
|
|
async open_approval_request(config, params, state, deps) {
|
|
const decisionId = needId(params, "decision_id");
|
|
const version = needInt(params, "proposal_version", 1, 1000000);
|
|
const digest = needString(params, "proposal_digest", 128, HEX);
|
|
const r = await write(config, state, "POST", "/v1/approval-requests", { decision_id: decisionId, proposal_version: version, proposal_digest: digest }, deps);
|
|
const b = record(r.body);
|
|
const approvers = Array.isArray(b.required_approvers) ? b.required_approvers.filter((a) => typeof a === "string") : [];
|
|
return {
|
|
verb: "open_approval_request", key: r.key, view: b,
|
|
// what the connector needs to post the approval message
|
|
request: { requestId: String(b.request_id), decisionId: String(b.decision_id ?? decisionId), proposalVersion: b.proposal_version ?? version, digest: String(b.proposal_digest ?? digest), approvers },
|
|
};
|
|
},
|
|
async get_approval_request(config, params, state, deps) {
|
|
const id = needInt(params, "request_id", 1, 1000000000);
|
|
const r = await callApi(config, { method: "GET", path: `/v1/approval-requests/${id}` }, deps);
|
|
return { verb: "get_approval_request", requestId: String(id), view: record(r.body) };
|
|
},
|
|
async create_document(config, params, state, deps) {
|
|
const collection = needString(params, "collection", 64, /^[A-Za-z0-9][A-Za-z0-9 _-]{0,63}$/);
|
|
const title = needString(params, "title", 200).trim();
|
|
const text = params.text === undefined ? "" : params.text;
|
|
if (typeof text !== "string" || text.length > DOCUMENT_MAX_CHARS) throw bad(`text must be a string of at most ${DOCUMENT_MAX_CHARS} characters`);
|
|
const body = { collection, title, text };
|
|
if (params.source !== undefined) body.source = needString(params, "source", 500, /^[^\p{Zl}\p{Zp}\p{Cc}]+$/u);
|
|
const r = await write(config, state, "POST", "/v1/documents", body, deps);
|
|
return { verb: "create_document", key: r.key, document: record(r.body) };
|
|
},
|
|
});
|
|
|
|
// --- rendering ---
|
|
|
|
function scalar(v) {
|
|
if (v === null || v === undefined) return "";
|
|
if (typeof v === "string") return v.replace(/\s+/g, " ").trim();
|
|
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
if (Array.isArray(v)) return v.map(scalar).filter((s) => s.length > 0).join(", ");
|
|
return JSON.stringify(v);
|
|
}
|
|
|
|
function cap(s) {
|
|
return s.length > RENDER_MAX_CHARS ? `${s.slice(0, RENDER_MAX_CHARS)}\n… cut at ${RENDER_MAX_CHARS} characters` : s;
|
|
}
|
|
|
|
// A record as `key: value` lines: id, type and revision first, the title,
|
|
// then the rest in the service's order, then the body last. The accepted
|
|
// snapshot and the round-trip props are not shown.
|
|
export function renderRecord(rec) {
|
|
const head = `${scalar(rec.id) || "(no id)"} (${scalar(rec.record_type) || "record"}) revision ${scalar(rec.revision) || "?"}`;
|
|
const lines = [head];
|
|
if (rec.title !== undefined) lines.push(`title: ${scalar(rec.title)}`);
|
|
let body = null;
|
|
for (const [k, v] of Object.entries(rec)) {
|
|
if (["id", "record_type", "revision", "title"].includes(k) || HIDDEN_PROPS.has(k)) continue;
|
|
if (k === "body" || k === "proposal_body") {
|
|
body = { k, v };
|
|
continue;
|
|
}
|
|
const s = scalar(v);
|
|
if (s.length > 0) lines.push(`${k}: ${s.slice(0, 500)}`);
|
|
}
|
|
if (body && typeof body.v === "string" && body.v.trim().length > 0) lines.push(`${body.k}:\n${body.v.trim()}`);
|
|
return cap(lines.join("\n"));
|
|
}
|
|
|
|
function summary(rec) {
|
|
const bits = [scalar(rec.title)];
|
|
for (const k of ["status", "priority", "kind"]) if (rec[k] !== undefined) bits.push(scalar(rec[k]));
|
|
return `${scalar(rec.id)}: ${bits.filter((b) => b.length > 0).join(" | ")} (rev ${scalar(rec.revision) || "?"})`;
|
|
}
|
|
|
|
function renderView(v) {
|
|
const approvals = Array.isArray(v.approvals) ? v.approvals.filter(isObject) : [];
|
|
const who = Array.isArray(v.required_approvers) ? v.required_approvers.length : "?";
|
|
return `request ${scalar(v.request_id)} for ${scalar(v.decision_id)} version ${scalar(v.proposal_version)}: ${scalar(v.state) || "?"}; ${approvals.length} of ${who} approvals recorded${v.message_id ? "; bound to a Discord message" : "; no message bound yet"}`;
|
|
}
|
|
|
|
export function renderSetspark(name, out) {
|
|
if (name === "record_list") {
|
|
const body = out.items.map(summary).join("\n");
|
|
return `${out.items.length} ${out.recordType} record(s) from offset ${out.offset} (limit ${out.limit})\n${body || "(none)"}`;
|
|
}
|
|
if (name === "record_get") return renderRecord(out.record);
|
|
if (name === "record_create") return `created ${scalar(out.record.id)} (${out.recordType}) revision ${scalar(out.record.revision)}; the record is live in SetSpark, no file and no commit`;
|
|
if (name === "record_update") return `updated ${out.id} from revision ${out.from} to ${scalar(out.record.revision)}; the change is live in SetSpark`;
|
|
if (name === "resolve_id") {
|
|
const body = out.matches.map((m) => `${scalar(m.id)} (${scalar(m.record_type)}): ${scalar(m.title)}${m.exact ? " [exact]" : ""}`).join("\n");
|
|
return `${out.matches.length} match(es) for ${JSON.stringify(out.query)}\n${body || "(none)"}`;
|
|
}
|
|
if (name === "open_approval_request") return `${renderView(out.view)}. The approval message with its Approve button is posted for you after this reply; do not claim any approval yourself.`;
|
|
if (name === "get_approval_request") return renderView(out.view);
|
|
if (name === "create_document") return `created document ${scalar(out.document.title || out.document.id)}${out.document.url ? ` at ${scalar(out.document.url)}` : ""}`;
|
|
throw new Error(`renderSetspark: unknown verb ${name}`);
|
|
}
|
|
|
|
// What the turn record keeps about a call, beyond the tool set's base.
|
|
export function setsparkDetails(name, out) {
|
|
const d = { verb: name };
|
|
if (out.key) d.key = out.key;
|
|
if (out.record && out.record.id !== undefined) d.id = String(out.record.id);
|
|
if (out.record && out.record.revision !== undefined) d.revision = out.record.revision;
|
|
if (out.request) d.request = out.request;
|
|
if (out.view && out.view.request_id !== undefined) d.requestId = String(out.view.request_id);
|
|
if (name === "record_list") d.count = out.items.length;
|
|
if (name === "resolve_id") d.count = out.matches.length;
|
|
return d;
|
|
}
|
|
|
|
export const SETSPARK_TOOL_DESCRIPTIONS = Object.freeze({
|
|
record_list: {
|
|
label: "List records",
|
|
description: `List SetSpark records of one type (${RECORD_TYPES.join(", ")}), optionally filtered by exact property values, up to ${LIST_MAX} at a time. Read only.`,
|
|
snippet: "record_list lists SetSpark records of one type",
|
|
},
|
|
record_get: {
|
|
label: "Get record",
|
|
description: "Read one SetSpark record by id, with its revision. Read a record before updating it, and cite the id in your reply.",
|
|
snippet: "record_get reads one SetSpark record by id",
|
|
},
|
|
record_create: {
|
|
label: "Create record",
|
|
description: "Create one SetSpark record; the service allocates the id. Give record_type and the record's properties (title required). Only when the user asked for a record to be created.",
|
|
snippet: "record_create creates one SetSpark record",
|
|
},
|
|
record_update: {
|
|
label: "Update record",
|
|
description: "Change named properties of one SetSpark record. Carry the revision from record_get; a stale revision is refused with what changed, then read again and retry.",
|
|
snippet: "record_update changes properties of one SetSpark record by revision",
|
|
},
|
|
resolve_id: {
|
|
label: "Resolve id",
|
|
description: "Find SetSpark records by exact id or a title substring.",
|
|
snippet: "resolve_id finds SetSpark records by id or title",
|
|
},
|
|
open_approval_request: {
|
|
label: "Open approval request",
|
|
description: "Ask the required approvers to approve a Proposed decision at its current version and digest (from record_get). The approval message and button are posted by the connector after your reply; you never record an approval yourself.",
|
|
snippet: "open_approval_request opens the approval of a Proposed decision",
|
|
},
|
|
get_approval_request: {
|
|
label: "Get approval request",
|
|
description: "Read the state of an approval request: open, approved or closed, and who has approved.",
|
|
snippet: "get_approval_request reads an approval request's state",
|
|
},
|
|
create_document: {
|
|
label: "Create document",
|
|
description: `Create one prose document in an allowed Outline collection with a title and Markdown text (at most ${DOCUMENT_MAX_CHARS} characters). Only when the user asked for a document.`,
|
|
snippet: "create_document creates one prose document in Outline",
|
|
},
|
|
});
|
|
|
|
// --- the connector's own client (bind and approvals) ---
|
|
|
|
// Built by the cli from the same config and passed to the connector like
|
|
// rest. Request ids are strings on the connector side and integers on the
|
|
// wire.
|
|
export function createSetsparkApi(config, deps = defaultDeps) {
|
|
const num = (id) => {
|
|
const n = Number(id);
|
|
if (!Number.isSafeInteger(n) || n < 1) throw new Error("setspark api: bad request id");
|
|
return n;
|
|
};
|
|
return {
|
|
async bindApprovalMessage({ requestId, messageId, channelId, idempotencyKey: key }) {
|
|
const r = await callApi(config, { method: "POST", path: `/v1/approval-requests/${num(requestId)}/message`, body: { message_id: messageId, channel_id: channelId, context: { client_version: USER_AGENT } }, idempotencyKey: key }, deps);
|
|
return r.body;
|
|
},
|
|
async addApproval({ requestId, kind, authorId, messageId, boundMessageId, sourceUrl, statement, idempotencyKey: key }) {
|
|
if (kind !== "button" && kind !== "reply") throw new Error("setspark api: kind must be button or reply");
|
|
const body = { request_id: num(requestId), kind, author_id: authorId, message_id: messageId, bound_message_id: boundMessageId, source_url: sourceUrl, statement, context: { source_url: sourceUrl, client_version: USER_AGENT } };
|
|
const r = await callApi(config, { method: "POST", path: "/v1/approvals", body, idempotencyKey: key }, deps);
|
|
return r.body;
|
|
},
|
|
async getApprovalRequest(requestId) {
|
|
const r = await callApi(config, { method: "GET", path: `/v1/approval-requests/${num(requestId)}` }, deps);
|
|
return r.body;
|
|
},
|
|
};
|
|
}
|