feat: add the operations layer for declared plugin actions

This commit is contained in:
LukeGus committed 2026-11-03 22:28:54 -06:00
1 parent b87fe3c6a5
commit cc725c7889
54 files changed
+22319 -8

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
/**
* Who may run what without a person saying yes.
*
* A plugin fills this through ctx.operations.approvals.provide. Core asks every
* provider and the strictest answer wins. A provider that throws denies the
* run, since skipping a safety rule is worse than a refused action.
*/
import type { OperationRisk } from "@termix-ssh/plugin-sdk/operations";
import { riskRank } from "@termix-ssh/plugin-sdk/operations";
import { hasCapability } from "../plugins/permissions.js";
import { pluginLogger } from "../utils/logger.js";
import type { ActorOrigin } from "../plugins/actor.js";
import type { PluginManifest } from "../plugins/manifest.js";
export interface ApprovalPolicyInput {
actionId: string;
pluginId: string;
/** What the plugin declared. */
risk: OperationRisk;
/** What rules should use. Plugins not signed as official count as change. */
effectiveRisk: OperationRisk;
origin: ActorOrigin;
userId: string;
hostId?: number;
hostTags?: string[];
batchId?: string;
}
export interface ApprovalDecision {
decision: "allow" | "approve" | "deny";
reason?: string;
/** The requester may approve their own run. */
allowSelf?: boolean;
expiresInMinutes?: number;
}
export type ApprovalPolicyProvider = (
input: ApprovalPolicyInput,
) =>
| ApprovalDecision
| null
| undefined
| Promise<ApprovalDecision | null | undefined>;
interface Entry {
pluginId: string;
manifest: PluginManifest;
provider: ApprovalPolicyProvider;
}
const providers = new Map<symbol, Entry>();
export function provideApprovalPolicy(
pluginId: string,
manifest: PluginManifest,
provider: ApprovalPolicyProvider,
): () => void {
const key = Symbol(pluginId);
providers.set(key, { pluginId, manifest, provider });
return () => {
providers.delete(key);
};
}
/** Test seam. */
export function clearApprovalPolicies(): void {
providers.clear();
}
const RANK = { allow: 0, approve: 1, deny: 2 } as const;
const TRUSTED_VIA = new Set(["automation", "rollout", "fleet"]);
const SELF_APPROVE_VIA = new Set(["assistant", "mcp", "automation"]);
/** A person is the check when the request came from something else. */
export function canSelfApprove(origin: ActorOrigin): boolean {
return origin.kind === "plugin" && SELF_APPROVE_VIA.has(origin.via ?? "");
}
/**
* Used when no provider has an opinion. People and their own keys go by RBAC,
* and so do automations they set up. Anything else needs a rule that allows it.
*/
function builtIn(input: ApprovalPolicyInput): ApprovalDecision {
if (riskRank(input.effectiveRisk) === 0) return { decision: "allow" };
if (input.origin.kind !== "plugin") return { decision: "allow" };
if (TRUSTED_VIA.has(input.origin.via ?? "")) return { decision: "allow" };
return {
decision: "deny",
reason: "This needs an approval rule. Turn on approvals to allow it.",
};
}
export async function evaluateApproval(
input: ApprovalPolicyInput,
): Promise<ApprovalDecision> {
let strictest: ApprovalDecision | null = null;
for (const entry of providers.values()) {
let decision: ApprovalDecision | null | undefined;
try {
// Rechecked at use, so a revoked grant stops the provider now.
if (
!(await hasCapability(
entry.pluginId,
"operations:approve",
entry.manifest.capabilities,
))
) {
continue;
}
decision = await entry.provider(input);
} catch (error) {
pluginLogger.warn("Approval rule failed, refusing the run", {
operation: "operation_approval",
pluginId: entry.pluginId,
error: error instanceof Error ? error.message : String(error),
});
return {
decision: "deny",
reason: "Approval rules are not available right now.",
};
}
if (!decision) continue;
if (!strictest || RANK[decision.decision] > RANK[strictest.decision]) {
strictest = decision;
}
}
return strictest ?? builtIn(input);
}
+251
View File
@@ -0,0 +1,251 @@
/**
* What a user can do right now, in their language.
*
* Built from the manifests of running plugins, never from GET /plugins, which
* hands every signed-in user the raw manifest. A plugin's locale file loads
* here on the server, because its frontend may not be loaded on the client:
* the CLI has no frontend, and a linked desktop may not have the plugin at all.
*/
import fs from "node:fs";
import path from "node:path";
import {
onSurface,
toJsonSchema,
type OperationAction,
type OperationEvent,
type OperationField,
type OperationResource,
type OperationSignal,
type OperationSurfaces,
} from "@termix-ssh/plugin-sdk/operations";
import { getAction } from "./registry.js";
import { qualifyPermission, type PluginManifest } from "../plugins/manifest.js";
import { PermissionManager } from "../utils/permission-manager.js";
interface CatalogPlugin {
id: string;
dir: string;
manifest: PluginManifest;
state: string;
official: boolean;
}
export interface CatalogField extends OperationField {
label: string;
description?: string;
placeholder?: string;
optionLabels?: Record<string, string>;
}
export interface CatalogAction extends Omit<
OperationAction,
"inputs" | "outputs"
> {
pluginId: string;
pluginName: string;
official: boolean;
title: string;
description?: string;
inputs: CatalogField[];
outputs: CatalogField[];
inputSchema: Record<string, unknown>;
available: boolean;
}
export interface CatalogEvent extends OperationEvent {
pluginId: string;
title: string;
}
export interface CatalogResource extends OperationResource {
pluginId: string;
title: string;
columns: (NonNullable<OperationResource["columns"]>[number] & {
title: string;
})[];
}
export interface CatalogSignal extends OperationSignal {
pluginId: string;
title: string;
}
export interface OperationCatalog {
actions: CatalogAction[];
events: CatalogEvent[];
resources: CatalogResource[];
signals: CatalogSignal[];
}
type Strings = Record<string, string>;
const localeCache = new Map<string, Strings>();
function flatten(value: unknown, prefix: string, out: Strings): void {
if (typeof value === "string") {
out[prefix] = value;
return;
}
if (typeof value !== "object" || value === null) return;
for (const [key, child] of Object.entries(value)) {
flatten(child, prefix ? `${prefix}.${key}` : key, out);
}
}
function readLocale(file: string): Strings {
const out: Strings = {};
try {
flatten(JSON.parse(fs.readFileSync(file, "utf8")), "", out);
} catch {
// A missing or broken file falls back to the key.
}
return out;
}
/** Locale names reach a path, so only a plain language tag is used. */
function safeLanguage(lang: string | undefined): string {
return lang && /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?$/.test(lang) ? lang : "en";
}
function stringsFor(plugin: CatalogPlugin, lang: string): Strings {
const cacheKey = `${plugin.id}@${plugin.manifest.version}:${lang}`;
const cached = localeCache.get(cacheKey);
if (cached) return cached;
const dir = path.resolve(plugin.dir, plugin.manifest.locales ?? "locales");
const strings = readLocale(path.join(dir, "en.json"));
if (lang !== "en") {
Object.assign(
strings,
readLocale(path.join(dir, "translated", `${lang}.json`)),
readLocale(path.join(dir, `${lang}.json`)),
);
}
localeCache.set(cacheKey, strings);
return strings;
}
function localizedField(field: OperationField, strings: Strings): CatalogField {
const text = (key?: string) => (key ? (strings[key] ?? key) : undefined);
const optionLabels: Record<string, string> = {};
for (const option of field.options ?? []) {
optionLabels[option.value] = text(option.labelKey) ?? option.value;
}
return {
...field,
label: text(field.labelKey) ?? field.key,
description: text(field.descriptionKey),
placeholder: text(field.placeholderKey),
optionLabels: field.options ? optionLabels : undefined,
};
}
async function runningPlugins(): Promise<CatalogPlugin[]> {
const { getPluginRuntime } = await import("../plugins/index.js");
return getPluginRuntime()
.loader.list()
.filter((plugin) => plugin.state === "active")
.map((plugin) => ({
id: plugin.id,
dir: plugin.dir,
manifest: plugin.manifest,
state: plugin.state,
official: plugin.source === "bundled" || plugin.signedTier === "official",
}));
}
export async function buildCatalog(options: {
userId: string;
lang?: string;
surface?: keyof OperationSurfaces;
}): Promise<OperationCatalog> {
const lang = safeLanguage(options.lang);
const permissions = PermissionManager.getInstance();
const catalog: OperationCatalog = {
actions: [],
events: [],
resources: [],
signals: [],
};
for (const plugin of await runningPlugins()) {
const operations = plugin.manifest.contributes?.operations;
if (!operations) continue;
const strings = stringsFor(plugin, lang);
const text = (key: string) => strings[key] ?? key;
for (const action of operations.actions ?? []) {
if (options.surface && !onSurface(action, options.surface)) continue;
// Community plugins stay away from the assistant and MCP until an admin allows them.
if (
!plugin.official &&
(options.surface === "assistant" || options.surface === "mcp")
) {
continue;
}
const permission = qualifyPermission(plugin.id, action.permission);
if (!(await permissions.hasPermission(options.userId, permission))) {
continue;
}
const inputs = (action.inputs ?? []).map((field) =>
localizedField(field, strings),
);
catalog.actions.push({
...action,
pluginId: plugin.id,
pluginName: plugin.manifest.name,
official: plugin.official,
title: text(action.titleKey),
description: action.descriptionKey
? text(action.descriptionKey)
: undefined,
inputs,
outputs: (action.outputs ?? [])
.filter((field) => !field.sensitive)
.map((field) => localizedField(field, strings)),
inputSchema: toJsonSchema(
action.inputs,
(field) =>
inputs.find((input) => input.key === field.key)?.description,
),
available: getAction(action.id) !== undefined,
});
}
const visible = new Set(catalog.actions.map((action) => action.id));
for (const event of operations.events ?? []) {
catalog.events.push({
...event,
pluginId: plugin.id,
title: text(event.titleKey),
});
}
for (const resource of operations.resources ?? []) {
if (!visible.has(resource.list)) continue;
catalog.resources.push({
...resource,
pluginId: plugin.id,
title: text(resource.titleKey),
columns: (resource.columns ?? []).map((column) => ({
...column,
title: text(column.titleKey),
})),
});
}
for (const signal of operations.signals ?? []) {
if (!visible.has(signal.read)) continue;
catalog.signals.push({
...signal,
pluginId: plugin.id,
title: text(signal.titleKey),
});
}
}
return catalog;
}
/** Test seam. */
export function clearCatalogCache(): void {
localeCache.clear();
}
+8
View File
@@ -0,0 +1,8 @@
/** How many events or actions may lead to one another before the chain stops. */
export const MAX_EVENT_DEPTH = 3;
/** How long finished runs and events stay in history. */
export const RETENTION_DAYS = 90;
/** How long a cached table row or tile counts as fresh. */
export const RESOURCE_FRESH_MS = 60_000;
+155
View File
@@ -0,0 +1,155 @@
/**
* Declared events, separate from the plugin.* bus any plugin can read.
*
* Each event is kept in a capped timeline. A subscriber bound to a user only
* hears about hosts that user can reach, checked at delivery. Events carry a
* depth, so a rule that fires an action that fires the same rule stops.
*/
import { randomUUID } from "node:crypto";
import { MAX_EVENT_DEPTH } from "./constants.js";
import { getActorTrail, runAsActor } from "../plugins/actor.js";
import { PermissionManager } from "../utils/permission-manager.js";
import { pluginLogger } from "../utils/logger.js";
import { createCurrentOperationRepository } from "../database/repositories/factory.js";
export interface OperationEventInput {
eventId: string;
pluginId: string;
hostId?: number;
severity?: string;
payload?: unknown;
runId?: string;
depth?: number;
}
export interface DeliveredEvent {
id: string;
eventId: string;
pluginId: string;
hostId?: number;
severity?: string;
payload?: unknown;
runId?: string;
depth: number;
at: string;
}
export interface OperationEventSubscription {
/** Exact id, `<pluginId>.*` or `*`. */
pattern: string;
handler: (event: DeliveredEvent) => void | Promise<void>;
/** Only events for hosts this user can reach, and runs the handler as them. */
userId?: string;
/** Skips repeats of one event and host inside this window. */
cooldownMs?: number;
}
interface Subscriber extends OperationEventSubscription {
pluginId: string;
lastFired: Map<string, number>;
}
const subscribers = new Set<Subscriber>();
function matches(pattern: string, eventId: string): boolean {
if (pattern === "*" || pattern === eventId) return true;
return pattern.endsWith(".*") && eventId.startsWith(pattern.slice(0, -1));
}
export function subscribeOperationEvents(
pluginId: string,
subscription: OperationEventSubscription,
): () => void {
const entry = { ...subscription, pluginId, lastFired: new Map() };
subscribers.add(entry);
return () => {
subscribers.delete(entry);
};
}
async function deliver(subscriber: Subscriber, event: DeliveredEvent) {
const key = `${event.eventId}|${event.hostId ?? ""}`;
if (subscriber.cooldownMs) {
const last = subscriber.lastFired.get(key) ?? 0;
if (Date.now() - last < subscriber.cooldownMs) return;
subscriber.lastFired.set(key, Date.now());
}
const { userId } = subscriber;
if (userId && event.hostId !== undefined) {
const access = await PermissionManager.getInstance().canAccessHost(
userId,
event.hostId,
"connect",
);
if (!access.hasAccess) return;
}
const fire = () => Promise.resolve(subscriber.handler(event));
await (userId
? runAsActor(userId, "service", fire, undefined, { depth: event.depth })
: fire());
}
/** Never throws. An event must not be able to fail what caused it. */
export async function emitOperationEvent(
input: OperationEventInput,
): Promise<void> {
try {
const trail = getActorTrail();
const depth = input.depth ?? trail.depth ?? 0;
const event: DeliveredEvent = {
id: randomUUID(),
eventId: input.eventId,
pluginId: input.pluginId,
hostId: input.hostId,
severity: input.severity,
payload: input.payload,
runId: input.runId ?? trail.runId,
depth,
at: new Date().toISOString(),
};
await createCurrentOperationRepository().insertEvent({
id: event.id,
eventId: event.eventId,
pluginId: event.pluginId,
hostId: event.hostId ?? null,
severity: event.severity ?? null,
payload:
event.payload === undefined ? null : JSON.stringify(event.payload),
runId: event.runId ?? null,
createdAt: event.at,
});
if (depth >= MAX_EVENT_DEPTH) {
pluginLogger.warn("Event chain stopped at the depth limit", {
operation: "operation_event_depth",
eventId: event.eventId,
});
return;
}
for (const subscriber of subscribers) {
if (!matches(subscriber.pattern, event.eventId)) continue;
void deliver(subscriber, event).catch((error) => {
pluginLogger.warn("Event subscriber failed", {
operation: "operation_event_delivery",
pluginId: subscriber.pluginId,
error: error instanceof Error ? error.message : String(error),
});
});
}
} catch (error) {
pluginLogger.warn("Could not record an event", {
operation: "operation_event",
error: error instanceof Error ? error.message : String(error),
});
}
}
/** Test seam. */
export function clearOperationEvents(): void {
subscribers.clear();
}
+891
View File
@@ -0,0 +1,891 @@
/**
* The one path every action takes, from any surface.
*
* In order: plugin up, grant still held, input valid, role permission, host
* access, key scope, destructive confirm, approval, limits, run row, audit.
* Handlers run in-process, so this is an access check for callers, not a
* sandbox around plugins.
*
* Handler failures stay here. They never reach the plugin error budget, or five
* failed hosts in a rollout would switch the plugin off.
*/
import { randomUUID } from "node:crypto";
import {
capText,
matchesWhen,
parseOperationInput,
redactFields,
riskRank,
type OperationAction,
type OperationRisk,
} from "@termix-ssh/plugin-sdk/operations";
import { MAX_EVENT_DEPTH } from "./constants.js";
import { getAction, type OperationRegistration } from "./registry.js";
import {
canSelfApprove,
evaluateApproval,
type ApprovalDecision,
} from "./approvals.js";
import { withLimits } from "./limits.js";
import { emitOperationEvent } from "./events.js";
import { dropResourceCache } from "./resources.js";
import { loadOperationHost, type OperationHost } from "./hosts.js";
import { runAsActor, type ActorOrigin } from "../plugins/actor.js";
import { hasCapability } from "../plugins/permissions.js";
import { qualifyPermission } from "../plugins/manifest.js";
import { safeModeOn } from "../plugins/safe-mode.js";
import { PermissionManager } from "../utils/permission-manager.js";
import { logAudit } from "../utils/audit-logger.js";
import {
decryptSystemSecret,
encryptSystemSecret,
isSystemEncrypted,
} from "../utils/system-secret-crypto.js";
import { pluginLogger } from "../utils/logger.js";
import {
createCurrentOperationRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import type {
OperationApprovalRecord,
OperationRunRecord,
} from "../database/repositories/operation-repository.js";
const MAX_OUTPUT = 32_000;
const DEFAULT_TIMEOUT_MS = 300_000;
const READ_TIMEOUT_MS = 60_000;
const DEFAULT_APPROVAL_MINUTES = 24 * 60;
const DRAIN_WAIT_MS = 10_000;
export type OperationErrorCode =
| "NOT_FOUND"
| "UNAVAILABLE"
| "INVALID_INPUT"
| "FORBIDDEN"
| "SCOPE"
| "NEEDS_CONFIRM"
| "DENIED"
| "TOO_DEEP"
| "STALE"
| "CONFLICT"
| "SAFE_MODE";
const STATUS: Record<OperationErrorCode, number> = {
NOT_FOUND: 404,
UNAVAILABLE: 503,
INVALID_INPUT: 400,
FORBIDDEN: 403,
SCOPE: 403,
NEEDS_CONFIRM: 409,
DENIED: 403,
TOO_DEEP: 409,
STALE: 409,
CONFLICT: 409,
SAFE_MODE: 409,
};
/** Refused before anything ran. Carries a status so routes can pass it on. */
export class OperationError extends Error {
readonly status: number;
constructor(
readonly code: OperationErrorCode,
message: string,
readonly details?: unknown,
) {
super(message);
this.name = "OperationError";
this.status = STATUS[code];
}
}
/** A handler throws this when a host needs a person to sign in first. */
export class OperationNeedsInteraction extends Error {
readonly code = "NEEDS_INTERACTION";
}
export interface OperationRequest {
actionId: string;
hostId?: number;
resourceKey?: string;
input?: unknown;
dryRun?: boolean;
/** A person agreed to a destructive action. */
confirmed?: boolean;
idempotencyKey?: string;
batchId?: string;
undoOf?: string;
/** Background reads for tables and tiles. Writes no run row. */
internal?: boolean;
userId: string;
origin: ActorOrigin;
apiKeyScopes?: string[] | null;
depth?: number;
}
export interface OperationOutcome {
runId: string | null;
status: string;
summary?: string;
changed?: boolean;
data?: unknown;
error?: string;
errorCode?: string;
approvalId?: string;
}
interface Prepared {
registration: OperationRegistration;
action: OperationAction;
pluginVersion: string;
input: Record<string, unknown>;
host: OperationHost | null;
effectiveRisk: OperationRisk;
}
/** The sealed copy an approval is bound to. */
interface ApprovalSnapshot {
runId: string;
actionId: string;
actionVersion: number;
pluginVersion: string;
hostId: number | null;
hostUpdatedAt: string | null;
resourceKey: string | null;
input: Record<string, unknown>;
}
const active = new Map<
string,
{ pluginId: string; controller: AbortController; done: Promise<unknown> }
>();
const draining = new Set<string>();
function now(): string {
return new Date().toISOString();
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function loadedPlugin(pluginId: string) {
const { getPluginRuntime } = await import("../plugins/index.js");
return getPluginRuntime().loader.get(pluginId);
}
async function usernameOf(userId: string): Promise<string> {
try {
const user = await createCurrentUserRepository().findById(userId);
return user?.username ?? userId;
} catch {
return userId;
}
}
/** Every check that does not depend on who approved it. */
async function prepare(request: OperationRequest): Promise<Prepared> {
if (safeModeOn()) {
throw new OperationError("SAFE_MODE", "Actions are off in safe mode");
}
if ((request.depth ?? 0) > MAX_EVENT_DEPTH) {
throw new OperationError("TOO_DEEP", "Too many actions led to this one");
}
const registration = getAction(request.actionId);
if (!registration) {
throw new OperationError(
"NOT_FOUND",
`No action "${request.actionId}" is available`,
);
}
const { action, pluginId } = registration;
const plugin = await loadedPlugin(pluginId);
if (plugin?.state !== "active" || draining.has(pluginId)) {
throw new OperationError("UNAVAILABLE", `${pluginId} is not running`);
}
// Rechecked at use, so revoking the grant stops it on the next call.
if (
!(await hasCapability(
pluginId,
"operations:provide",
plugin.manifest.capabilities,
))
) {
throw new OperationError("UNAVAILABLE", `${pluginId} may not run actions`);
}
const parsed = parseOperationInput(action.inputs, request.input);
if (parsed.errors.length > 0) {
throw new OperationError(
"INVALID_INPUT",
parsed.errors.join("; "),
parsed.errors,
);
}
const permissions = PermissionManager.getInstance();
const permission = qualifyPermission(pluginId, action.permission);
if (!(await permissions.hasPermission(request.userId, permission))) {
throw new OperationError(
"FORBIDDEN",
`This needs the ${permission} permission`,
);
}
let host: OperationHost | null = null;
if (action.scope !== "server") {
if (request.hostId === undefined) {
throw new OperationError("INVALID_INPUT", "This action needs a host");
}
if (action.scope === "resource" && !request.resourceKey) {
throw new OperationError("INVALID_INPUT", "This action needs a target");
}
const access = await permissions.canAccessHost(
request.userId,
request.hostId,
action.hostAccess ?? "edit",
);
if (!access.hasAccess) {
throw new OperationError(
"FORBIDDEN",
`You need ${action.hostAccess ?? "edit"} access to this host`,
);
}
host = await loadOperationHost(request.hostId, plugin.manifest);
if (!host) throw new OperationError("NOT_FOUND", "Host not found");
if (!matchesWhen(action.when, host)) {
throw new OperationError(
"UNAVAILABLE",
"This action does not apply to this host",
);
}
}
// A claim by the plugin, so only signed official plugins are taken at
// their word when rules decide what needs a person.
const official =
plugin.source === "bundled" || plugin.signedTier === "official";
const effectiveRisk: OperationRisk =
!official && action.risk === "read" ? "change" : action.risk;
return {
registration,
action,
pluginVersion: plugin.manifest.version,
input: parsed.values,
host,
effectiveRisk,
};
}
function checkKeyScope(request: OperationRequest, action: OperationAction) {
const scopes = request.apiKeyScopes;
if (scopes === undefined || scopes === null) return;
if (scopes.includes("operations:run")) return;
if (scopes.includes("operations:read") && action.risk === "read") return;
throw new OperationError("SCOPE", "This API key may not run this action");
}
function originText(origin: ActorOrigin): string {
return JSON.stringify(origin);
}
function auditRun(
request: OperationRequest,
username: string,
action: OperationAction,
host: OperationHost | null,
success: boolean,
errorMessage?: string,
verb = "operation_run",
): void {
void logAudit({
userId: request.userId,
username,
action: verb,
resourceType: "operation",
resourceId: action.id,
resourceName: host?.name ?? undefined,
details: originText(request.origin),
success,
errorMessage,
}).catch(() => {});
}
async function execute(
prepared: Prepared,
request: OperationRequest,
runId: string | null,
username: string,
): Promise<OperationOutcome> {
const { action, registration, input, host } = prepared;
const repository = createCurrentOperationRepository();
const controller = new AbortController();
const dryRun = request.dryRun === true;
const timeoutMs =
action.timeoutMs ??
(action.risk === "read" ? READ_TIMEOUT_MS : DEFAULT_TIMEOUT_MS);
const lines: string[] = [];
const context = {
hostId: request.hostId,
resourceKey: request.resourceKey,
runId,
dryRun,
signal: controller.signal,
log: (line: string) => {
if (lines.length < 200) lines.push(line);
},
};
const work = async () => {
if (dryRun && action.dryRun !== "native") {
const summary = registration.handler.describe
? await registration.handler.describe(input, context)
: `Would run ${action.id}`;
return { summary, changed: false };
}
return registration.handler.run(input, context);
};
const timer = setTimeout(() => controller.abort(), timeoutMs);
timer.unref?.();
const aborted = new Promise<never>((_, reject) => {
controller.signal.addEventListener("abort", () =>
reject(new Error("The action was stopped or timed out")),
);
});
aborted.catch(() => {});
// Settles when the handler really stops, which can be after the run gives
// up on it. A plugin going down waits on this, not on the run.
let handlerStopped: () => void = () => {};
const stopped = new Promise<void>((resolve) => {
handlerStopped = resolve;
});
const startWork = () => {
// Cancelled while waiting for a slot, so it never starts.
if (controller.signal.aborted) {
handlerStopped();
return aborted;
}
const handling = Promise.resolve().then(work);
void handling.finally(handlerStopped).catch(() => {});
return Promise.race([handling, aborted]);
};
const running = withLimits(
{ hostId: request.hostId, exclusive: action.exclusive },
() =>
runAsActor(request.userId, "service", startWork, undefined, {
origin: request.origin,
apiKeyScopes: request.apiKeyScopes,
runId: runId ?? undefined,
depth: (request.depth ?? 0) + 1,
}),
);
if (runId) {
active.set(runId, {
pluginId: registration.pluginId,
controller,
done: stopped,
});
}
let outcome: OperationOutcome;
try {
const result = await running;
const data = redactFields(action.outputs, result.data);
outcome = {
runId,
status: "success",
summary: result.summary,
changed: result.changed,
data,
};
if (runId) {
await repository.updateRunFrom(runId, "running", {
status: "success",
summary: capText(result.summary ?? "", 2000),
changed: result.changed ?? null,
output:
data === undefined ? null : capText(JSON.stringify(data), MAX_OUTPUT),
undoInput: result.undoInput ? JSON.stringify(result.undoInput) : null,
sealedInput: null,
finishedAt: now(),
});
}
} catch (error) {
const needsPerson =
(error as { code?: unknown } | null)?.code === "NEEDS_INTERACTION";
const stopping = draining.has(registration.pluginId);
const status = needsPerson
? "needs_interaction"
: stopping
? "interrupted"
: "failed";
const message = stopping
? "The plugin stopped during this run. Check the host."
: capText(errorText(error), 2000);
outcome = { runId, status, error: message };
if (needsPerson) outcome.errorCode = "NEEDS_INTERACTION";
if (runId) {
await repository.updateRunFrom(runId, "running", {
status,
error: message,
errorCode: outcome.errorCode ?? null,
sealedInput: null,
finishedAt: now(),
});
}
} finally {
clearTimeout(timer);
if (runId && !draining.has(registration.pluginId)) active.delete(runId);
}
if (action.risk !== "read" && !dryRun) {
if (request.hostId !== undefined && outcome.status === "success") {
dropResourceCache(request.hostId);
}
auditRun(
request,
username,
action,
host,
outcome.status === "success",
outcome.error,
);
void emitOperationEvent({
eventId:
outcome.status === "success"
? "operation.succeeded"
: "operation.failed",
pluginId: registration.pluginId,
hostId: request.hostId,
severity: outcome.status === "success" ? "info" : "warning",
payload: {
actionId: action.id,
summary: outcome.summary,
error: outcome.error,
origin: request.origin,
},
runId: runId ?? undefined,
depth: (request.depth ?? 0) + 1,
});
}
return outcome;
}
function outcomeOf(
run: OperationRunRecord,
approvalId?: string,
): OperationOutcome {
return {
runId: run.id,
status: run.status,
summary: run.summary ?? undefined,
changed: run.changed ?? undefined,
error: run.error ?? undefined,
errorCode: run.errorCode ?? undefined,
approvalId,
};
}
async function requestApproval(
prepared: Prepared,
request: OperationRequest,
decision: ApprovalDecision,
run: { id: string; username: string; createdAt: string },
): Promise<OperationOutcome> {
const { action, host, input } = prepared;
const repository = createCurrentOperationRepository();
const snapshot: ApprovalSnapshot = {
runId: run.id,
actionId: action.id,
actionVersion: action.version ?? 1,
pluginVersion: prepared.pluginVersion,
hostId: request.hostId ?? null,
hostUpdatedAt: host?.updatedAt ?? null,
resourceKey: request.resourceKey ?? null,
input,
};
await repository.updateRunFrom(run.id, "created", {
status: "pending_approval",
summary: decision.reason ?? null,
sealedInput: await encryptSystemSecret(JSON.stringify(snapshot)),
});
const minutes = decision.expiresInMinutes ?? DEFAULT_APPROVAL_MINUTES;
const approvalId = randomUUID();
await repository.insertApproval({
id: approvalId,
runId: run.id,
requestedBy: request.userId,
reason: decision.reason ?? null,
allowSelf: decision.allowSelf === true || canSelfApprove(request.origin),
status: "pending",
createdAt: run.createdAt,
expiresAt: new Date(Date.now() + minutes * 60_000).toISOString(),
});
void emitOperationEvent({
eventId: "operation.approval-requested",
pluginId: prepared.registration.pluginId,
hostId: request.hostId,
severity: "warning",
payload: { actionId: action.id, approvalId },
runId: run.id,
});
return { runId: run.id, status: "pending_approval", approvalId };
}
/** Runs an action, or parks it for approval. Throws OperationError if refused. */
export async function runOperation(
request: OperationRequest,
): Promise<OperationOutcome> {
const registration = getAction(request.actionId);
if (registration) checkKeyScope(request, registration.action);
const repository = createCurrentOperationRepository();
if (request.idempotencyKey) {
const earlier = await repository.findByIdempotencyKey(
request.userId,
request.idempotencyKey,
);
if (earlier) return outcomeOf(earlier);
}
const prepared = await prepare(request);
const { action } = prepared;
const dryRun = request.dryRun === true;
const isRead = action.risk === "read";
if (action.risk === "destructive" && !dryRun && !request.confirmed) {
throw new OperationError("NEEDS_CONFIRM", "This action needs a confirm");
}
let decision: ApprovalDecision = { decision: "allow" };
if (!dryRun && !request.internal) {
decision = await evaluateApproval({
actionId: action.id,
pluginId: prepared.registration.pluginId,
risk: action.risk,
effectiveRisk: prepared.effectiveRisk,
origin: request.origin,
userId: request.userId,
hostId: request.hostId,
hostTags: prepared.host?.tags,
batchId: request.batchId,
});
}
const username = await usernameOf(request.userId);
if (decision.decision === "deny") {
auditRun(
request,
username,
action,
prepared.host,
false,
decision.reason,
"operation_denied",
);
throw new OperationError(
"DENIED",
decision.reason ?? "A rule refused this action",
);
}
// Internal reads poll constantly and would flood history and the save loop.
if (request.internal && isRead) {
return execute(prepared, request, null, username);
}
const run = {
id: randomUUID(),
actionId: action.id,
actionVersion: action.version ?? 1,
pluginId: prepared.registration.pluginId,
pluginVersion: prepared.pluginVersion,
userId: request.userId,
username,
origin: originText(request.origin),
hostId: request.hostId ?? null,
hostName: prepared.host?.name ?? null,
batchId: request.batchId ?? null,
dryRun,
risk: action.risk,
status: decision.decision === "approve" ? "created" : "running",
input: JSON.stringify(redactFields(action.inputs, prepared.input)),
undoOf: request.undoOf ?? null,
idempotencyKey: request.idempotencyKey ?? null,
createdAt: now(),
startedAt: decision.decision === "approve" ? null : now(),
};
await repository.insertRun(run);
if (decision.decision === "approve") {
return requestApproval(prepared, request, decision, run);
}
return execute(prepared, request, run.id, username);
}
function originOf(run: OperationRunRecord): ActorOrigin {
try {
return JSON.parse(run.origin) as ActorOrigin;
} catch {
return { kind: "api" };
}
}
async function failStale(run: OperationRunRecord, why: string): Promise<void> {
await createCurrentOperationRepository().updateRunFrom(
run.id,
"pending_approval",
{
status: "rejected",
error: why,
sealedInput: null,
finishedAt: now(),
},
);
}
/**
* Records a person's decision. One shot: the row moves from pending once, so a
* second click or a replay does nothing. What runs is the sealed copy, never
* what the caller sends now.
*/
export async function decideOperationApproval(options: {
approvalId: string;
approverId: string;
approve: boolean;
comment?: string;
impersonated?: boolean;
}): Promise<OperationOutcome> {
const { approvalId, approverId, approve, comment } = options;
const repository = createCurrentOperationRepository();
const approval = await repository.findApproval(approvalId);
if (!approval) throw new OperationError("NOT_FOUND", "Approval not found");
if (approval.status !== "pending") {
throw new OperationError("CONFLICT", "This was already decided");
}
const run = await repository.findRun(approval.runId);
if (!run) throw new OperationError("NOT_FOUND", "Run not found");
if (options.impersonated) {
throw new OperationError("FORBIDDEN", "You cannot approve as another user");
}
if (approval.requestedBy === approverId && !approval.allowSelf) {
throw new OperationError(
"FORBIDDEN",
"Someone else has to approve your request",
);
}
if (new Date(approval.expiresAt).getTime() < Date.now()) {
await repository.decideApproval(approvalId, "expired", null, null, now());
await failStale(run, "The approval expired");
throw new OperationError("STALE", "This approval expired");
}
// Approving needs the same rights as running it.
const registration = getAction(run.actionId);
if (registration) {
const permissions = PermissionManager.getInstance();
const needed = qualifyPermission(
registration.pluginId,
registration.action.permission,
);
const allowed =
(await permissions.hasPermission(approverId, needed)) &&
(run.hostId === null ||
(
await permissions.canAccessHost(
approverId,
run.hostId,
registration.action.hostAccess ?? "edit",
)
).hasAccess);
if (!allowed) {
throw new OperationError(
"FORBIDDEN",
"You need the same access as this action to approve it",
);
}
}
const won = await repository.decideApproval(
approvalId,
approve ? "approved" : "rejected",
approverId,
comment?.slice(0, 500) ?? null,
now(),
);
if (!won) throw new OperationError("CONFLICT", "This was already decided");
const username = await usernameOf(approverId);
void logAudit({
userId: approverId,
username,
action: approve ? "operation_approved" : "operation_rejected",
resourceType: "operation",
resourceId: run.actionId,
details: run.id,
success: true,
}).catch(() => {});
if (!approve) {
await repository.updateRunFrom(run.id, "pending_approval", {
status: "rejected",
sealedInput: null,
finishedAt: now(),
});
return { runId: run.id, status: "rejected" };
}
return runApproved(run, approval);
}
async function readSnapshot(
run: OperationRunRecord,
): Promise<ApprovalSnapshot | null> {
if (!run.sealedInput || !isSystemEncrypted(run.sealedInput)) return null;
try {
const snapshot = JSON.parse(
await decryptSystemSecret(run.sealedInput),
) as ApprovalSnapshot;
return snapshot.runId === run.id ? snapshot : null;
} catch {
return null;
}
}
async function runApproved(
run: OperationRunRecord,
approval: OperationApprovalRecord,
): Promise<OperationOutcome> {
const snapshot = await readSnapshot(run);
if (!snapshot || !run.userId) {
await failStale(run, "The approved request could not be read");
throw new OperationError("STALE", "The approved request could not be read");
}
const request: OperationRequest = {
actionId: snapshot.actionId,
hostId: snapshot.hostId ?? undefined,
resourceKey: snapshot.resourceKey ?? undefined,
input: snapshot.input,
confirmed: true,
batchId: run.batchId ?? undefined,
userId: run.userId,
origin: originOf(run),
};
// The requester's own rights, as of now. They may have changed.
let prepared: Prepared;
try {
prepared = await prepare(request);
} catch (error) {
await failStale(run, errorText(error));
throw error;
}
const changedSince =
prepared.pluginVersion !== snapshot.pluginVersion ||
(prepared.action.version ?? 1) !== snapshot.actionVersion ||
(prepared.host?.updatedAt ?? null) !== snapshot.hostUpdatedAt;
if (changedSince) {
await failStale(run, "The host or plugin changed after this was requested");
throw new OperationError(
"STALE",
"The host or plugin changed after this was requested",
);
}
const repository = createCurrentOperationRepository();
const claimed = await repository.updateRunFrom(run.id, "pending_approval", {
status: "running",
startedAt: now(),
});
if (!claimed) throw new OperationError("CONFLICT", "This already ran");
const outcome = await execute(prepared, request, run.id, run.username);
return { ...outcome, approvalId: approval.id };
}
export async function cancelOperationRun(
run: OperationRunRecord,
): Promise<boolean> {
const repository = createCurrentOperationRepository();
if (run.status === "pending_approval") {
const approval = await repository.findApprovalForRun(run.id);
if (approval) {
await repository.decideApproval(
approval.id,
"rejected",
null,
"cancelled",
now(),
);
}
return repository.updateRunFrom(run.id, "pending_approval", {
status: "cancelled",
sealedInput: null,
finishedAt: now(),
});
}
const running = active.get(run.id);
if (!running) return false;
running.controller.abort();
return true;
}
/**
* Called when a plugin stops, before its SSH connections are torn down. New
* runs are refused and running ones told to stop. Anything still going after
* the wait has an unknown outcome and is never retried.
*/
export async function drainPlugin(pluginId: string): Promise<void> {
draining.add(pluginId);
try {
const runs = [...active.entries()].filter(
([, entry]) => entry.pluginId === pluginId,
);
if (runs.length === 0) return;
for (const [, entry] of runs) entry.controller.abort();
await Promise.race([
Promise.all(runs.map(([, entry]) => entry.done)),
new Promise((resolve) => setTimeout(resolve, DRAIN_WAIT_MS)),
]);
const repository = createCurrentOperationRepository();
for (const [runId] of runs) {
await repository.updateRunFrom(runId, "running", {
status: "interrupted",
error: "The plugin stopped during this run. Check the host.",
sealedInput: null,
finishedAt: now(),
});
active.delete(runId);
}
} catch (error) {
pluginLogger.warn("Could not finish stopping runs", {
operation: "operation_drain",
pluginId,
error: errorText(error),
});
} finally {
draining.delete(pluginId);
}
}
export function riskAtLeast(
risk: OperationRisk,
floor: OperationRisk,
): boolean {
return riskRank(risk) >= riskRank(floor);
}
/** Test seam. */
export function clearOperationGate(): void {
active.clear();
draining.clear();
}
+68
View File
@@ -0,0 +1,68 @@
/**
* The small view of a host the operations layer needs: enough to name it, test
* an action's `when`, and notice it changed. No secret field is ever read here.
*/
import type { OperationWhen } from "@termix-ssh/plugin-sdk/operations";
import type { PluginManifest } from "../plugins/manifest.js";
import { getSetting } from "../plugins/settings.js";
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
export interface OperationHost {
id: number;
name: string;
updatedAt: string | null;
tags: string[];
os: string | null;
/** Only the setting keys some `when` asks about. */
settings: Record<string, unknown>;
}
export function splitTags(tags: string | null | undefined): string[] {
return (tags ?? "")
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
}
async function settingsFor(
manifest: PluginManifest,
hostId: number,
): Promise<Record<string, unknown>> {
const keys = new Set<string>();
for (const action of manifest.contributes?.operations?.actions ?? []) {
for (const key of Object.keys(action.when?.settings ?? {})) keys.add(key);
}
const settings: Record<string, unknown> = {};
for (const key of keys) {
settings[key] = await getSetting(manifest, "host", hostId, key);
}
return settings;
}
/** A host with the settings its plugin's `when` rules read. */
export async function loadOperationHost(
hostId: number,
manifest: PluginManifest,
): Promise<OperationHost | null> {
const repository = createCurrentHostResolutionRepository();
const ownerId = await repository.findHostOwnerId(hostId);
if (!ownerId) return null;
const host = (await repository.findHostById(hostId, ownerId)) as {
name: string | null;
ip: string;
tags: string | null;
updatedAt?: string | null;
} | null;
if (!host) return null;
return {
id: hostId,
name: host.name || host.ip,
updatedAt: host.updatedAt ?? null,
tags: splitTags(host.tags),
os: null,
settings: await settingsFor(manifest, hostId),
};
}
export type { OperationWhen };
+44
View File
@@ -0,0 +1,44 @@
/**
* What a scoped API key may reach. Enforced where keys are authenticated, so a
* limited key cannot step around the gate through /plugin-api or a ws ticket. A
* key with no scopes has full access, which is every key made before this.
*/
export const API_KEY_SCOPES = ["operations:read", "operations:run"] as const;
const MCP_PATH = /^\/plugin-api\/[a-z0-9-]+\/mcp(\/|$)/;
const OPERATIONS_PATH = /^\/operations(\/|$)/;
export function parseKeyScopes(
raw: string | null | undefined,
): string[] | null {
if (!raw) return null;
try {
const value = JSON.parse(raw);
return Array.isArray(value)
? value.filter((scope): scope is string => typeof scope === "string")
: [];
} catch {
// Unreadable means no access, not full access.
return [];
}
}
export function keyScopeAllows(
scopes: string[] | null,
method: string,
path: string,
): boolean {
if (scopes === null) return true;
const clean = path.split("?")[0];
if (MCP_PATH.test(clean)) {
return (
scopes.includes("operations:read") || scopes.includes("operations:run")
);
}
if (!OPERATIONS_PATH.test(clean)) return false;
if (scopes.includes("operations:run")) return true;
if (!scopes.includes("operations:read")) return false;
// The gate still checks each action's risk on a run.
return method === "GET" || /^\/operations\/[^/]+\/run$/.test(clean);
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Caps on how much runs at once. The SSH pool caps per key, not per host, so a
* large rollout or an all hosts table needs its own limit here.
*/
export const MAX_RUNNING = 32;
export const MAX_PER_HOST = 3;
class Slots {
private used = 0;
private readonly waiting: (() => void)[] = [];
constructor(private readonly size: number) {}
async take(): Promise<void> {
if (this.used < this.size) {
this.used++;
return;
}
await new Promise<void>((resolve) => this.waiting.push(resolve));
}
give(): void {
const next = this.waiting.shift();
if (next) next();
else this.used--;
}
get idle(): boolean {
return this.used === 0 && this.waiting.length === 0;
}
}
const everything = new Slots(MAX_RUNNING);
const perHost = new Map<number, Slots>();
const exclusive = new Map<number, Slots>();
function slotsFor(
map: Map<number, Slots>,
hostId: number,
size: number,
): Slots {
let slots = map.get(hostId);
if (!slots) {
slots = new Slots(size);
map.set(hostId, slots);
}
return slots;
}
/** Runs `work` once there is room. Exclusive work on a host goes one at a time. */
export async function withLimits<T>(
options: { hostId?: number; exclusive?: boolean },
work: () => Promise<T>,
): Promise<T> {
const { hostId } = options;
const held: { map?: Map<number, Slots>; slots: Slots }[] = [];
const hold = async (slots: Slots, map?: Map<number, Slots>) => {
await slots.take();
held.push({ slots, map });
};
try {
// Same order everywhere, so two runs cannot wait on each other.
if (hostId !== undefined && options.exclusive) {
await hold(slotsFor(exclusive, hostId, 1), exclusive);
}
if (hostId !== undefined) {
await hold(slotsFor(perHost, hostId, MAX_PER_HOST), perHost);
}
await hold(everything);
return await work();
} finally {
for (const { slots, map } of held.reverse()) {
slots.give();
if (map && hostId !== undefined && slots.idle) map.delete(hostId);
}
}
}
/** Shares one in-flight promise per key, so one read serves every waiter. */
const inflight = new Map<string, Promise<unknown>>();
export function coalesce<T>(key: string, work: () => Promise<T>): Promise<T> {
const running = inflight.get(key);
if (running) return running as Promise<T>;
const started = work().finally(() => inflight.delete(key));
inflight.set(key, started);
return started;
}
+47
View File
@@ -0,0 +1,47 @@
/** Startup sweep and periodic cleanup for runs, approvals and events. */
import { RETENTION_DAYS } from "./constants.js";
import { pluginLogger } from "../utils/logger.js";
import { createCurrentOperationRepository } from "../database/repositories/factory.js";
const SWEEP_MS = 10 * 60_000;
let timer: ReturnType<typeof setInterval> | null = null;
/** Parks expired approvals and trims history. Returns rows removed. */
export async function sweepOperations(at = new Date()): Promise<number> {
const repository = createCurrentOperationRepository();
const iso = at.toISOString();
for (const approval of await repository.listExpiredApprovals(iso)) {
await repository.decideApproval(approval.id, "expired", null, null, iso);
await repository.updateRunFrom(approval.runId, "pending_approval", {
status: "expired",
sealedInput: null,
finishedAt: iso,
});
}
const cutoff = new Date(at.getTime() - RETENTION_DAYS * 86_400_000);
return repository.prune(cutoff.toISOString());
}
/** Anything still running at boot lost its process, so its outcome is unknown. */
export async function startOperationsMaintenance(): Promise<void> {
await createCurrentOperationRepository().markRunningInterrupted(
new Date().toISOString(),
);
await sweepOperations();
timer ??= setInterval(() => {
sweepOperations().catch((error) => {
pluginLogger.warn("Operations cleanup failed", {
operation: "operations_sweep",
error: error instanceof Error ? error.message : String(error),
});
});
}, SWEEP_MS);
timer.unref?.();
}
export function stopOperationsMaintenance(): void {
if (timer) clearInterval(timer);
timer = null;
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Handlers plugins registered for the actions their manifest declares.
*
* Same generation rule as service-registry.ts: a plugin that crashed and
* restarted must not have its old disposer remove the new registration, and a
* run that resolved a handler before a reload must not call the stale one.
*/
import type {
OperationAction,
OperationResult,
} from "@termix-ssh/plugin-sdk/operations";
export interface OperationRunContext {
hostId?: number;
resourceKey?: string;
runId: string | null;
dryRun: boolean;
signal: AbortSignal;
log: (line: string) => void;
}
export interface OperationHandler {
run: (
input: Record<string, unknown>,
context: OperationRunContext,
) => Promise<OperationResult> | OperationResult;
/** What a dry run reports when the action has no native dry run. */
describe?: (
input: Record<string, unknown>,
context: OperationRunContext,
) => string | Promise<string>;
}
export interface OperationRegistration {
pluginId: string;
action: OperationAction;
handler: OperationHandler;
generation: number;
}
const registrations = new Map<string, OperationRegistration>();
let generationCounter = 0;
export function registerAction(
pluginId: string,
action: OperationAction,
handler: OperationHandler,
): OperationRegistration {
const existing = registrations.get(action.id);
if (existing && existing.pluginId !== pluginId) {
throw new Error(
`Action "${action.id}" is already registered by ${existing.pluginId}`,
);
}
const entry = {
pluginId,
action,
handler,
generation: ++generationCounter,
};
registrations.set(action.id, entry);
return entry;
}
/** Removes only the exact registration given. */
export function revokeAction(registration: OperationRegistration): boolean {
const current = registrations.get(registration.action.id);
if (!current || current.generation !== registration.generation) return false;
return registrations.delete(registration.action.id);
}
export function getAction(id: string): OperationRegistration | undefined {
return registrations.get(id);
}
export function listActions(): OperationRegistration[] {
return [...registrations.values()];
}
/** Test seam. */
export function clearOperationRegistry(): void {
registrations.clear();
}
+231
View File
@@ -0,0 +1,231 @@
/**
* Reads for All hosts tables and status tiles.
*
* Asking every host over SSH on each page load cannot hold up, so a request
* answers right away from memory and refreshes stale hosts behind it. The page
* polls and the table fills in as hosts answer. Results stay in memory, never
* SQLite, since a save rewrites the whole database.
*
* Keys include the user: someone a host is shared with can have their own
* login there and see different rows.
*/
import type {
ResourceListResult,
SignalResult,
SignalStatus,
} from "@termix-ssh/plugin-sdk/operations";
import { RESOURCE_FRESH_MS } from "./constants.js";
import { coalesce } from "./limits.js";
import { getAction } from "./registry.js";
import { matchesWhen } from "@termix-ssh/plugin-sdk/operations";
import { loadOperationHost } from "./hosts.js";
import type { ActorOrigin } from "../plugins/actor.js";
import { hostStatus } from "../plugins/host-status-provider.js";
import { PermissionManager } from "../utils/permission-manager.js";
import { loadProbeTargets } from "../plugins/ctx-probe.js";
export type HostReadState =
"ok" | "loading" | "offline" | "error" | "no-access" | "not-applicable";
interface CacheEntry {
value: unknown;
error?: string;
fetchedAt: number;
}
export interface HostRead<T> {
hostId: number;
state: HostReadState;
value?: T;
error?: string;
fetchedAt?: string;
}
const cache = new Map<string, CacheEntry>();
const MAX_ENTRIES = 20_000;
function cacheKey(userId: string, actionId: string, hostId: number): string {
return `${userId}|${actionId}|${hostId}`;
}
/** After a change, the next read of this host goes back to the host. */
export function dropResourceCache(hostId: number): void {
const suffix = `|${hostId}`;
for (const key of cache.keys()) {
if (key.endsWith(suffix)) cache.delete(key);
}
}
function remember(key: string, entry: CacheEntry): void {
if (cache.size >= MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest) cache.delete(oldest);
}
cache.delete(key);
cache.set(key, entry);
}
function isOffline(hostId: number): boolean {
const status = hostStatus.get(hostId) as
string | { status?: string } | null | undefined;
const value = typeof status === "string" ? status : status?.status;
return value === "offline";
}
interface ReadOptions {
userId: string;
actionId: string;
hostIds?: number[];
refresh?: boolean;
input?: Record<string, unknown>;
origin: ActorOrigin;
}
function refreshHost(
options: ReadOptions,
hostId: number,
key: string,
): Promise<unknown> {
return coalesce(key, async () => {
const { runOperation, OperationError } = await import("./gate.js");
try {
const outcome = await runOperation({
actionId: options.actionId,
hostId,
input: options.input,
internal: true,
userId: options.userId,
origin: options.origin,
});
if (outcome.status !== "success") {
remember(key, {
value: undefined,
error: outcome.error ?? outcome.status,
fetchedAt: Date.now(),
});
return;
}
remember(key, { value: outcome.data, fetchedAt: Date.now() });
} catch (error) {
remember(key, {
value: undefined,
error:
error instanceof OperationError || error instanceof Error
? error.message
: String(error),
fetchedAt: Date.now(),
});
}
});
}
/** One entry per host the user can reach. Never waits on a host. */
export async function readAcrossHosts<T>(
options: ReadOptions,
): Promise<HostRead<T>[]> {
const registration = getAction(options.actionId);
if (!registration) return [];
const { action } = registration;
const { getPluginRuntime } = await import("../plugins/index.js");
const manifest = getPluginRuntime().loader.get(
registration.pluginId,
)?.manifest;
if (!manifest) return [];
const permissions = PermissionManager.getInstance();
const targets = await loadProbeTargets(options.userId, options.hostIds);
const reads: HostRead<T>[] = [];
for (const target of targets) {
const hostId = target.id;
const access = await permissions.canAccessHost(
options.userId,
hostId,
action.hostAccess ?? "connect",
);
if (!access.hasAccess) {
reads.push({ hostId, state: "no-access" });
continue;
}
if (action.when) {
const host = await loadOperationHost(hostId, manifest);
if (!host || !matchesWhen(action.when, host)) continue;
}
const key = cacheKey(options.userId, options.actionId, hostId);
const cached = cache.get(key);
const fresh = cached && Date.now() - cached.fetchedAt < RESOURCE_FRESH_MS;
if (isOffline(hostId)) {
reads.push({
hostId,
state: "offline",
value: cached?.value as T | undefined,
fetchedAt: cached
? new Date(cached.fetchedAt).toISOString()
: undefined,
});
continue;
}
if (!fresh || options.refresh) void refreshHost(options, hostId, key);
if (!cached) {
reads.push({ hostId, state: "loading" });
} else if (cached.error) {
reads.push({
hostId,
state: "error",
error: cached.error,
fetchedAt: new Date(cached.fetchedAt).toISOString(),
});
} else {
reads.push({
hostId,
state: "ok",
value: cached.value as T,
fetchedAt: new Date(cached.fetchedAt).toISOString(),
});
}
}
return reads;
}
export interface ResourceRow {
hostId: number;
key: string;
row: Record<string, unknown>;
}
/** Flattens per host reads into table rows, keeping each host's state. */
export function flattenResourceRows(
reads: HostRead<ResourceListResult>[],
keyField: string,
): { rows: ResourceRow[]; hosts: Omit<HostRead<unknown>, "value">[] } {
const rows: ResourceRow[] = [];
const hosts: Omit<HostRead<unknown>, "value">[] = [];
for (const read of reads) {
const { value, ...state } = read;
hosts.push(state);
for (const row of value?.rows ?? []) {
rows.push({ hostId: read.hostId, key: String(row[keyField]), row });
}
}
return { rows, hosts };
}
const WORST: SignalStatus[] = ["fail", "warn", "unknown", "ok"];
/** The worst status across hosts, for a server level tile. */
export function worstStatus(
results: (SignalResult | undefined)[],
): SignalStatus {
for (const status of WORST) {
if (results.some((result) => result?.status === status)) return status;
}
return "unknown";
}
/** Test seam. */
export function clearResourceCache(): void {
cache.clear();
}