A fixture-only organization reads a ready catalogue with no model proxy client: the catalogue asks the proxy only when a module calls it. A thinking-only change, and a limits patch that keeps harness, model and entry, no longer asks the catalogue. A change that truly needs it still refuses, and the WebUI names the next step: keep the model and change thinking, or press Disable model proxy catalogue. The Routing switch follows whether cliproxy is listed and removes or adds only cliproxy.
415 lines
27 KiB
JavaScript
415 lines
27 KiB
JavaScript
// Shared human settings projection/update (REQ-LIMIT-1), used by the trusted broker.
|
|
import { organizationWriter, OrganizationError } from "./organization.mjs";
|
|
import { validateBusinessDocument, businessFilePath } from "./organization-config.mjs";
|
|
import { resolveInstance } from "./resolve.mjs";
|
|
import { usageCounts } from "./launch.mjs";
|
|
import { canonicalJson, isPlainObject, requireObject, rejectUnknownKeys } from "./util.mjs";
|
|
import { FALLBACK_VARS } from "./vars.mjs";
|
|
import { THREAD_DEFINITION, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
|
|
import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
|
|
|
|
// Row 213 (C2-08, Case 2 design 4, M10): a creative instance, its persona included, is the
|
|
// human's. A write that touches one is accepted only when its actor is the business file's
|
|
// human; the broker sets the actor to the human's id, "tester" or <definition>.<instance>.
|
|
export function creativeHumanOnly(document, actor, instance, definition) {
|
|
if (CREATIVE_DEFINITIONS.includes(definition) && actor !== document.human) {
|
|
throw new OrganizationError("organization-creative-human-only", `${instance} is a ${definition} instance, which only the human changes`);
|
|
}
|
|
}
|
|
|
|
// Row 194 (D2.4 B7, webby-0094 N3): only the human changes a kept instance's model, through the
|
|
// instance's own key or by re-pointing the launch.models entry that key names (the chief and the
|
|
// tester are refused, as row 213 refuses them a creative instance's). The check reads the file, so a
|
|
// keep the broker's copy hasn't loaded holds too.
|
|
function keptHumanOnly(document, actor, instance) {
|
|
if (actor !== document.human && (document.launch?.keep ?? []).includes(instance)) {
|
|
throw new OrganizationError("instance-kept", `${instance} is kept; only the human changes its model`);
|
|
}
|
|
}
|
|
|
|
// Row 155 (#1649, Jason's ruling Q169): the fields of an assistant instance's entry the human and
|
|
// the chief of staff may change. holder and credentials stay hand edits.
|
|
const ASSISTANT_FIELDS = ["vars", "tracker", "definition"];
|
|
|
|
// Every key a limits update takes. The broker's and the harness tool's
|
|
// ORGANIZATION_KEYS must match it (packages/harness/tests/tools.test.mjs).
|
|
export const SETTINGS_KEYS = Object.freeze(["revision", "limits", "models", "instances", "reset", "assistants"]);
|
|
|
|
const own = (value, key) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.hasOwn(value, key);
|
|
const plain = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
|
|
// Row 125 (#1619, REQ-LIMIT-3): token limits sit only at their schema depth, tokens.instances.X,
|
|
// tokens.providers.X, tokens.keys.X, tokens.models.P.M and (row 126) tokens.daily.X. Nothing else in launch.limits is one,
|
|
// so a concurrency provider or model named `limit`, or a token limit's subject named `limit`, is
|
|
// never mistaken for a leaf. The bus core's limitLeaves walks the same depths.
|
|
const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2, daily: 1 };
|
|
function tokenLeaves(limits) {
|
|
const out = [];
|
|
const walk = (value, depth, path) => {
|
|
if (!plain(value)) return;
|
|
for (const [key, entry] of Object.entries(value)) {
|
|
if (depth > 1) walk(entry, depth - 1, [...path, key]);
|
|
else out.push({ path: [...path, key], entry });
|
|
}
|
|
};
|
|
const tokens = own(limits, "tokens") ? limits.tokens : undefined;
|
|
if (plain(tokens)) {
|
|
for (const [kind, value] of Object.entries(tokens)) if (Object.hasOwn(DEPTH, kind)) walk(value, DEPTH[kind], ["tokens", kind]);
|
|
}
|
|
return out;
|
|
}
|
|
const at = (value, path) => path.reduce((v, part) => (own(v, part) ? v[part] : undefined), value);
|
|
|
|
// The writer, not the caller, sets a token limit's `since`. A new limit gets now; a changed or
|
|
// unchanged one keeps its stored time; a caller-supplied time that differs from the stored one is
|
|
// refused, so only a reset moves it. An entry that isn't an object is left for the schema to refuse.
|
|
// Row 333 R4 (#1831): a new daily key quota carries its agent's count (carried, below).
|
|
function stamp(next, old, now, lineage) {
|
|
for (const { path, entry } of tokenLeaves(next)) {
|
|
if (!plain(entry)) continue;
|
|
const before = at(old, path);
|
|
const kept = plain(before) ? before.since : undefined;
|
|
if (own(entry, "since") && entry.since !== kept) throw new OrganizationError("invalid-request");
|
|
entry.since = kept ?? carried(path, entry, old, now, lineage) ?? now;
|
|
}
|
|
}
|
|
|
|
// Row 333 R4 (#1831): a new tokens.keys leaf in the daily quota shape is a quota on one agent's model
|
|
// key. It takes the earliest stored `since` of the old document's daily key quotas whose ids are in
|
|
// that key's lineage, so moving a quota from A to B after a rotation or re-issue keeps the day's
|
|
// count, whether or not the same write removes A. With none it is new and gets now (undefined
|
|
// here). lineage(id) answers the ids of the agent holding id, read from the bus's own credential
|
|
// records, or [] when no agent holds it. A lookup that throws or answers malformed refuses the write.
|
|
// A quota removed in one write and added back in a later one finds no old leaf, so it gets now.
|
|
const KEY_ID = /^[a-f0-9]{12}$/;
|
|
function carried(path, entry, old, now, lineage) {
|
|
const [, kind, id] = path;
|
|
if (kind !== "keys" || !isDailyModelQuota(id, { ...entry, since: now })) return undefined;
|
|
let ids;
|
|
try { ids = lineage(id); } catch { throw new OrganizationError("proxy-key-refused"); }
|
|
if (!Array.isArray(ids) || ids.some(x => typeof x !== "string" || !KEY_ID.test(x)) || (ids.length && !ids.includes(id))) {
|
|
throw new OrganizationError("proxy-key-refused");
|
|
}
|
|
const keys = at(old, ["tokens", "keys"]);
|
|
const times = Object.entries(plain(keys) ? keys : {})
|
|
.filter(([key, value]) => ids.includes(key) && isDailyModelQuota(key, value)).map(([, value]) => value.since);
|
|
return times.sort((a, b) => Date.parse(a) - Date.parse(b))[0];
|
|
}
|
|
|
|
// A reset names token limit paths, e.g. ["tokens", "instances", "coder-1"]; each gets since = now.
|
|
// Any other path, a concurrency limit's included, is refused.
|
|
function reset(limits, paths, now) {
|
|
if (!Array.isArray(paths) || !paths.length) throw new OrganizationError("invalid-request");
|
|
const leaves = tokenLeaves(limits);
|
|
for (const path of paths) {
|
|
if (!Array.isArray(path) || !path.length || path.some(part => typeof part !== "string")) {
|
|
throw new OrganizationError("invalid-request");
|
|
}
|
|
const leaf = leaves.find(l => l.path.length === path.length && l.path.every((part, i) => part === path[i]));
|
|
if (!leaf || !plain(leaf.entry)) throw new OrganizationError("invalid-request");
|
|
leaf.entry.since = now;
|
|
}
|
|
}
|
|
|
|
// Row 65 (REQ-PLUG-2): the checks every write of an instance's route vars runs: the routes table's,
|
|
// instances.set's and limits.set's. Each instance's model, and its fallback model when it states a
|
|
// fallback, must be a launch.models key. A route the write changes must name a harness that is
|
|
// switched on in plugins.harness (harness-off) and whose module applies its thinking level
|
|
// (thinking-unsupported). levels(name) answers the levels a harness module applies, or null when
|
|
// the module didn't load, which refuses harness-refused. An unchanged route isn't checked, so a
|
|
// model change still saves while plugins.harness is unset; that instance's launch refuses instead.
|
|
// A route var the instance doesn't set is left to the launch, which refuses no-route.
|
|
const ROUTES = Object.freeze([["harness", "model", "thinking"], FALLBACK_VARS]);
|
|
export function checkRoutes({ proposed, old = null, levels, catalogue = null }) {
|
|
const on = Array.isArray(proposed.plugins?.harness) ? proposed.plugins.harness : [];
|
|
for (const [instance, entry] of Object.entries(proposed.roles)) {
|
|
const vars = entry.vars;
|
|
const before = old && Object.hasOwn(old.roles, instance) ? old.roles[instance].vars : {};
|
|
for (const [harness, model, thinking] of ROUTES) {
|
|
// A missing model refuses no-route at launch, not here.
|
|
if (proposed.launch && Object.hasOwn(vars, model) && !Object.hasOwn(proposed.launch.models, vars[model])) {
|
|
throw new OrganizationError("unknown-model", `${instance} variable ${model} isn't a launch.models key`);
|
|
}
|
|
const currentEntry = proposed.launch?.models?.[vars[model]];
|
|
const previousEntry = old?.launch?.models?.[before[model]];
|
|
// D3 (P0-4): the catalogue answers which harness a model runs on, not its thinking level, so
|
|
// a change that keeps the harness, the model key and its entry needs no catalogue; the
|
|
// harness checks below still run on the new thinking level.
|
|
const routeChanged = [harness, model].some(field => vars[field] !== before[field]) || canonicalJson(currentEntry ?? null) !== canonicalJson(previousEntry ?? null);
|
|
if (routeChanged && previousEntry?.module && !currentEntry?.module && vars[model] === before[model]) throw new OrganizationError('model-provider-unavailable');
|
|
if (routeChanged && currentEntry?.module) {
|
|
if (catalogue?.status !== 'ready') throw new OrganizationError('model-provider-unavailable');
|
|
const offered = catalogue.models?.find(m => m.key === vars[model]);
|
|
if (!offered || !offered.available || !proposed.plugins?.['model-provider']?.includes(currentEntry.module) || canonicalJson(currentEntry) !== canonicalJson({ provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream })) throw new OrganizationError('model-provider-unavailable');
|
|
if (!offered.harnesses.includes(vars[harness])) throw new OrganizationError('model-harness-incompatible');
|
|
}
|
|
if (routeChanged && currentEntry && !currentEntry.module && proposed.plugins?.['model-provider']?.length) {
|
|
if (catalogue?.status !== 'ready') throw new OrganizationError('model-provider-unavailable');
|
|
const mapping = catalogue.resolveLegacy ? catalogue.resolveLegacy(currentEntry) : catalogue.legacy?.[vars[model]];
|
|
const offered = mapping && catalogue.models?.find(m => m.module === mapping.module && m.upstream === mapping.upstream && m.model === currentEntry.model);
|
|
if (!offered?.available) throw new OrganizationError('model-provider-unavailable');
|
|
if (!offered.harnesses.includes(vars[harness])) throw new OrganizationError('model-harness-incompatible');
|
|
}
|
|
if (!Object.hasOwn(vars, harness) || (vars[harness] === before[harness] && vars[thinking] === before[thinking])) continue;
|
|
const name = vars[harness];
|
|
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} variable ${harness} isn't switched on in plugins.harness`);
|
|
if (vars[thinking] === undefined) continue;
|
|
const applies = levels(name);
|
|
if (!Array.isArray(applies)) throw new OrganizationError("harness-refused", `${instance} variable ${harness}: its harness module didn't load, so its thinking levels are unknown`);
|
|
if (!applies.includes(vars[thinking])) {
|
|
throw new OrganizationError("thinking-unsupported", `${instance} variable ${thinking}: its harness doesn't apply this thinking level; it applies ${applies.join(", ")}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const ROUTE_FIELDS = Object.freeze(["harness", "model", "thinking"]);
|
|
|
|
// levels(name): the thinking levels harness module `name` applies, or null (checkRoutes).
|
|
// Row 333 R4 (#1831): keyLineage(business, keyId) answers the key ids in the lineage of the agent
|
|
// that holds keyId (carried, above). The bus runtime passes one read from its credential records.
|
|
// Without it, a write that adds a daily key quota refuses proxy-key-refused; no other write asks.
|
|
export function launchSettings({ dir, rolesDir, system, sessions = () => [], now = () => new Date().toISOString(), levels = () => null,
|
|
keyLineage = () => { throw new OrganizationError("proxy-key-refused"); } }) {
|
|
const writer = organizationWriter({ dir, rolesDir, dataRoot:system?.dataRoot ?? null });
|
|
|
|
// The validated business as the file stands now (row 125: the limit checks read it at each ask).
|
|
function current(id) {
|
|
return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
|
|
}
|
|
|
|
// The file before a write, validated, for checkRoutes; null when it no longer validates, so every
|
|
// route is checked.
|
|
function before(id, document) {
|
|
try {
|
|
return validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
|
|
} catch (e) {
|
|
if (e.name === "BusinessError") return null;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function view(id) {
|
|
const { document, revision } = writer.read(id);
|
|
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
|
|
if (!business.launch) throw new OrganizationError("launch-not-configured");
|
|
const instances = Object.fromEntries(Object.keys(business.roles).map(instance => {
|
|
const vars = resolveInstance({ business, system, instance }).vars;
|
|
const key = vars.model;
|
|
// No model var reads as unset; the launch refuses it no-route.
|
|
if (key === undefined) return [instance, { harness: vars.harness ?? null, provider: null, model: null, modelKey: null }];
|
|
const entry = Object.hasOwn(business.launch.models, key) ? business.launch.models[key] : null;
|
|
if (!entry) throw new OrganizationError("unknown-model");
|
|
return [instance, { harness: vars.harness ?? null, ...entry, modelKey: key }];
|
|
}));
|
|
// Each assistant instance's entry fields as the file has them, so a change replaces what it shows.
|
|
const assistants = Object.fromEntries(Object.entries(document.roles)
|
|
.filter(([, entry]) => entry.definition === THREAD_DEFINITION)
|
|
.map(([instance, entry]) => [instance, {
|
|
definition: entry.definition, vars: entry.vars ?? {}, tracker: entry.tracker ?? null,
|
|
}]));
|
|
// model is the external id; modelKey is the table selection.
|
|
return {
|
|
business: id, revision, models: business.launch.models,
|
|
limits: business.launch.limits, instances, assistants, usage: usageCounts(sessions(), id),
|
|
};
|
|
}
|
|
|
|
function set(id, args, actor, catalogue = null) {
|
|
requireObject(args, "limits update");
|
|
rejectUnknownKeys(args, SETTINGS_KEYS, "limits update");
|
|
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
|
|
throw new OrganizationError("invalid-request");
|
|
}
|
|
if (!SETTINGS_KEYS.some(key => key !== "revision" && Object.hasOwn(args, key))) {
|
|
throw new OrganizationError("invalid-request");
|
|
}
|
|
const old = writer.read(id);
|
|
const next = structuredClone(old.document);
|
|
if (!next.launch) throw new OrganizationError("launch-not-configured");
|
|
const at = now();
|
|
if (Object.hasOwn(args, "limits")) {
|
|
next.launch.limits = structuredClone(args.limits);
|
|
stamp(next.launch.limits, old.document.launch.limits, at, key => keyLineage(id, key));
|
|
}
|
|
if (Object.hasOwn(args, "reset")) reset(next.launch.limits, args.reset, at);
|
|
if (Object.hasOwn(args, "models")) {
|
|
if (actor !== old.document.human && plain(args.models)) {
|
|
for (const [key, entry] of Object.entries(args.models)) {
|
|
if (entry?.module && canonicalJson(entry) !== canonicalJson(old.document.launch.models[key] ?? null)) throw new OrganizationError('organization-model-provider-human-only');
|
|
// Resolve changed legacy entries with the trusted module resolver; a table key
|
|
// alone does not authorize introducing a different catalogue identity.
|
|
const previous = old.document.launch.models[key];
|
|
if (canonicalJson(entry) !== canonicalJson(previous ?? null) && plain(entry) && !entry.module && old.document.plugins?.['model-provider']?.length) {
|
|
if (catalogue?.status !== 'ready' || typeof catalogue.resolveLegacy !== 'function' || !Array.isArray(catalogue.models)) throw new OrganizationError('model-provider-unavailable');
|
|
const mapping = catalogue.resolveLegacy(entry);
|
|
const prior = previous?.module
|
|
? { module: previous.module, upstream: previous.upstream, model: previous.model }
|
|
: plain(previous) ? catalogue.resolveLegacy(previous) : null;
|
|
if (mapping && canonicalJson(mapping) !== canonicalJson(prior) && catalogue.models.some(model => model.module === mapping.module && model.upstream === mapping.upstream && model.model === entry.model)) {
|
|
throw new OrganizationError('organization-model-provider-human-only');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// M10: replacing launch.models can change the model behind a key a creative instance names
|
|
// without touching the instance, so a change to or removal of such a key is the human's.
|
|
for (const [instance, entry] of Object.entries(old.document.roles)) {
|
|
const vars = entry.vars ?? {};
|
|
for (const key of [vars.model, vars["fallback.model"]]) {
|
|
if (typeof key !== "string") continue;
|
|
const was = own(old.document.launch.models, key) ? canonicalJson(old.document.launch.models[key]) : null;
|
|
const is = own(args.models, key) ? canonicalJson(args.models[key]) : null;
|
|
if (was !== is) {
|
|
creativeHumanOnly(old.document, actor, instance, entry.definition);
|
|
keptHumanOnly(old.document, actor, instance);
|
|
}
|
|
}
|
|
}
|
|
next.launch.models = args.models;
|
|
}
|
|
if (Object.hasOwn(args, "instances")) {
|
|
requireObject(args.instances, "instance model selections");
|
|
for (const [instance, key] of Object.entries(args.instances)) {
|
|
if (!Object.hasOwn(next.roles, instance) || typeof key !== "string") {
|
|
throw new OrganizationError("invalid-request");
|
|
}
|
|
// M10: a creative instance's model changes through instances.set or routes.set, whoever asks.
|
|
if (CREATIVE_DEFINITIONS.includes(next.roles[instance].definition)) {
|
|
throw new OrganizationError("organization-creative-instance", `${instance}'s model changes through instances.set or routes.set`);
|
|
}
|
|
keptHumanOnly(old.document, actor, instance);
|
|
next.roles[instance].vars = { ...next.roles[instance].vars, model: key };
|
|
}
|
|
}
|
|
// Each field given replaces that field; tracker null removes it. Only an assistant instance is a
|
|
// target, and its definition stays one the broker treats as the assistant, so the change can't
|
|
// strip END_PROTECTED's protection.
|
|
if (Object.hasOwn(args, "assistants")) {
|
|
requireObject(args.assistants, "assistant settings");
|
|
for (const [instance, entry] of Object.entries(args.assistants)) {
|
|
if (!Object.hasOwn(next.roles, instance)) throw new OrganizationError("invalid-request");
|
|
if (next.roles[instance].definition !== THREAD_DEFINITION) throw new OrganizationError("organization-not-assistant");
|
|
requireObject(entry, `assistant settings ${instance}`);
|
|
rejectUnknownKeys(entry, ASSISTANT_FIELDS, `assistant settings ${instance}`);
|
|
if (Object.keys(entry).length === 0) throw new OrganizationError("invalid-request");
|
|
if (Object.hasOwn(entry, "definition")) {
|
|
if (entry.definition !== THREAD_DEFINITION) throw new OrganizationError("organization-definition-refused");
|
|
next.roles[instance].definition = entry.definition;
|
|
}
|
|
if (Object.hasOwn(entry, "vars")) next.roles[instance].vars = requireObject(entry.vars, `assistant settings ${instance}.vars`);
|
|
if (entry.tracker === null) delete next.roles[instance].tracker;
|
|
else if (Object.hasOwn(entry, "tracker")) next.roles[instance].tracker = entry.tracker;
|
|
}
|
|
}
|
|
// No key may change another instance's definition, so none gives an instance an END_PROTECTED one
|
|
// after a decision about it was resolved (dewey-0546).
|
|
for (const [instance, entry] of Object.entries(old.document.roles)) {
|
|
if (next.roles[instance]?.definition !== entry.definition) throw new OrganizationError("organization-definition-refused");
|
|
}
|
|
// Check every instance's route before publishing the complete document.
|
|
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
|
|
checkRoutes({ proposed, old: before(id, old.document), levels, catalogue });
|
|
const receipt = writer.write(id, next, { revision: args.revision, actor });
|
|
return { ...view(id), transaction: receipt.transaction };
|
|
}
|
|
|
|
// Row 65 (mos-0840): the routing table. One row per instance with its harness, model, thinking
|
|
// and fallback, and what each cell offers: the switched-on harnesses, the launch.models keys and
|
|
// the levels each harness applies (null for a module that didn't load). A route var the instance
|
|
// doesn't set reads null; its launch refuses no-route.
|
|
function routes(id) {
|
|
const { document, revision } = writer.read(id);
|
|
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
|
|
if (!business.launch) throw new OrganizationError("launch-not-configured");
|
|
const harnesses = Array.isArray(business.plugins?.harness) ? [...business.plugins.harness] : [];
|
|
const named = new Set(harnesses);
|
|
const instances = Object.fromEntries(Object.entries(business.roles).map(([instance, { vars }]) => {
|
|
for (const key of ["harness", "fallback.harness"]) if (Object.hasOwn(vars, key)) named.add(vars[key]);
|
|
return [instance, {
|
|
harness: vars.harness ?? null, model: vars.model ?? null, thinking: vars.thinking ?? null,
|
|
fallback: Object.hasOwn(vars, "fallback.harness")
|
|
? { harness: vars["fallback.harness"], model: vars["fallback.model"], thinking: vars["fallback.thinking"] } : null,
|
|
}];
|
|
}));
|
|
return {
|
|
business: id, revision, harnesses, models: Object.keys(business.launch.models),
|
|
providerModels: Object.fromEntries(Object.entries(business.launch.models).filter(([, entry]) => entry.module).map(([key, entry]) => [key, { module: entry.module, upstream: entry.upstream, model: entry.model }])),
|
|
levels: Object.fromEntries([...named].sort().map(name => [name, levels(name)])), instances,
|
|
};
|
|
}
|
|
|
|
// args: { revision, routes: { <instance>: { harness, model, thinking, fallback } } }, fallback
|
|
// being null or { harness, model, thinking }. Each named instance's route vars are replaced in one
|
|
// organization write; a null fallback clears all three. It is human-only and outside
|
|
// ORGANIZATION_KEYS (the broker's routes.set). The chief of staff's tool changes the model key,
|
|
// and the assistant's whole vars (Q169), through limits.set, which runs the same checkRoutes.
|
|
function setRoutes(id, args, actor, catalogue = null) {
|
|
requireObject(args, "routes update");
|
|
rejectUnknownKeys(args, ["revision", "routes"], "routes update");
|
|
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
|
|
throw new OrganizationError("invalid-request");
|
|
}
|
|
if (!isPlainObject(args.routes) || Object.keys(args.routes).length === 0) throw new OrganizationError("invalid-request");
|
|
const old = writer.read(id);
|
|
const next = structuredClone(old.document);
|
|
if (!next.launch) throw new OrganizationError("launch-not-configured");
|
|
const exact = (value, keys) => isPlainObject(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
for (const [instance, route] of Object.entries(args.routes)) {
|
|
if (!Object.hasOwn(next.roles, instance)) throw new OrganizationError("organization-unknown-instance");
|
|
creativeHumanOnly(old.document, actor, instance, next.roles[instance].definition);
|
|
// webby-0095 N1, sage-2248: a route is a second write of a kept instance's model.
|
|
keptHumanOnly(old.document, actor, instance);
|
|
if (!exact(route, [...ROUTE_FIELDS, "fallback"]) || ROUTE_FIELDS.some(key => typeof route[key] !== "string")) {
|
|
throw new OrganizationError("invalid-request");
|
|
}
|
|
const { fallback } = route;
|
|
if (fallback !== null && (!exact(fallback, ROUTE_FIELDS) || ROUTE_FIELDS.some(key => typeof fallback[key] !== "string"))) {
|
|
throw new OrganizationError("organization-partial-fallback", `${instance}'s fallback sets all of harness, model and thinking or is cleared`);
|
|
}
|
|
// The client supplies only a key. New entries come from the trusted snapshot.
|
|
const priorVars = old.document.roles[instance].vars ?? {};
|
|
for (const [choice, prefix] of [[route, ''], [fallback, 'fallback.']]) {
|
|
if (!choice) continue;
|
|
const unchanged = ROUTE_FIELDS.every(field => priorVars[prefix + field] === choice[field]);
|
|
// D3 (P0-4): a choice that keeps the harness and model key needs no catalogue, as in
|
|
// checkRoutes; only its thinking level may differ. A key the catalogue offers as a
|
|
// different model still refuses model-key-collision below on any change.
|
|
const sameRoute = ['harness', 'model'].every(field => priorVars[prefix + field] === choice[field]);
|
|
const offered = catalogue?.models?.find(m => m.key === choice.model);
|
|
const existing = next.launch.models[choice.model];
|
|
const entry = offered ? { provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream } : null;
|
|
if (!existing && !offered && catalogue?.reason === 'model-key-collision') throw new OrganizationError('model-key-collision');
|
|
if (!existing && !offered && catalogue?.status === 'unavailable') throw new OrganizationError('model-catalogue-unavailable');
|
|
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} variable ${prefix}model isn't a launch.models key`);
|
|
if (existing && offered && canonicalJson(existing) !== canonicalJson(entry) && !unchanged) throw new OrganizationError('model-key-collision');
|
|
if (!existing || existing.module) {
|
|
if (sameRoute) continue;
|
|
if (catalogue?.status !== 'ready') throw new OrganizationError('model-catalogue-unavailable');
|
|
if (!offered || !offered.available || !Array.isArray(next.plugins?.['model-provider']) || !next.plugins['model-provider'].includes(offered.module)) throw new OrganizationError('model-provider-unavailable');
|
|
if (!offered.harnesses.includes(choice.harness)) throw new OrganizationError('model-harness-incompatible');
|
|
if (existing && canonicalJson(existing) !== canonicalJson(entry)) throw new OrganizationError('model-key-collision');
|
|
if (!existing) {
|
|
if (actor !== old.document.human) throw new OrganizationError('organization-model-provider-human-only');
|
|
next.launch.models[choice.model] = entry;
|
|
}
|
|
}
|
|
}
|
|
const vars = { ...next.roles[instance].vars };
|
|
for (const key of FALLBACK_VARS) delete vars[key];
|
|
for (const key of ROUTE_FIELDS) {
|
|
vars[key] = route[key];
|
|
if (fallback !== null) vars[`fallback.${key}`] = fallback[key];
|
|
}
|
|
next.roles[instance].vars = vars;
|
|
}
|
|
if (canonicalJson(next) === canonicalJson(old.document)) throw new OrganizationError("organization-no-change");
|
|
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
|
|
checkRoutes({ proposed, old: before(id, old.document), levels, catalogue });
|
|
const receipt = writer.write(id, next, { revision: args.revision, actor });
|
|
return { ...routes(id), transaction: receipt.transaction };
|
|
}
|
|
|
|
return { writer, read: view, set, current, routes, setRoutes };
|
|
}
|