feat(sdk): punktfunk-scripting — the managed script/plugin runner (M5)
apple / swift (push) Successful in 1m18s
apple / screenshots (push) Successful in 4m42s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 49s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
ci / bench (push) Successful in 5m14s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 56s
arch / build-publish (push) Successful in 15m11s
android / android (push) Successful in 15m50s
deb / build-publish (push) Successful in 16m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m8s
ci / rust (push) Successful in 24m55s
docker / deploy-docs (push) Successful in 10s
apple / swift (push) Successful in 1m18s
apple / screenshots (push) Successful in 4m42s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 49s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
ci / bench (push) Successful in 5m14s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 56s
arch / build-publish (push) Successful in 15m11s
android / android (push) Successful in 15m50s
deb / build-publish (push) Successful in 16m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m8s
ci / rust (push) Successful in 24m55s
docker / deploy-docs (push) Successful in 10s
The optional supervision layer (RFC §8): one service runs everything in <config_dir>/scripts/ plus installed punktfunk-plugin-* packages (<config_dir>/plugins/node_modules/), as Effect fibers. - Plugins (a definePlugin default export, either main shape) are SUPERVISED: a failure restarts them with capped exponential backoff (jittered, 1s→60s); a clean return completes them. The Effect shape runs under the PunktfunkHost layer; the async-fn shape gets a facade client whose close is scope-guaranteed. - Bare scripts are one-shot: importing them is the run, no restart (export a plugin to be supervised). - Shutdown is STRUCTURAL: SIGINT/SIGTERM interrupt the whole fiber tree, so Effect plugins' scoped finalizers run and clients close before exit — the systemctl-stop story, and the reason the Effect plugin shape exists at all. - The sshd rule applies to unit files (world-writable → refused loudly); cache-busted imports make restarts real; --list for inventory. 6 new bun tests (17 total green): discovery + refusal, both plugin shapes against a mock host, crash→restart with backoff, one-shot semantics, and finalizer-on-interrupt. Live-verified against a real host: a supervised watcher plugin received library.changed through the pinned tunnel, and SIGTERM shut the tree down structurally (exit 0). Deferred to the packaging follow-up (release.yml is in flight in a parallel session): the vendored-Bun deb/rpm/iss packages and the host-log-ring tee (needs a host ingest endpoint); console page rides the other console surfaces. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -138,6 +138,15 @@ curl -Nk -H "Authorization: Bearer $(cat ~/.config/punktfunk/mgmt-token)" \
|
||||
`event: dropped` frame first — resync from the REST snapshots (`/status`, `/clients`, …).
|
||||
- `?kinds=` filters server-side: exact kinds or `domain.*` prefixes, comma-separated.
|
||||
|
||||
## Scripts, plugins, and the runner
|
||||
|
||||
For anything beyond a `curl` one-liner there is **`@punktfunk/host`** — the TypeScript SDK
|
||||
(`sdk/` in the repo): typed events with automatic reconnect/resume, the REST surface, and a
|
||||
plugin convention (`punktfunk-plugin-*`). Its **runner** (`punktfunk-scripting`) supervises a
|
||||
directory of scripts and installed plugins as one service: crash-restarts with backoff, and a
|
||||
`systemctl stop` that interrupts plugins structurally so their cleanup runs. See the SDK README
|
||||
for the five-line quickstart and unit templates.
|
||||
|
||||
The canonical "decide, don't just observe" pattern — approve pairing from your phone: watch
|
||||
`pairing.pending`, send yourself a notification, and call
|
||||
`POST /api/v1/native/pending/{id}/approve` when you tap yes. The full API is documented at
|
||||
|
||||
+38
-1
@@ -94,7 +94,44 @@ export default definePlugin({
|
||||
|
||||
In v1 a plugin is a script you run (see below); the managed runner package is a later step.
|
||||
|
||||
## Running as a service
|
||||
## The runner: `punktfunk-scripting`
|
||||
|
||||
Instead of one unit file per script, run everything under the managed runner — it discovers
|
||||
your units and supervises them:
|
||||
|
||||
```sh
|
||||
bun src/runner-cli.ts # runs <config_dir>/scripts/* + installed punktfunk-plugin-*
|
||||
bun src/runner-cli.ts --list # show what it found
|
||||
```
|
||||
|
||||
- **Plugins** (a `definePlugin` default export, from the scripts dir or a
|
||||
`punktfunk-plugin-*` package installed under `<config_dir>/plugins/`): supervised — a crash
|
||||
restarts them with capped exponential backoff; a clean return completes them.
|
||||
- **Bare scripts**: importing them is the run — one-shot, no restart (export a plugin to be
|
||||
supervised).
|
||||
- **Shutdown is structural**: SIGINT/SIGTERM interrupt every unit's fiber — Effect plugins'
|
||||
scoped finalizers run (release the preset, deregister cleanly) and facade clients close
|
||||
before the process exits. This is what makes `systemctl stop` clean.
|
||||
- The sshd rule applies: a group/world-writable unit file is refused loudly.
|
||||
|
||||
systemd user unit for the runner (`~/.config/systemd/user/punktfunk-scripting.service`):
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=punktfunk script/plugin runner
|
||||
After=punktfunk-host.service
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/bun /path/to/sdk/src/runner-cli.ts
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# SIGTERM (the default KillSignal) triggers the runner's structured shutdown.
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
## Running a single script as a service
|
||||
|
||||
systemd user unit (`~/.config/systemd/user/punktfunk-myscript.service`):
|
||||
|
||||
|
||||
+5
-1
@@ -11,7 +11,8 @@
|
||||
"scripts": {
|
||||
"gen": "orval --config ./orval.config.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test"
|
||||
"test": "bun test",
|
||||
"runner": "bun src/runner-cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect": "^3.19.0"
|
||||
@@ -23,5 +24,8 @@
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"undici": "^7.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"punktfunk-scripting": "./src/runner-cli.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bun
|
||||
// `punktfunk-scripting` — run the operator's scripts and punktfunk-plugin-* packages under
|
||||
// supervision (see ./runner.ts). SIGINT/SIGTERM interrupt the whole tree structurally, so
|
||||
// every plugin's finalizers run before exit (the systemd-stop story).
|
||||
//
|
||||
// bun src/runner-cli.ts [--scripts DIR] [--plugins DIR] [--list]
|
||||
import { Effect, Fiber } from "effect";
|
||||
import { discoverUnits, runner } from "./runner.js";
|
||||
|
||||
const arg = (flag: string): string | undefined => {
|
||||
const i = process.argv.indexOf(flag);
|
||||
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||
};
|
||||
|
||||
const options = {
|
||||
scriptsDir: arg("--scripts"),
|
||||
pluginsDir: arg("--plugins"),
|
||||
};
|
||||
|
||||
if (process.argv.includes("--list")) {
|
||||
for (const u of discoverUnits(options)) console.log(`${u.name}\t${u.file}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const fiber = Effect.runFork(runner(options));
|
||||
let stopping = false;
|
||||
const shutdown = (signal: string) => {
|
||||
if (stopping) return process.exit(1); // second signal = get out now
|
||||
stopping = true;
|
||||
console.log(`${new Date().toISOString()} [runner] ${signal} — interrupting units…`);
|
||||
void Effect.runPromise(Fiber.interrupt(fiber)).finally(() => process.exit(0));
|
||||
};
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
|
||||
await Effect.runPromise(Fiber.await(fiber));
|
||||
@@ -0,0 +1,226 @@
|
||||
// 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: a world-writable unit file is refused loudly.
|
||||
import {
|
||||
Cause,
|
||||
Duration,
|
||||
Effect,
|
||||
Schedule,
|
||||
} from "effect";
|
||||
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.DurationInput;
|
||||
/** Line sink. Default: stamped stdout. */
|
||||
log?: (line: string) => 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 = (line: string) =>
|
||||
console.log(`${new Date().toISOString()} ${line}`);
|
||||
|
||||
/** The sshd rule (RFC §9.1/§9.4): refuse group/world-writable unit files, loudly. */
|
||||
const fileIsSafe = (file: string, log: (l: string) => void): boolean => {
|
||||
if (process.platform === "win32") return true; // config dir is DACL'd; ACL check is a follow-up
|
||||
try {
|
||||
const mode = fs.statSync(file).mode & 0o022;
|
||||
if (mode !== 0) {
|
||||
log(
|
||||
`[runner] REFUSING ${file} — group/world-writable (chmod go-w it first)`,
|
||||
);
|
||||
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: (l: string) => void = 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");
|
||||
try {
|
||||
for (const pkg of fs.readdirSync(modules).sort()) {
|
||||
if (!pkg.startsWith("punktfunk-plugin-")) continue;
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(modules, pkg, "package.json"), "utf8"),
|
||||
) as { main?: string; module?: string };
|
||||
const rel = manifest.module ?? manifest.main ?? "index.js";
|
||||
const file = path.join(modules, pkg, rel);
|
||||
if (!fileIsSafe(file, log)) continue;
|
||||
units.push({ name: pkg, file });
|
||||
} catch (e) {
|
||||
log(`[runner] skipping ${pkg}: unreadable package.json (${e})`);
|
||||
}
|
||||
}
|
||||
} 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: (l: string) => void,
|
||||
): 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;
|
||||
const restart = Schedule.exponential(options.restartBase ?? "1 second").pipe(
|
||||
Schedule.union(Schedule.spaced("60 seconds")), // cap
|
||||
Schedule.jittered,
|
||||
);
|
||||
let attempt = 0;
|
||||
const once = Effect.suspend(() => {
|
||||
attempt += 1;
|
||||
if (attempt > 1) log(`[${unit.name}] restarting (attempt ${attempt})`);
|
||||
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.tapErrorCause((cause) =>
|
||||
Effect.sync(() =>
|
||||
log(`[${unit.name}] failed: ${Cause.pretty(cause).split("\n")[0]}`),
|
||||
),
|
||||
),
|
||||
Effect.retry(restart),
|
||||
Effect.catchAllCause((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)}`)),
|
||||
),
|
||||
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
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
// The runner's contract: discovery (scripts + plugin packages + the sshd refusal), both plugin
|
||||
// shapes running against a mock host, crash → supervised restart, one-shot scripts, and the
|
||||
// M5 heart — structured interruption running scoped finalizers on shutdown.
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { Effect, Fiber } from "effect";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { discoverUnits, runner, superviseUnit } from "../src/runner.js";
|
||||
|
||||
const TOKEN = "runner-token";
|
||||
// Fixtures live under sdk/ so the generated plugin files can resolve "effect" and the SDK.
|
||||
const ROOT = path.join(import.meta.dir, "..", `.runner-fixtures-${process.pid}`);
|
||||
fs.mkdirSync(ROOT, { recursive: true });
|
||||
afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
const mkdirs = (name: string) => {
|
||||
const dir = path.join(ROOT, name);
|
||||
fs.mkdirSync(path.join(dir, "scripts"), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, "plugins", "node_modules"), { recursive: true });
|
||||
return {
|
||||
scriptsDir: path.join(dir, "scripts"),
|
||||
pluginsDir: path.join(dir, "plugins"),
|
||||
dir,
|
||||
};
|
||||
};
|
||||
|
||||
const write = (file: string, content: string) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, content);
|
||||
fs.chmodSync(file, 0o644);
|
||||
};
|
||||
|
||||
const mockHost = () =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
if (req.headers.get("authorization") !== `Bearer ${TOKEN}`)
|
||||
return Response.json({ error: "no" }, { status: 401 });
|
||||
const p = new URL(req.url).pathname;
|
||||
if (p === "/api/v1/host") return Response.json({ hostname: "mock" });
|
||||
if (p === "/api/v1/status") return Response.json({ ok: true });
|
||||
return Response.json({ error: "nf" }, { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
const waitFor = async (predicate: () => boolean, ms = 5000) => {
|
||||
const deadline = Date.now() + ms;
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) throw new Error("condition never became true");
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
};
|
||||
|
||||
describe("discovery", () => {
|
||||
test("finds scripts + plugin packages, refuses world-writable files", () => {
|
||||
const d = mkdirs("discover");
|
||||
write(path.join(d.scriptsDir, "b-script.ts"), "export {};");
|
||||
write(path.join(d.scriptsDir, "a-script.ts"), "export {};");
|
||||
write(path.join(d.scriptsDir, "notes.txt"), "not code");
|
||||
const evil = path.join(d.scriptsDir, "evil.ts");
|
||||
write(evil, "export {};");
|
||||
fs.chmodSync(evil, 0o777);
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-x", "package.json"),
|
||||
JSON.stringify({ name: "punktfunk-plugin-x", main: "index.js" }),
|
||||
);
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-x", "index.js"),
|
||||
"export default { name: 'x', main: async () => {} };",
|
||||
);
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "unrelated-pkg", "package.json"),
|
||||
JSON.stringify({ name: "unrelated-pkg" }),
|
||||
);
|
||||
|
||||
const logs: string[] = [];
|
||||
const units = discoverUnits(d, (l) => logs.push(l));
|
||||
expect(units.map((u) => u.name)).toEqual([
|
||||
"a-script",
|
||||
"b-script",
|
||||
"punktfunk-plugin-x",
|
||||
]);
|
||||
expect(logs.join("\n")).toContain("REFUSING");
|
||||
expect(logs.join("\n")).toContain("evil.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("supervision", () => {
|
||||
test("async-fn plugin runs with a facade client; clean return completes", async () => {
|
||||
const server = mockHost();
|
||||
const d = mkdirs("fn-plugin");
|
||||
const out = path.join(d.dir, "out.txt");
|
||||
write(
|
||||
path.join(d.scriptsDir, "fn.ts"),
|
||||
`export default { name: "fn", main: async (pf) => {
|
||||
const host = await pf.request("GET", "/host");
|
||||
require("node:fs").writeFileSync(${JSON.stringify(out)}, host.hostname);
|
||||
}};`,
|
||||
);
|
||||
const logs: string[] = [];
|
||||
try {
|
||||
const fiber = Effect.runFork(
|
||||
runner({
|
||||
...d,
|
||||
connect: { url: `http://127.0.0.1:${server.port}`, token: TOKEN },
|
||||
log: (l) => logs.push(l),
|
||||
}),
|
||||
);
|
||||
await waitFor(() => fs.existsSync(out));
|
||||
expect(fs.readFileSync(out, "utf8")).toBe("mock");
|
||||
await waitFor(() => logs.some((l) => l.includes("[fn] plugin completed")));
|
||||
await Effect.runPromise(Fiber.interrupt(fiber));
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("a crashing plugin is restarted with backoff", async () => {
|
||||
const server = mockHost();
|
||||
const d = mkdirs("crashy");
|
||||
const counter = path.join(d.dir, "count.txt");
|
||||
write(
|
||||
path.join(d.scriptsDir, "crashy.ts"),
|
||||
`import * as fs from "node:fs";
|
||||
export default { name: "crashy", main: async () => {
|
||||
const n = fs.existsSync(${JSON.stringify(counter)}) ? Number(fs.readFileSync(${JSON.stringify(counter)}, "utf8")) : 0;
|
||||
fs.writeFileSync(${JSON.stringify(counter)}, String(n + 1));
|
||||
throw new Error("boom " + n);
|
||||
}};`,
|
||||
);
|
||||
const logs: string[] = [];
|
||||
try {
|
||||
const fiber = Effect.runFork(
|
||||
runner({
|
||||
...d,
|
||||
connect: { url: `http://127.0.0.1:${server.port}`, token: TOKEN },
|
||||
restartBase: "20 millis",
|
||||
log: (l) => logs.push(l),
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
try {
|
||||
return Number(fs.readFileSync(counter, "utf8")) >= 3;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
expect(logs.some((l) => l.includes("[crashy] failed: "))).toBe(true);
|
||||
expect(logs.some((l) => l.includes("restarting (attempt 2)"))).toBe(true);
|
||||
await Effect.runPromise(Fiber.interrupt(fiber));
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("a bare script is one-shot: runs on import, never restarts", async () => {
|
||||
const d = mkdirs("bare");
|
||||
const counter = path.join(d.dir, "ran.txt");
|
||||
write(
|
||||
path.join(d.scriptsDir, "once.ts"),
|
||||
`import * as fs from "node:fs";
|
||||
const n = fs.existsSync(${JSON.stringify(counter)}) ? Number(fs.readFileSync(${JSON.stringify(counter)}, "utf8")) : 0;
|
||||
fs.writeFileSync(${JSON.stringify(counter)}, String(n + 1));`,
|
||||
);
|
||||
const logs: string[] = [];
|
||||
const fiber = Effect.runFork(
|
||||
runner({ ...d, restartBase: "20 millis", log: (l) => logs.push(l) }),
|
||||
);
|
||||
await waitFor(() => logs.some((l) => l.includes("[once] script completed")));
|
||||
await new Promise((r) => setTimeout(r, 200)); // would have restarted by now
|
||||
expect(fs.readFileSync(counter, "utf8")).toBe("1");
|
||||
await Effect.runPromise(Fiber.interrupt(fiber));
|
||||
});
|
||||
|
||||
test("shutdown interrupts an Effect plugin STRUCTURALLY — its finalizer runs", async () => {
|
||||
const server = mockHost();
|
||||
const d = mkdirs("finalizer");
|
||||
const acquired = path.join(d.dir, "acquired.txt");
|
||||
const released = path.join(d.dir, "released.txt");
|
||||
write(
|
||||
path.join(d.scriptsDir, "holder.ts"),
|
||||
`import { Effect } from "effect";
|
||||
import * as fs from "node:fs";
|
||||
export default { name: "holder", main: Effect.scoped(Effect.gen(function* () {
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => fs.writeFileSync(${JSON.stringify(acquired)}, "1")),
|
||||
() => Effect.sync(() => fs.writeFileSync(${JSON.stringify(released)}, "1")),
|
||||
);
|
||||
yield* Effect.never; // hold the resource for the plugin's lifetime
|
||||
})) };`,
|
||||
);
|
||||
try {
|
||||
const fiber = Effect.runFork(
|
||||
runner({
|
||||
...d,
|
||||
connect: { url: `http://127.0.0.1:${server.port}`, token: TOKEN },
|
||||
log: () => {},
|
||||
}),
|
||||
);
|
||||
await waitFor(() => fs.existsSync(acquired));
|
||||
expect(fs.existsSync(released)).toBe(false);
|
||||
await Effect.runPromise(Fiber.interrupt(fiber)); // the SIGTERM path
|
||||
await waitFor(() => fs.existsSync(released));
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("supervised unit ends cleanly when its plugin completes (no spin)", async () => {
|
||||
const server = mockHost();
|
||||
const d = mkdirs("done");
|
||||
write(
|
||||
path.join(d.scriptsDir, "done.ts"),
|
||||
`export default { name: "done", main: async () => {} };`,
|
||||
);
|
||||
const logs: string[] = [];
|
||||
try {
|
||||
await Effect.runPromise(
|
||||
superviseUnit(
|
||||
{ name: "done", file: path.join(d.scriptsDir, "done.ts") },
|
||||
{
|
||||
connect: { url: `http://127.0.0.1:${server.port}`, token: TOKEN },
|
||||
log: (l) => logs.push(l),
|
||||
restartBase: "10 millis",
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(logs.filter((l) => l.includes("restarting")).length).toBe(0);
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user