Jason's 2026-11-12 ruling: the only deployed system is the dev environment on dragon-lin, so the system-type plug-in kind goes with no retired kind, fence, conversion or new refusal code. - plugins: system-type leaves KINDS; system-types.mjs and the five modules are deleted. An org file naming the kind refuses unknown-kind. - organization: the plugins.system-type check, systemTypesFence and the system-types.console right are removed. - bus: system-types.console, project.system-types[.set], the system-type half of the tester check, the runtime reads and system-type-not-enabled are removed. Step 3v-3w drops project_system_types, its index, its four guards and its rows; SCHEMA_VERSION is 3w (32 tables, 126 triggers, 5 views). - cli: mosaic org system-types and mosaic project system-types[-set] are removed; stack-release COUNTED and the budget supervisor's closed list move to 3w. - channel: the project.system-types verbs are removed. - webui: the System types page, route, dashboard row, chips and project selection are removed. - docs: REQ-SYS-1/2 now describe starter packs; README system-type text and the requirements titles follow.
158 lines
9.3 KiB
JavaScript
158 lines
9.3 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",
|
|
}),
|
|
// A starter pack (#1873 SP1): starterPack() takes nothing and returns a pack document, which
|
|
// starter-pack.mjs checks with the origin of where it was read. A pack is copied into an
|
|
// organization, never switched on, so its switch is "none": `plugins` can never name one.
|
|
"starter-pack": Object.freeze({
|
|
kind: "starter-pack", interfaceVersion: 1, exports: Object.freeze(["starterPack"]), switch: "none",
|
|
}),
|
|
// 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. A kind whose
|
|
// switch is "none" takes no setting at all.
|
|
export function namesFor(spec, setting, where, fail = raise) {
|
|
if (spec.switch === "none") fail("bad-setting", `${where} can't be set; a ${spec.kind} is never switched on`);
|
|
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;
|
|
}
|