A user could not get the VirtualHere plugin to use their VirtualHere client and asked, reasonably, where the logs were. There was no good answer, and the reason they were stuck turned out to be ours. **The runner could not see /tmp.** `punktfunk-scripting.service` set PrivateTmp=yes, which hands the unit a private tmpfs. But integrating with things already running on the box is the entire job of a plugin, and on Linux those talk over /tmp: VirtualHere's client IPC is the FIFO pair /tmp/vhclient + /tmp/vhclient_response, X11 is /tmp/.X11-unix. So the plugin launched the vendor binary happily and could then never reach the daemon behind it — while the same command worked perfectly in the operator's own shell, because that shell has the real /tmp. No config change could fix it, which is exactly the loop the report described. PrivateTmp is now off, with /tmp added to ReadWritePaths (which ProtectSystem=strict would otherwise make read-only). **Plugin logs now land in the console.** Plugins are not host child processes — the runner is a separate bun process that import()s each plugin in-process — so nothing they print passed through the host's tracing, and the console's Logs page could not show a single plugin line. The fallback was journalctl on Linux; on Windows the runner's scheduled task writes no log file at all, so a failing plugin was diagnosable only by stopping the task and re-running the runner by hand. Both mean shell access on the host box, which is what the console exists to avoid — and it left the one question a stuck user asks with no answer. So the runner now tees its output to POST /api/v1/plugins/logs, and those lines join the host's own ring under one cursor, targeted plugin:<name>. The console grows a Host/Plugins switch beside the level filter; an empty Plugins view says the thing that is actually usually wrong (the runner isn't running) rather than "adjust the filter". The shipper keeps stdout authoritative — journald and foreground output are unchanged whatever the host is doing — and is built so that logging can never hurt the thing being logged: it never throws into a caller, holds a bounded queue that drops oldest and then says how many, backs off when the host is away (a restart is normal), and re-sends a batch the host failed to take. Lines logged while a POST is in flight are kept, which cost one round to get right: the first version held its recursion guard across the await and silently dropped exactly the lines a busy plugin produces. Runner lines that report a failure (a refused unit file, a crashed plugin, a give-up) now go out at warn/error instead of all arriving as INFO, so the console's level filter means something for them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
510 lines
19 KiB
TypeScript
510 lines
19 KiB
TypeScript
// The managed script/plugin runner (RFC §8, M5) — what the `punktfunk-scripting` package runs:
|
|
// discover the operator's units, supervise them as Effect fibers, shut down structurally.
|
|
//
|
|
// Units:
|
|
// - **Plugins** — a file whose default export is a [`PluginDef`] (`definePlugin`), from the
|
|
// scripts dir or an installed `punktfunk-plugin-*` package. Supervised: a failure restarts
|
|
// it with capped exponential backoff; a clean return completes it. The Effect `main` shape
|
|
// runs with the `PunktfunkHost` layer provided and is interrupted STRUCTURALLY on shutdown
|
|
// (scoped finalizers run — release the preset, deregister cleanly); the async-fn shape gets
|
|
// a connected facade client whose close is guaranteed by the same scope.
|
|
// - **Bare scripts** — any other `.ts`/`.js` file in the scripts dir: importing it IS the run
|
|
// (top-level await). One-shot: completion logs, failure logs — no restart (a bare script's
|
|
// background work is invisible to supervision; export a plugin to be supervised).
|
|
//
|
|
// Trust model (RFC §9.4): a unit is code the operator chose to run — no sandbox is pretended.
|
|
// The same sshd rule as hooks applies on BOTH platforms: a unit file a non-privileged principal
|
|
// could have written is refused loudly (mode bits on Unix, owner + DACL on Windows).
|
|
import {
|
|
Cause,
|
|
Duration,
|
|
Effect,
|
|
Schedule,
|
|
} from "effect";
|
|
import { spawnSync } from "node:child_process";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { layer as hostLayer, PunktfunkHost } from "./client.js";
|
|
import { type ConnectOptions, configDir } from "./config.js";
|
|
import { connect, type PluginDef } from "./index.js";
|
|
|
|
export interface RunnerOptions {
|
|
/** Where loose scripts live. Default `<config_dir>/scripts`. */
|
|
scriptsDir?: string;
|
|
/**
|
|
* Where plugin packages are installed (`<pluginsDir>/node_modules/punktfunk-plugin-*`,
|
|
* i.e. the operator runs `bun add punktfunk-plugin-x` there). Default `<config_dir>/plugins`.
|
|
*/
|
|
pluginsDir?: string;
|
|
/** Connection overrides handed to every unit's client/layer. */
|
|
connect?: ConnectOptions;
|
|
/** Restart backoff base (test seam). Default 1 s, capped at 60 s, jittered. */
|
|
restartBase?: Duration.Input;
|
|
/**
|
|
* Line sink. Default: stamped stdout, with `warn`/`error` going to the matching console method
|
|
* (hence stderr, and hence the right level in the console's log page — see `log-ship.ts`).
|
|
*
|
|
* `level` is optional so an existing `(line: string) => void` sink stays assignable.
|
|
*/
|
|
log?: (line: string, level?: RunnerLogLevel) => void;
|
|
}
|
|
|
|
/**
|
|
* Severity of a runner line. Only three, because that is all the runner distinguishes: it is
|
|
* reporting on units, not producing application logs.
|
|
*/
|
|
export type RunnerLogLevel = "info" | "warn" | "error";
|
|
|
|
/** The sink shape used internally, with the level always supplied by the caller's default. */
|
|
type LogSink = (line: string, level?: RunnerLogLevel) => void;
|
|
|
|
export interface Unit {
|
|
/** Display name: the file stem, or the plugin package name. */
|
|
name: string;
|
|
/** Absolute path of the module to import. */
|
|
file: string;
|
|
}
|
|
|
|
const defaultLog: LogSink = (line, level = "info") => {
|
|
const stamped = `${new Date().toISOString()} ${line}`;
|
|
if (level === "error") console.error(stamped);
|
|
else if (level === "warn") console.warn(stamped);
|
|
else console.log(stamped);
|
|
};
|
|
|
|
// ---- unit-file trust (the sshd rule, both halves) ---------------------------------------------
|
|
|
|
// SDDL access-mask bits that let a principal change the file's content or its protection:
|
|
// write/append data + EAs, DELETE (delete + recreate), WRITE_DAC / WRITE_OWNER (rewrite the
|
|
// protection itself), and the generic write/all bits. FILE_WRITE_ATTRIBUTES (0x100) is
|
|
// deliberately NOT here: it only toggles timestamps/readonly/hidden — never content — and the
|
|
// runner's own service principal legitimately holds it (bun's module loader opens unit files
|
|
// requesting RX+WA; `plugins enable` grants exactly that on the plugins/scripts dirs — counting
|
|
// WA as tampering would make the runner refuse every unit it is supposed to run).
|
|
const SDDL_WRITE_BITS =
|
|
0x2 | 0x4 | 0x10 | 0x10000 | 0x40000 | 0x80000 | 0x10000000 | 0x40000000;
|
|
|
|
// SDDL two-letter rights tokens → access-mask bits (generic, standard, file-specific, and the
|
|
// low object-rights aliases hex masks sometimes render as). Anything unrecognized is treated as
|
|
// write-capable — fail closed.
|
|
const SDDL_RIGHT_TOKENS: Record<string, number> = {
|
|
GA: 0x10000000,
|
|
GX: 0x20000000,
|
|
GW: 0x40000000,
|
|
GR: 0x80000000,
|
|
RC: 0x20000,
|
|
SD: 0x10000,
|
|
WD: 0x40000,
|
|
WO: 0x80000,
|
|
FA: 0x1f01ff,
|
|
FR: 0x120089,
|
|
FW: 0x120116,
|
|
FX: 0x1200a0,
|
|
CC: 0x1,
|
|
DC: 0x2,
|
|
LC: 0x4,
|
|
SW: 0x8,
|
|
RP: 0x10,
|
|
WP: 0x20,
|
|
DT: 0x40,
|
|
LO: 0x80,
|
|
CR: 0x100,
|
|
};
|
|
|
|
// SDDL two-letter account abbreviations we may meet on a unit file, → full SIDs.
|
|
const SDDL_SID_ABBREV: Record<string, string> = {
|
|
SY: "S-1-5-18", // NT AUTHORITY\SYSTEM
|
|
BA: "S-1-5-32-544", // BUILTIN\Administrators
|
|
OW: "S-1-3-4", // OWNER RIGHTS
|
|
CO: "S-1-3-0", // CREATOR OWNER
|
|
LS: "S-1-5-19", // NT AUTHORITY\LOCAL SERVICE
|
|
NS: "S-1-5-20", // NT AUTHORITY\NETWORK SERVICE
|
|
BU: "S-1-5-32-545", // BUILTIN\Users
|
|
AU: "S-1-5-11", // Authenticated Users
|
|
IU: "S-1-5-4", // INTERACTIVE
|
|
WD: "S-1-1-0", // Everyone
|
|
};
|
|
|
|
const TRUSTED_OWNER_SIDS = new Set([
|
|
"S-1-5-18", // SYSTEM
|
|
"S-1-5-32-544", // Administrators
|
|
"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", // TrustedInstaller
|
|
]);
|
|
|
|
/** An SDDL rights field as an access mask; -1 when it can't be fully understood (fail closed). */
|
|
const sddlRightsMask = (rights: string): number => {
|
|
if (rights.startsWith("0x") || rights.startsWith("0X")) {
|
|
const n = Number.parseInt(rights, 16);
|
|
return Number.isNaN(n) ? -1 : n;
|
|
}
|
|
if (rights.length % 2 !== 0) return -1;
|
|
let mask = 0;
|
|
for (let i = 0; i < rights.length; i += 2) {
|
|
const bits = SDDL_RIGHT_TOKENS[rights.slice(i, i + 2)];
|
|
if (bits === undefined) return -1;
|
|
mask = (mask | bits) >>> 0;
|
|
}
|
|
return mask;
|
|
};
|
|
|
|
/**
|
|
* The Windows half of the sshd rule, as a pure function over the file's SDDL (exported for
|
|
* tests). Returns `null` when the descriptor is trustworthy — owner is
|
|
* SYSTEM/Administrators/TrustedInstaller (or `extraTrustedSid`, the account running the runner:
|
|
* the Unix rule's "your own file is fine") and no other principal holds a write-capable allow
|
|
* ACE — else a human-readable refusal reason. Unknown ACE shapes count as write-capable.
|
|
*/
|
|
export const windowsSddlUnsafeReason = (
|
|
sddl: string,
|
|
extraTrustedSid?: string,
|
|
): string | null => {
|
|
const trusted = new Set(TRUSTED_OWNER_SIDS);
|
|
trusted.add("S-1-3-4"); // OWNER RIGHTS — constrained by the owner check below
|
|
if (extraTrustedSid) trusted.add(extraTrustedSid);
|
|
|
|
const owner = /^O:(S-[0-9-]+|[A-Z]{2})/.exec(sddl)?.[1];
|
|
const ownerSid =
|
|
owner === undefined
|
|
? undefined
|
|
: owner.startsWith("S-")
|
|
? owner
|
|
: SDDL_SID_ABBREV[owner];
|
|
if (ownerSid === undefined || !trusted.has(ownerSid)) {
|
|
return `owner ${owner ?? "unknown"} is not SYSTEM/Administrators/TrustedInstaller`;
|
|
}
|
|
|
|
const daclAt = sddl.indexOf("D:");
|
|
if (daclAt < 0) return "no DACL in the security descriptor";
|
|
// ACE format: (type;flags;rights;objectGuid;inheritGuid;sid[;condition]). SACL ACEs after
|
|
// "S:" match the regex too but are audit types, filtered by the allow-type check.
|
|
for (const [, ace] of sddl.slice(daclAt + 2).matchAll(/\(([^)]*)\)/g)) {
|
|
const [type, flags = "", rights = "", , , sid = ""] = ace.split(";");
|
|
if (type !== "A" && type !== "XA") continue; // deny/audit ACEs only ever tighten
|
|
// Inherit-only ACEs are templates for children; they grant nothing on this file. Flags
|
|
// come in two-letter tokens — compare exactly, not by substring.
|
|
const flagTokens: string[] = flags.match(/.{2}/g) ?? [];
|
|
if (flagTokens.includes("IO")) continue;
|
|
const mask = sddlRightsMask(rights);
|
|
if (mask !== -1 && (mask & SDDL_WRITE_BITS) === 0) continue; // read-only ACE
|
|
const resolved = sid.startsWith("S-") ? sid : (SDDL_SID_ABBREV[sid] ?? sid);
|
|
if (!trusted.has(resolved)) {
|
|
return `${sid} can write it (only SYSTEM/Administrators may)`;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/** The SID this process runs as, fetched once (`undefined` when it can't be determined). */
|
|
let processSidCache: string | undefined | false;
|
|
const processSid = (): string | undefined => {
|
|
if (processSidCache === undefined) {
|
|
const res = spawnSync(
|
|
windowsPowershell(),
|
|
[
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-Command",
|
|
"[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value",
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
windowsHide: true,
|
|
timeout: 15_000,
|
|
env: windowsPowershellEnv(),
|
|
},
|
|
);
|
|
const sid = res.status === 0 ? (res.stdout ?? "").trim() : "";
|
|
processSidCache = /^S-[0-9-]+$/.test(sid) ? sid : false;
|
|
}
|
|
return processSidCache === false ? undefined : processSidCache;
|
|
};
|
|
|
|
// Full System32 path, never PATH — a planted powershell.exe must not run with our privileges
|
|
// (mirrors the host CLI's powershell_path, security-review 2026-07-17).
|
|
const windowsPowershell = (): string =>
|
|
path.join(
|
|
process.env.SystemRoot ?? "C:\\Windows",
|
|
"System32",
|
|
"WindowsPowerShell",
|
|
"v1.0",
|
|
"powershell.exe",
|
|
);
|
|
|
|
// Spawn env for Windows PowerShell 5.1 with PSModulePath STRIPPED (5.1 rebuilds its default).
|
|
// An inherited PSModulePath that includes a PowerShell 7 module dir (pwsh adds a machine-scope
|
|
// entry) makes 5.1 fail to autoload Microsoft.PowerShell.Security — type-data conflict
|
|
// ("AuditToString is already present") — so Get-Acl dies and every unit is refused. Seen live
|
|
// on the .173 host box; intermittent per spawn, deterministic once stripped.
|
|
const windowsPowershellEnv = (): Record<string, string | undefined> => {
|
|
const env: Record<string, string | undefined> = { ...process.env };
|
|
delete env.PSModulePath;
|
|
return env;
|
|
};
|
|
|
|
/** Read a file's SDDL and apply [`windowsSddlUnsafeReason`]. Unreadable ACL ⇒ refuse. */
|
|
const windowsFileIsSafe = (file: string, log: LogSink): boolean => {
|
|
const escaped = file.replace(/'/g, "''");
|
|
const res = spawnSync(
|
|
windowsPowershell(),
|
|
[
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-Command",
|
|
`(Get-Acl -LiteralPath '${escaped}').Sddl`,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
windowsHide: true,
|
|
timeout: 15_000,
|
|
env: windowsPowershellEnv(),
|
|
},
|
|
);
|
|
const sddl = res.status === 0 ? (res.stdout ?? "").trim() : "";
|
|
if (!sddl) {
|
|
log(`[runner] REFUSING ${file} — could not read its ACL`, "error");
|
|
return false;
|
|
}
|
|
const reason = windowsSddlUnsafeReason(sddl, processSid());
|
|
if (reason !== null) {
|
|
log(
|
|
`[runner] REFUSING ${file} — ${reason}. Reinstall the plugin with ` +
|
|
`\`punktfunk-host plugins add\`, or re-own the file to Administrators and strip ` +
|
|
`non-admin write ACEs (icacls).`,
|
|
"error",
|
|
);
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* The sshd rule (RFC §9.1/§9.4): refuse a unit file anyone less privileged than the operator
|
|
* could have written — group/world-writable mode on Unix; on Windows, an owner outside
|
|
* SYSTEM/Administrators/TrustedInstaller or a write-capable ACE for a non-admin principal.
|
|
*/
|
|
const fileIsSafe = (file: string, log: LogSink): boolean => {
|
|
if (process.platform === "win32") return windowsFileIsSafe(file, log);
|
|
try {
|
|
const mode = fs.statSync(file).mode & 0o022;
|
|
if (mode !== 0) {
|
|
log(
|
|
`[runner] REFUSING ${file} — group/world-writable (chmod go-w it first)`,
|
|
"error",
|
|
);
|
|
return false;
|
|
}
|
|
} catch {
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const SCRIPT_EXTENSIONS = new Set([".ts", ".js", ".mjs", ".mts", ".cjs"]);
|
|
|
|
/** Enumerate the operator's units: loose scripts plus installed plugin packages. */
|
|
export const discoverUnits = (
|
|
options: RunnerOptions = {},
|
|
log: LogSink = options.log ?? defaultLog,
|
|
): Unit[] => {
|
|
const units: Unit[] = [];
|
|
const scriptsDir = options.scriptsDir ?? path.join(configDir(), "scripts");
|
|
const pluginsDir = options.pluginsDir ?? path.join(configDir(), "plugins");
|
|
try {
|
|
for (const entry of fs.readdirSync(scriptsDir).sort()) {
|
|
const file = path.join(scriptsDir, entry);
|
|
if (!SCRIPT_EXTENSIONS.has(path.extname(entry))) continue;
|
|
if (!fs.statSync(file).isFile()) continue;
|
|
if (!fileIsSafe(file, log)) continue;
|
|
units.push({ name: path.basename(entry, path.extname(entry)), file });
|
|
}
|
|
} catch {
|
|
// no scripts dir — fine
|
|
}
|
|
const modules = path.join(pluginsDir, "node_modules");
|
|
// The packages the operator actually installed — `bun add` records them as the plugins dir's
|
|
// own `dependencies`. This is what separates a plugin from a plugin's LIBRARY:
|
|
// `@punktfunk/plugin-kit` matches the `plugin-*` naming convention exactly but arrives as a
|
|
// transitive dependency of every kit-built plugin, and running it as a unit is nonsense.
|
|
// `undefined` only when there is no readable `package.json` at all — a hand-assembled tree then
|
|
// falls back to the naming convention rather than discovering nothing. A package.json with no
|
|
// `dependencies` key yields an EMPTY set, not `undefined`: `bun remove` drops the key when the
|
|
// last plugin goes, and orphaned transitive packages can outlive it, so falling back there
|
|
// would start running a plugin's library the moment you uninstall the last real plugin.
|
|
let topLevel: Set<string> | undefined;
|
|
try {
|
|
const root = JSON.parse(
|
|
fs.readFileSync(path.join(pluginsDir, "package.json"), "utf8"),
|
|
) as { dependencies?: Record<string, string> };
|
|
topLevel = new Set(Object.keys(root.dependencies ?? {}));
|
|
} catch {
|
|
// no package.json — fall back to the convention
|
|
}
|
|
// Read a plugin package's manifest (`module`/`main` entry) and add it as a unit.
|
|
const addPlugin = (dir: string, name: string): void => {
|
|
if (topLevel && !topLevel.has(name)) return; // a dependency, not an installed plugin
|
|
try {
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(path.join(dir, "package.json"), "utf8"),
|
|
) as { main?: string; module?: string };
|
|
const rel = manifest.module ?? manifest.main ?? "index.js";
|
|
const file = path.join(dir, rel);
|
|
if (!fileIsSafe(file, log)) return;
|
|
units.push({ name, file });
|
|
} catch (e) {
|
|
log(`[runner] skipping ${name}: unreadable package.json (${e})`, "warn");
|
|
}
|
|
};
|
|
try {
|
|
for (const pkg of fs.readdirSync(modules).sort()) {
|
|
// Unscoped convention: `punktfunk-plugin-*`.
|
|
if (pkg.startsWith("punktfunk-plugin-")) {
|
|
addPlugin(path.join(modules, pkg), pkg);
|
|
continue;
|
|
}
|
|
// Scoped convention: `<any scope>/plugin-*`. A scoped name resolves cleanly from a
|
|
// registry scope-map, so a plugin can depend on `@punktfunk/host` + `effect` as shared
|
|
// (hoisted) deps rather than bundling its own copy of each.
|
|
//
|
|
// ANY scope, not just `@punktfunk`: the plugin store requires catalog entries to be
|
|
// scoped precisely so the scope can map to that entry's registry, so a third-party
|
|
// plugin necessarily arrives as `@their-scope/plugin-*`. Limiting discovery to the
|
|
// first-party scope would let such a plugin install and then never run.
|
|
if (pkg.startsWith("@")) {
|
|
try {
|
|
for (const scoped of fs.readdirSync(path.join(modules, pkg)).sort()) {
|
|
if (scoped.startsWith("plugin-")) {
|
|
addPlugin(path.join(modules, pkg, scoped), `${pkg}/${scoped}`);
|
|
}
|
|
}
|
|
} catch {
|
|
// not a readable scope dir — fine
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// no plugins dir — fine
|
|
}
|
|
return units;
|
|
};
|
|
|
|
const isPluginDef = (v: unknown): v is PluginDef =>
|
|
typeof v === "object" &&
|
|
v !== null &&
|
|
typeof (v as PluginDef).name === "string" &&
|
|
(v as PluginDef).main !== undefined;
|
|
|
|
/** One attempt at a unit: import (cache-busted per attempt) and run whatever it exports. */
|
|
const attemptUnit = (
|
|
unit: Unit,
|
|
attempt: number,
|
|
options: RunnerOptions,
|
|
log: LogSink,
|
|
): Effect.Effect<"plugin" | "script", unknown> =>
|
|
Effect.gen(function* () {
|
|
const mod = (yield* Effect.tryPromise(
|
|
() => import(`${pathToFileURL(unit.file).href}?attempt=${attempt}`),
|
|
)) as { default?: unknown };
|
|
if (!isPluginDef(mod.default)) {
|
|
return "script" as const; // the import WAS the run (top-level await)
|
|
}
|
|
const def = mod.default;
|
|
if (Effect.isEffect(def.main)) {
|
|
// The well-behaved shape: interruption reaches it structurally, its scoped
|
|
// finalizers run on shutdown.
|
|
yield* (def.main as Effect.Effect<unknown, unknown, PunktfunkHost>).pipe(
|
|
Effect.provide(hostLayer(options.connect)),
|
|
);
|
|
} else {
|
|
// The simple shape: a facade client whose close is guaranteed by the scope —
|
|
// on completion, failure, OR interruption (shutdown).
|
|
const main = def.main as (pf: unknown) => Promise<unknown> | unknown;
|
|
yield* Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const pf = yield* Effect.acquireRelease(
|
|
Effect.tryPromise(() => connect(options.connect)),
|
|
(client) => Effect.sync(() => client.close()),
|
|
);
|
|
yield* Effect.tryPromise(async () => await main(pf));
|
|
}),
|
|
);
|
|
}
|
|
return "plugin" as const;
|
|
});
|
|
|
|
/**
|
|
* A unit under supervision: plugins restart on failure (capped exponential backoff, jittered);
|
|
* a clean completion ends the unit; bare scripts are one-shot either way. Never fails the
|
|
* runner — every outcome is logged.
|
|
*/
|
|
export const superviseUnit = (
|
|
unit: Unit,
|
|
options: RunnerOptions = {},
|
|
): Effect.Effect<void> => {
|
|
const log = options.log ?? defaultLog;
|
|
// Exponential backoff, capped at 60 s (min-delay of the two schedules), then jittered.
|
|
// (v4 replaced `Schedule.union` with the array-form `Schedule.min`.)
|
|
const restart = Schedule.min([
|
|
Schedule.exponential(options.restartBase ?? "1 second"),
|
|
Schedule.spaced("60 seconds"),
|
|
]).pipe(Schedule.jittered);
|
|
let attempt = 0;
|
|
const once = Effect.suspend(() => {
|
|
attempt += 1;
|
|
if (attempt > 1)
|
|
log(`[${unit.name}] restarting (attempt ${attempt})`, "warn");
|
|
return attemptUnit(unit, attempt, options, log);
|
|
});
|
|
return once.pipe(
|
|
Effect.tap((kind) =>
|
|
Effect.sync(() =>
|
|
log(
|
|
kind === "script"
|
|
? `[${unit.name}] script completed`
|
|
: `[${unit.name}] plugin completed`,
|
|
),
|
|
),
|
|
),
|
|
Effect.tapCause((cause) =>
|
|
Effect.sync(() =>
|
|
log(
|
|
`[${unit.name}] failed: ${Cause.pretty(cause).split("\n")[0]}`,
|
|
"error",
|
|
),
|
|
),
|
|
),
|
|
Effect.retry(restart),
|
|
Effect.catchCause((cause) =>
|
|
// A retry schedule that gives up (it doesn't, but stay total) — log and end.
|
|
Effect.sync(() =>
|
|
log(`[${unit.name}] gave up: ${Cause.pretty(cause)}`, "error"),
|
|
),
|
|
),
|
|
Effect.asVoid,
|
|
);
|
|
};
|
|
|
|
/**
|
|
* The runner: discover units, supervise each as a fiber, run until interrupted — at which
|
|
* point every unit is interrupted STRUCTURALLY (scoped finalizers run: facade clients close,
|
|
* Effect plugins release what they acquired).
|
|
*/
|
|
export const runner = (options: RunnerOptions = {}): Effect.Effect<void> => {
|
|
const log = options.log ?? defaultLog;
|
|
return Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const units = discoverUnits(options, log);
|
|
if (units.length === 0) {
|
|
log(
|
|
"[runner] nothing to run — add scripts to the scripts dir or install punktfunk-plugin-* packages",
|
|
);
|
|
}
|
|
for (const unit of units) {
|
|
log(`[runner] starting ${unit.name} (${unit.file})`);
|
|
yield* Effect.forkScoped(superviseUnit(unit, options));
|
|
}
|
|
yield* Effect.never; // interruption (shutdown) collapses the scope → all units
|
|
}),
|
|
);
|
|
};
|