PRD 1.20 REQ-TASK-1, REQ-PLUG-1, REQ-WS-8. Each organization keeps its tasks in its own bus through the new task-tracker plug-in kind. The default module is the built-in store; Vikunja is the optional module and a Vikunja organization keeps today's path. - bus schema 3u to 3v (migration 3u-3v.sql): store_tasks, append-only, one row per write, each citing its events row; seven guard triggers. - broker: the eight role verbs on the store with the slice 1 field table checked twice (module, then a before/after field diff); the person's tasks.create/edit/archive/unarchive/delete as human.input events; the tasks read returns store rows in board order (group, position), filtered and paged like tracker rows. - runtime: each business gets its module at start (organization file plugin, else vikunja with a tracker entry, else store); a store business with a Vikunja tracker refuses the boot (tracker-config). - plugins: task-tracker kind with store and vikunja modules. - cli: the launcher's tracker flag covers store organizations; trail prints store writes. - pins: schema.test counts, the bus migration tests, stack-release #811 and T46 (store_tasks on the always list; COUNTED unchanged), budget-supervisor accepts 3v. Rebased onto SYS1 by the lead: SYS1 took 3t-3u, so this step is 3u-3v.
154 lines
9.0 KiB
JavaScript
154 lines
9.0 KiB
JavaScript
// The plug-in kind registry (REQ-PLUG-1; the first two kinds are
|
|
// REQ-LIMIT-4's). A kind names the exports its modules must provide, the
|
|
// interface version they implement, and whether the organization file
|
|
// switches on one module or a list. Adding a kind, or changing one, is a
|
|
// core change: a reviewed edit to this file.
|
|
|
|
// Optional proxy telemetry is separate from session usage-source and limit-check.
|
|
export function checkUsageSettings(value) {
|
|
const object = v => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
const bad = () => { throw new PluginError("invalid-usage-settings", "usage.cliproxyQueue.pollSeconds must be a whole number from 1 through 59"); };
|
|
if (!object(value) || Object.keys(value).some(k => k !== "cliproxyQueue")) bad();
|
|
if (Object.hasOwn(value, "cliproxyQueue")) {
|
|
const queue = value.cliproxyQueue;
|
|
if (!object(queue) || Object.keys(queue).some(k => k !== "pollSeconds")) bad();
|
|
if (Object.hasOwn(queue, "pollSeconds") && (!Number.isSafeInteger(queue.pollSeconds) || queue.pollSeconds < 1 || queue.pollSeconds >= 60)) bad();
|
|
}
|
|
return structuredClone(value);
|
|
}
|
|
|
|
export const NAME = /^[a-z][a-z0-9-]*$/;
|
|
|
|
export const KINDS = Object.freeze({
|
|
// Reads one turn's usage for the limit checks (row 124 converts the first).
|
|
"usage-source": Object.freeze({
|
|
kind: "usage-source", interfaceVersion: 1, exports: Object.freeze(["readTurn"]), switch: "list",
|
|
}),
|
|
// Decides whether a launch may start, whether a running session must stop,
|
|
// and what each limit has used (REQ-LIMIT-3; version 2 since row 125). Core
|
|
// calls each with the business's limits and usage rows, never a store:
|
|
// mayLaunch({ business, limits, subject, records, now }) -> { allow: true } | { allow: false, hit }
|
|
// mustStop({ business, limits, subject, records, now }) -> { stop: false } | { stop: true, hit }
|
|
// report({ business, limits, records, now }) -> [{ path, limit, since, count, kinds, unknownTurns }]
|
|
// with hit = { path, limit, count, unknownTurns } and subject = { instance, provider, model, account }.
|
|
// While a daily key quota is set (row 333), subject also carries modelKeyId and every export gets
|
|
// lineages: one array of key ids per hired agent, current, retiring and earlier (R3).
|
|
"limit-check": Object.freeze({
|
|
kind: "limit-check", interfaceVersion: 2, exports: Object.freeze(["mayLaunch", "mustStop", "report"]), switch: "list",
|
|
}),
|
|
// Answers web_search for a role's web tool (REQ-HARN-2, REQ-PLUG-2). The organization file names
|
|
// one source, and the module's name is the choice: harness-builtin, searxng-bundled or
|
|
// searxng-url (technical decision 0b81b0c5). The broker calls `search` with the business's
|
|
// settings and an AbortSignal (`search(settings, query, deps, { signal })`); harness-builtin's `search` only refuses, since the harness serves web itself.
|
|
"search-source": Object.freeze({
|
|
kind: "search-source", interfaceVersion: 1, exports: Object.freeze(["search"]), switch: "one",
|
|
}),
|
|
// Human project/thread surfaces; proof and the recovery writer remain in core.
|
|
channel: Object.freeze({
|
|
kind: "channel", interfaceVersion: 1, exports: Object.freeze(["dispatch"]), switch: "list",
|
|
}),
|
|
// A managed session's harness (row 65, REQ-PLUG-2, Q156). Core reaches a harness only through
|
|
// these, each a function the launcher, the bundle and the usage reader call on the host; the
|
|
// sandboxed runner gets their answers as data in session.json. The organization file switches
|
|
// on a list, and an instance's `harness` var names one of them. packages/plugins/README.md has
|
|
// each one's contract:
|
|
// adapter() -> the adapter script's absolute path levels() -> the thinking levels it applies
|
|
// preflight({ env }) -> null | { code, message } version({ env }) -> string | null
|
|
// countsAccount() -> true when its launches spend launch.account
|
|
// mounts({ env }) -> read-only binds [{ src, dest, why }]
|
|
// builtinWeb() -> true when it serves harness-builtin web
|
|
// builtins({ tools, webBuiltin }) -> its names for the built-in tools
|
|
// typedName(name) -> its name for a typed bus tool reliesOn() -> the row S0 lines
|
|
// gateTimeout() -> the gate hook's timeouts | null
|
|
// bundle({ dir, files, session, write, quote }) -> { files, digests, sources }, its own bundle files
|
|
// env({ files, policy, tools, session }) -> its adapter variables, written into session.json
|
|
// turnEnd() -> "marker" (a turn ends only with the turn marker) | "exit"
|
|
// outputPolicy({ proxy }) -> "adapter" | "non-newline-stdout" | "stdout"
|
|
// transcript({ runDir, store }) -> the directory its transcripts land in, for usage
|
|
// compactions({ store, cursors, budget, streams, first }) -> { found, cursors, known, reason, version },
|
|
// the session store's compactions past the ledger's cursors (row 307)
|
|
harness: Object.freeze({
|
|
kind: "harness", interfaceVersion: 1, switch: "list",
|
|
exports: Object.freeze(["adapter", "levels", "preflight", "version", "countsAccount", "mounts", "builtinWeb", "builtins",
|
|
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "outputPolicy", "transcript", "compactions"]),
|
|
}),
|
|
// Optional proxy telemetry (row 333): read({ readUsageQueue }) -> the drained events. The bus
|
|
// hands the module that one function and nothing else from its management client (R3).
|
|
usage: Object.freeze({
|
|
kind: "usage", interfaceVersion: 1, exports: Object.freeze(["read"]), switch: "one",
|
|
}),
|
|
"model-provider": Object.freeze({
|
|
kind: "model-provider", interfaceVersion: 1, exports: Object.freeze(["catalogue", "preflight", "route"]), switch: "list",
|
|
}),
|
|
// Project system types are installed modules, enabled per organization.
|
|
"system-type": Object.freeze({
|
|
kind: "system-type", interfaceVersion: 1, exports: Object.freeze(["systemType"]), switch: "list",
|
|
}),
|
|
// A Console colour theme (stream S4, PLAN C5). A module is data only: theme() takes nothing and returns
|
|
// { displayName, tokens: { light, dim, dark } }
|
|
// where each mode is a flat map of the Console's colour custom properties to #rrggbb strings. theme.mjs
|
|
// checks it strictly and refuses a module that isn't exactly that. Themes are found by listing
|
|
// packages/plugins/theme/ (loader.loadInstalled), not by a name in a list; an organization picks one
|
|
// with `appearance.theme`, never with `plugins`, so the switch below is only the kind's shape.
|
|
theme: Object.freeze({
|
|
kind: "theme", interfaceVersion: 1, exports: Object.freeze(["theme"]), switch: "list",
|
|
}),
|
|
// Where an organization keeps its tasks (U4a, REQ-TASK-1). The organization file names one module;
|
|
// with none, the bus uses `store`, the task store built into each organization's own bus. `vikunja`
|
|
// is the optional Vikunja tracker, set up from the boot config's `trackers` as before. Core calls
|
|
// adapter({ trackers }) -> async ({ broker, credentials, businesses, processing }) -> { handle, timeout, close }
|
|
// once per bus, where handle(cap, verb, args) answers the eight task verbs for a role's cap and
|
|
// timeout is in milliseconds. Every module answers the same `tasks` read through the broker.
|
|
"task-tracker": Object.freeze({
|
|
kind: "task-tracker", interfaceVersion: 1, exports: Object.freeze(["adapter"]), switch: "one",
|
|
}),
|
|
});
|
|
|
|
export class PluginError extends Error {
|
|
constructor(code, message, name = null) {
|
|
super(message);
|
|
this.code = code;
|
|
this.name = "PluginError";
|
|
this.plugin = name;
|
|
}
|
|
}
|
|
|
|
const raise = (code, message, name) => {
|
|
throw new PluginError(code, message, name);
|
|
};
|
|
|
|
// The module names one kind's setting switches on, checked against the
|
|
// kind's cardinality and the name pattern. A list may be empty.
|
|
export function namesFor(spec, setting, where, fail = raise) {
|
|
if (spec.switch === "one") {
|
|
if (typeof setting !== "string") fail("bad-setting", `${where} must be one module name`);
|
|
} else if (!Array.isArray(setting)) {
|
|
fail("bad-setting", `${where} must be a list of module names`);
|
|
}
|
|
const names = spec.switch === "one" ? [setting] : setting;
|
|
const seen = new Set();
|
|
for (const name of names) {
|
|
if (typeof name !== "string" || !NAME.test(name)) {
|
|
fail("bad-name", `${where} names ${JSON.stringify(name)}, which doesn't match ${NAME}`,
|
|
typeof name === "string" ? name : null);
|
|
}
|
|
if (seen.has(name)) fail("bad-setting", `${where} names ${name} twice`, name);
|
|
seen.add(name);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
// The organization file's `plugins` object: one key per kind, each a known kind.
|
|
export function checkPlugins(value, where, { kinds = KINDS, fail = raise } = {}) {
|
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
fail("bad-setting", `${where} must be an object with one key per kind`);
|
|
}
|
|
for (const [kind, setting] of Object.entries(value)) {
|
|
if (!Object.hasOwn(kinds, kind)) {
|
|
fail("unknown-kind", `${where} names unknown kind ${JSON.stringify(kind)}; adding a kind is a core change`);
|
|
}
|
|
namesFor(kinds[kind], setting, `${where}.${kind}`, fail);
|
|
}
|
|
return value;
|
|
}
|