Files
jason.woltje 9b7bf11afb feat(tasks): U4a, a task store built into each organization (#1873)
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.
2026-11-09 19:25:44 -06:00

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;
}