Files
rocko 4bdc7e0e86 processing: inspectable per-run stop seam for lifecycle fire
Unify halt and fire cancellation by verified run identity with durable first-owner receipts and shared deadlines. Integrate the instance control with the current host, library and tester-socket paths. Focused nine-file evidence is 62 passing tests; broad suites, mutants and review remain pending.
2026-10-21 22:42:12 -05:00

75 lines
4.2 KiB
JavaScript

// The broker-side web service (REQ-HARN-2): what answers a role's web.search and web.fetch.
// The fetch is packages/discord/src/web.mjs's, with all of row 23's fences: https only, public
// addresses only (checked once, then the connection goes to the vetted address), at most 3
// redirects, a capped body, a time limit, text types only. The search goes to a "search-source"
// plug-in (packages/plugins): any object with `search(query)` that resolves
// { query, total, results: [{ title, url, snippet }] } or throws a WebRefusal.
//
// A fence refusal is a normal result with one fixed reason, as in the Discord connector: it is
// `{ refused: reason }` for the session and outcome "refused" in the broker's evidence. A bug or an
// unexpected error throws, and the broker answers web-failed. The broker (not this file) records
// every call and labels every reply untrusted (broker.mjs, requestWeb).
import { SEARCH_MAX_QUERY_CHARS, WEB_DEFAULTS, WEB_REFUSAL, WebRefusal, webFetch } from "../../discord/src/web.mjs";
import { currentProcessing } from '../../processing-control/src/effects.mjs';
export const WEB_SEARCH_NOT_CONFIGURED = "search is not configured for this business";
// Under the runner's own 30 s wait for a tool call, so a slow source is a refusal, not a lost reply.
export const WEB_DEADLINE_MS = 25_000;
// webService({ search, maxFetchBytes, deadlineMs, deps })
// search a search-source plug-in instance, or null when none is configured
// maxFetchBytes the cap on one fetched body (web.mjs's default when left out)
// deps web.mjs's network seams (lookup, httpsRequest), for tests
// Returns { timeout, handle(verb, args) -> { result, summary } }.
export function webService({ search = null, maxFetchBytes = WEB_DEFAULTS.maxFetchBytes, deadlineMs = WEB_DEADLINE_MS, deps } = {}) {
const config = Object.freeze({ maxFetchBytes, timeoutMs: Math.min(WEB_DEFAULTS.timeoutMs, deadlineMs) });
const refused = (reason, extra = {}) => ({ result: { refused: reason }, summary: { outcome: "refused", reason, ...extra } });
// `start(signal)` begins the call with an AbortSignal that fires at the deadline (vikunja:19/307),
// so a stalled backend is cut off, not just ignored: web.mjs destroys its request on abort, and a
// search-source module gets the signal as the `signal` of its last argument.
const within = (start) => {
const processing = currentProcessing(); processing?.guard();
const controller = new AbortController();
let timer;
const late = new Promise((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new WebRefusal(WEB_REFUSAL.TIMEOUT));
}, deadlineMs);
});
const signal = processing ? AbortSignal.any([processing.signal, controller.signal]) : controller.signal;
return Promise.race([start(signal), late]).finally(() => clearTimeout(timer));
};
async function run(verb, args) {
if (verb === "web.fetch") {
const page = await within((signal) => webFetch({ ...config, signal }, { url: args.url }, deps));
return {
result: page,
summary: { outcome: "ok", status: page.status, bytes: page.bytes, redirects: page.redirects, finalUrl: page.finalUrl, truncated: page.truncated || page.textTruncated },
};
}
if (verb === "web.search") {
if (!search) return refused(WEB_SEARCH_NOT_CONFIGURED);
// The one query limit, web.mjs's, applied before any backend is touched: the same refusal
// and the same reason as a module's own check.
const q = args.query;
if (q.trim().length === 0 || q.length > SEARCH_MAX_QUERY_CHARS || q.includes("\0")) return refused(WEB_REFUSAL.BAD_QUERY);
const found = await within((signal) => search.search(q, { signal }));
return { result: found, summary: { outcome: "ok", total: found.total, returned: found.results.length } };
}
throw new Error(`unknown web verb ${verb}`);
}
return {
timeout: deadlineMs + 5000,
async handle(verb, args) {
try {
return await run(verb, args);
} catch (error) {
if (error instanceof WebRefusal) return refused(error.reason, error.status ? { status: error.status } : {});
throw error;
}
},
};
}