diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts index 20eb412dd..d7b082546 100644 --- a/apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts +++ b/apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts @@ -24,8 +24,8 @@ import { } from './helpers/browser-bundle'; const BUDGETS: Array<{ entry: string; label: string; maxBytes: number }> = [ - { entry: 'create-tool.ts', label: 'create() + one tool', maxBytes: 1_323_819 }, - { entry: 'create-webmcp-tool.ts', label: 'create() + WebMcpPlugin + one tool', maxBytes: 1_330_696 }, + { entry: 'create-tool.ts', label: 'create() + one tool', maxBytes: 1_325_128 }, + { entry: 'create-webmcp-tool.ts', label: 'create() + WebMcpPlugin + one tool', maxBytes: 1_332_004 }, ]; const VITE_INITIAL_CHUNK_MAX_BYTES = 960_000; @@ -46,6 +46,7 @@ const FEATURE_MODULES: Record = { ), channels: sdkModule('channel/(?!channel\\.utils)'), 'distributed transport': sdkModule('ha/|transport/(bus|relay|event-stores)/'), + duties: sdkModule('duty/'), elicitation: sdkModule('elicitation/(store|flows)/|elicitation/send-elicitation-result\\.tool'), health: sdkModule('health/'), 'http transport': sdkModule( diff --git a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts index b5ccea3e3..8a720b844 100644 --- a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts +++ b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts @@ -43,6 +43,7 @@ const fixtureFiles = [ 'hook-ordering-must-compile.ts', 'hook-ordering-must-fail.ts', 'live-surface-must-compile.ts', + 'duties-must-compile.ts', 'offload-must-compile.ts', ].map((fileName) => path.join(fixtureDir, fileName)); @@ -204,6 +205,10 @@ describe('@frontmcp/sdk declarations in a strict consumer project', () => { expect(describeEach(inFixtureFile('live-surface-must-compile.ts'))).toEqual([]); }); + it('types @Duty, its metadata and run context, and createContainer({ runDuties, info })', () => { + expect(describeEach(inFixtureFile('duties-must-compile.ts'))).toEqual([]); + }); + it('types this.offload() from the offloaded function, @Resource({ worker }) and the offload option', () => { expect(describeEach(inFixtureFile('offload-must-compile.ts'))).toEqual([]); }); diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/duties-must-compile.ts b/apps/e2e/demo-e2e-dts-consumer/fixture/duties-must-compile.ts new file mode 100644 index 000000000..26e252b29 --- /dev/null +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/duties-must-compile.ts @@ -0,0 +1,49 @@ +import 'reflect-metadata'; + +import { + App, + createContainer, + Duty, + FrontMcp, + type DutyInterface, + type DutyMetadata, + type DutyRunContext, +} from '@frontmcp/sdk'; + +@Duty({ every: '10m', scope: 'machine' }) +export class PruneCacheDuty implements DutyInterface { + async run({ signal }: DutyRunContext): Promise { + if (signal.aborted) return; + } +} + +const nightly: DutyMetadata = { name: 'reindex', every: 86_400_000, scope: 'workspace', leaseMs: 30_000 }; + +@Duty(nightly) +export class ReindexDuty { + run(): void { + return undefined; + } +} + +@App({ name: 'maintenance', providers: [PruneCacheDuty, ReindexDuty] }) +class MaintenanceApp {} + +@FrontMcp({ info: { name: 'duties', version: '1.0.0' }, apps: [MaintenanceApp] }) +export class DutiesServer {} + +// @ts-expect-error a duty needs a run() method +@Duty({ every: '1m', scope: 'machine' }) +export class DutyWithoutRun {} + +export class InvalidDuties { + // @ts-expect-error scope is 'machine' or 'workspace' + static readonly scope: DutyMetadata = { every: '1m', scope: 'galaxy' }; + // @ts-expect-error every is milliseconds or a duration string + static readonly every: DutyMetadata = { every: true, scope: 'machine' }; +} + +export async function runDutiesInAScript(): Promise { + const container = await createContainer({ apps: [MaintenanceApp], runDuties: true, info: { name: 'duties' } }); + await container.dispose(); +} diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json b/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json index 3898f5270..ea3eee315 100644 --- a/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json @@ -30,6 +30,7 @@ "call-recorder-must-compile.ts", "hook-ordering-must-compile.ts", "hook-ordering-must-fail.ts", - "live-surface-must-compile.ts" + "live-surface-must-compile.ts", + "duties-must-compile.ts" ] } diff --git a/apps/e2e/demo-e2e-local-runtime/e2e/duties.e2e.spec.ts b/apps/e2e/demo-e2e-local-runtime/e2e/duties.e2e.spec.ts new file mode 100644 index 000000000..6e9f6c75f --- /dev/null +++ b/apps/e2e/demo-e2e-local-runtime/e2e/duties.e2e.spec.ts @@ -0,0 +1,98 @@ +/** + * E2E: five servers on one machine share a `@Duty`'s lease. One of them runs it each interval; when it is killed, + * another takes over once the lease runs out, and when it stops gracefully it hands the lease over at once. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { killAll, spawnWorker, type Worker } from './helpers/workers'; + +const SERVERS = 5; +const EVERY_MS = 500; +const LEASE_MS = 12_000; + +interface Run { + pid: number; + at: number; +} + +function runsOf(workers: Worker[]): Run[] { + return workers + .flatMap(({ lines }) => lines.filter((line) => line.startsWith('ran '))) + .map((line) => { + const [, pid, at] = line.split(' '); + return { pid: Number(pid), at: Number(at) }; + }) + .sort((a, b) => a.at - b.at); +} + +function workerOf(workers: Worker[], pid: number): Worker { + const found = workers.find(({ child }) => child.pid === pid); + if (!found) throw new Error(`no worker with pid ${pid}`); + return found; +} + +async function waitUntil(condition: () => boolean, timeoutMs: number, what: string): Promise { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +/** One process ran every run, and no two runs came closer than half an interval. */ +function expectOneHolderOncePerInterval(runs: Run[]): void { + expect(new Set(runs.map(({ pid }) => pid)).size).toBe(1); + for (let index = 1; index < runs.length; index++) { + expect(runs[index].at - runs[index - 1].at).toBeGreaterThanOrEqual(EVERY_MS / 2); + } +} + +describe('@Duty across processes', () => { + let home: string; + let started: Worker[] = []; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'local-runtime-duties-')); + }); + + afterEach(() => { + killAll(started); + fs.rmSync(home, { recursive: true, force: true }); + }); + + it('runs once per interval in one of five servers, and another takes over when the holder dies or stops', async () => { + const workers = Array.from({ length: SERVERS }, () => + spawnWorker('duty-server.ts', { FRONTMCP_HOME: home, EVERY_MS: String(EVERY_MS), LEASE_MS: String(LEASE_MS) }), + ); + started = workers; + await Promise.all(workers.map((worker) => worker.waitFor('ready'))); + + await waitUntil(() => runsOf(workers).length >= 6, 30_000, 'six runs'); + const beforeKill = runsOf(workers); + expectOneHolderOncePerInterval(beforeKill); + if (process.platform !== 'win32') expect(fs.statSync(path.join(home, 'duties.sqlite')).mode & 0o777).toBe(0o600); + + const first = workerOf(workers, beforeKill[0].pid); + const killedAt = Date.now(); + first.child.kill('SIGKILL'); + const survivors = workers.filter((worker) => worker !== first); + await waitUntil(() => runsOf(survivors).length >= 4, LEASE_MS * 3, 'a takeover after the holder was killed'); + const afterKill = runsOf(survivors); + expect(afterKill[0].at - killedAt).toBeLessThan(LEASE_MS * 2); + expectOneHolderOncePerInterval(afterKill); + + const second = workerOf(survivors, afterKill[0].pid); + const terminatedAt = Date.now(); + second.child.kill('SIGTERM'); + await expect(second.result).resolves.toEqual({ stopped: true }); + const lastRunOfSecond = runsOf([second]).at(-1)?.at ?? 0; + const rest = survivors.filter((worker) => worker !== second); + await waitUntil(() => runsOf(rest).length >= 2, LEASE_MS * 3, 'a takeover after the holder stopped'); + const afterStop = runsOf(rest); + expect(afterStop[0].at - terminatedAt).toBeLessThan((LEASE_MS * 2) / 3); + expect(afterStop[0].at - lastRunOfSecond).toBeGreaterThanOrEqual(EVERY_MS / 2); + expectOneHolderOncePerInterval(afterStop); + }); +}); diff --git a/apps/e2e/demo-e2e-local-runtime/e2e/helpers/workers.ts b/apps/e2e/demo-e2e-local-runtime/e2e/helpers/workers.ts index bb97a0c69..e1b995f95 100644 --- a/apps/e2e/demo-e2e-local-runtime/e2e/helpers/workers.ts +++ b/apps/e2e/demo-e2e-local-runtime/e2e/helpers/workers.ts @@ -6,6 +6,8 @@ const projectRoot = join(__dirname, '..', '..'); export interface Worker { readonly child: ChildProcess; + /** Every complete line the worker printed so far. */ + readonly lines: readonly string[]; /** Resolves when the worker prints `line`; rejects if it exits first. */ waitFor(line: string): Promise; send(line: string): void; @@ -53,6 +55,7 @@ export function spawnWorker(script: string, env: Record return { child, + lines, result, send: (line) => child.stdin?.write(`${line}\n`), waitFor: (line) => diff --git a/apps/e2e/demo-e2e-local-runtime/fixture/duty-server.ts b/apps/e2e/demo-e2e-local-runtime/fixture/duty-server.ts new file mode 100644 index 000000000..0f1a94192 --- /dev/null +++ b/apps/e2e/demo-e2e-local-runtime/fixture/duty-server.ts @@ -0,0 +1,37 @@ +/** A server with one machine duty that prints `ran ` on each run; SIGTERM shuts it down gracefully. */ +import 'reflect-metadata'; + +import { App, Duty, FrontMcpInstance, LogLevel } from '@frontmcp/sdk'; + +import { report, requiredEnv, runWorker, signal } from './worker-protocol'; + +@Duty({ + name: 'tick', + every: Number(requiredEnv('EVERY_MS')), + scope: 'machine', + leaseMs: Number(requiredEnv('LEASE_MS')), +}) +class TickDuty { + run(): void { + signal(`ran ${process.pid} ${Date.now()}`); + } +} + +@App({ name: 'duties', providers: [TickDuty] }) +class DutiesApp {} + +runWorker(async () => { + const server = await FrontMcpInstance.createDirect({ + info: { name: 'duty-e2e', version: '1.0.0' }, + apps: [DutiesApp], + logging: { level: LogLevel.Off, enableConsole: false }, + }); + const keepAlive = setInterval(() => undefined, 60_000); + const terminated = new Promise((resolve) => process.once('SIGTERM', () => resolve())); + signal('ready'); + + await terminated; + await server.dispose(); + clearInterval(keepAlive); + report({ stopped: true }); +}); diff --git a/libs/sdk/src/common/decorators/duty.decorator.ts b/libs/sdk/src/common/decorators/duty.decorator.ts new file mode 100644 index 000000000..752c1fcbd --- /dev/null +++ b/libs/sdk/src/common/decorators/duty.decorator.ts @@ -0,0 +1,29 @@ +import 'reflect-metadata'; + +import { InvalidDecoratorMetadataError } from '../../errors/decorator.errors'; +import { type DutyInterface } from '../interfaces/duty.interface'; +import { defineDutyMetadata, dutyMetadataSchema, type DutyMetadata } from '../metadata/duty.metadata'; +import { FrontMcpProviderTokens } from '../tokens'; + +type DutyClass = abstract new (...args: never[]) => DutyInterface; + +/** + * Marks a provider class as a duty: once the server serves, `run()` is called every `every` in exactly one process + * of the machine or project (`scope`), the one that holds the duty's lease. Register it in `providers`. + */ +export function Duty(metadata: DutyMetadata): (target: DutyClass) => void { + return (target) => { + const result = dutyMetadataSchema.safeParse(metadata); + if (!result.success) { + const [issue] = result.error.issues; + throw new InvalidDecoratorMetadataError('Duty', issue.path.join('.'), issue.message); + } + if (typeof target.prototype?.run !== 'function') { + throw new InvalidDecoratorMetadataError('Duty', 'run', `${target.name} has no run() method`); + } + const { name = target.name, every: everyMs, scope, leaseMs } = result.data; + const defaultLeaseMs = Math.min(Math.max(3 * everyMs, 3_000), 60_000); + Reflect.defineMetadata(FrontMcpProviderTokens.type, true, target); + defineDutyMetadata(target, { name, everyMs, scope, leaseMs: leaseMs ?? defaultLeaseMs }); + }; +} diff --git a/libs/sdk/src/common/decorators/index.ts b/libs/sdk/src/common/decorators/index.ts index 8aff42cd2..b359d15dc 100644 --- a/libs/sdk/src/common/decorators/index.ts +++ b/libs/sdk/src/common/decorators/index.ts @@ -15,3 +15,4 @@ export * from './skill.decorator'; export * from './job.decorator'; export * from './workflow.decorator'; export * from './channel.decorator'; +export * from './duty.decorator'; diff --git a/libs/sdk/src/common/interfaces/duty.interface.ts b/libs/sdk/src/common/interfaces/duty.interface.ts new file mode 100644 index 000000000..a68d096f6 --- /dev/null +++ b/libs/sdk/src/common/interfaces/duty.interface.ts @@ -0,0 +1,10 @@ +/** What a duty's `run()` receives. */ +export interface DutyRunContext { + /** Aborted when this process loses the duty's lease or shuts down: stop the run early when it fires. */ + readonly signal: AbortSignal; +} + +/** A `@Duty` class: `run()` is called every interval in the one process that holds the duty's lease. */ +export interface DutyInterface { + run(context: DutyRunContext): void | Promise; +} diff --git a/libs/sdk/src/common/interfaces/index.ts b/libs/sdk/src/common/interfaces/index.ts index f6670f52f..708b098a0 100644 --- a/libs/sdk/src/common/interfaces/index.ts +++ b/libs/sdk/src/common/interfaces/index.ts @@ -37,5 +37,6 @@ export * from './skill.interface'; export * from './job.interface'; export * from './workflow.interface'; export * from './channel.interface'; +export * from './duty.interface'; export * from './internal'; diff --git a/libs/sdk/src/common/metadata/duty.metadata.ts b/libs/sdk/src/common/metadata/duty.metadata.ts new file mode 100644 index 000000000..6d1fdc27c --- /dev/null +++ b/libs/sdk/src/common/metadata/duty.metadata.ts @@ -0,0 +1,67 @@ +import { z } from '@frontmcp/lazy-zod'; + +/** Where one process at a time runs a duty: one per machine, or one per project root (the nearest package.json). */ +export type DutyScope = 'machine' | 'workspace'; + +export interface DutyMetadata { + /** The lease's name within the server. Set it when a build renames classes. @default the class name */ + name?: string; + /** How often the duty runs: milliseconds, or a duration such as `'500ms'`, `'30s'`, `'5m'`, `'1h'` or `'1d'`. */ + every: number | string; + scope: DutyScope; + /** + * How long the lease outlives a holder that stopped renewing it (killed, paused, hung), so how soon another process + * takes the duty over. Renewed every third of it. @default three intervals, between 3000 and 60000 + */ + leaseMs?: number; +} + +export interface ResolvedDutyMetadata { + name: string; + everyMs: number; + scope: DutyScope; + leaseMs: number; +} + +const DUTY_METADATA = Symbol.for('frontmcp:duty'); +const MAX_TIMER_MS = 2_147_483_647; +const UNIT_MS: Record = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }; +const EVERY_FORMAT = 'every must be milliseconds or a duration such as "30s", "5m", "1h"'; + +function toMilliseconds(every: number | string): number { + if (typeof every === 'number') return every; + const match = /^(\d+)(ms|s|m|h|d)$/.exec(every.trim()); + return match ? Number(match[1]) * UNIT_MS[match[2]] : Number.NaN; +} + +function wholeMilliseconds(field: string) { + return z + .number({ message: field === 'every' ? EVERY_FORMAT : `${field} must be a number of milliseconds` }) + .int(`${field} must be a whole number of milliseconds`) + .max(MAX_TIMER_MS, `${field} must be at most ${MAX_TIMER_MS} ms (about 24.8 days)`); +} + +export const dutyMetadataSchema = z + .object({ + name: z.string({ message: 'name must be a string' }).min(1, 'name must not be empty').optional(), + every: z + .union([z.number(), z.string()], { message: EVERY_FORMAT }) + .transform(toMilliseconds) + .pipe(wholeMilliseconds('every').positive('every must be greater than 0')), + scope: z.enum(['machine', 'workspace'], { message: "scope must be 'machine' or 'workspace'" }), + leaseMs: wholeMilliseconds('leaseMs') + .min(1_000, 'leaseMs must be at least 1000 (stores keep a TTL in whole seconds)') + .optional(), + }) + .strict(); + +/** @internal Records what `@Duty` resolved on the class. */ +export function defineDutyMetadata(target: object, metadata: ResolvedDutyMetadata): void { + Reflect.defineMetadata(DUTY_METADATA, metadata, target); +} + +/** The `@Duty` metadata of an instance's class, when it has some. */ +export function dutyMetadataOf(value: unknown): ResolvedDutyMetadata | undefined { + if (typeof value !== 'object' || value === null) return undefined; + return Reflect.getMetadata(DUTY_METADATA, value.constructor); +} diff --git a/libs/sdk/src/common/metadata/index.ts b/libs/sdk/src/common/metadata/index.ts index f371756a1..101771608 100644 --- a/libs/sdk/src/common/metadata/index.ts +++ b/libs/sdk/src/common/metadata/index.ts @@ -25,3 +25,4 @@ export * from './workflow.metadata'; export * from './channel.metadata'; export * from './app-filter.metadata'; export * from './remote-primitive.metadata'; +export * from './duty.metadata'; diff --git a/libs/sdk/src/common/types/options/sqlite/resolve.ts b/libs/sdk/src/common/types/options/sqlite/resolve.ts index 1375d9666..24b9df4cc 100644 --- a/libs/sdk/src/common/types/options/sqlite/resolve.ts +++ b/libs/sdk/src/common/types/options/sqlite/resolve.ts @@ -68,7 +68,8 @@ function sanitizeForFs(name: string | undefined): string { return name.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/^[-.]+/, ''); } -function findProjectRoot(start: string): string | undefined { +/** The nearest directory above `start`, itself included, that holds a package.json. */ +export function findProjectRoot(start: string): string | undefined { let dir = start; // Cap the walk at 32 levels to bound worst-case (sandbox / symlink loops). // `@frontmcp/utils.readFileSync` is the sync FS escape hatch per the diff --git a/libs/sdk/src/duty/__tests__/duty.decorator.spec.ts b/libs/sdk/src/duty/__tests__/duty.decorator.spec.ts new file mode 100644 index 000000000..463a53624 --- /dev/null +++ b/libs/sdk/src/duty/__tests__/duty.decorator.spec.ts @@ -0,0 +1,99 @@ +import 'reflect-metadata'; + +import { Duty, dutyMetadataOf, type DutyMetadata, type DutyRunContext } from '../../common'; +import { InvalidDecoratorMetadataError } from '../../errors'; + +function decorate(metadata: DutyMetadata): object { + @Duty(metadata) + class SweepDuty { + run(_context: DutyRunContext): void { + return undefined; + } + } + return new SweepDuty(); +} + +function decorationError(metadata: unknown): unknown { + try { + decorate(metadata as DutyMetadata); + } catch (error) { + return error; + } + return undefined; +} + +describe('@Duty', () => { + it.each([ + [250, 250], + ['250ms', 250], + ['30s', 30_000], + ['5m', 300_000], + ['2h', 7_200_000], + ['1d', 86_400_000], + ])('reads every: %p as %p ms', (every, everyMs) => { + expect(dutyMetadataOf(decorate({ every, scope: 'machine' }))).toMatchObject({ everyMs }); + }); + + it('names the duty after its class unless named, and keeps the scope', () => { + expect(dutyMetadataOf(decorate({ every: '1m', scope: 'workspace' }))).toEqual({ + name: 'SweepDuty', + everyMs: 60_000, + scope: 'workspace', + leaseMs: 60_000, + }); + expect(dutyMetadataOf(decorate({ name: 'sweep-cache', every: '1m', scope: 'machine' }))?.name).toBe('sweep-cache'); + }); + + it.each([ + [100, 3_000], + [1_500, 4_500], + [10_000, 30_000], + [3_600_000, 60_000], + ])('gives every: %p a lease of %p ms by default (three intervals, between 3 s and 60 s)', (every, leaseMs) => { + expect(dutyMetadataOf(decorate({ every, scope: 'machine' }))?.leaseMs).toBe(leaseMs); + }); + + it('keeps an explicit leaseMs', () => { + expect(dutyMetadataOf(decorate({ every: '1h', scope: 'machine', leaseMs: 5_000 }))?.leaseMs).toBe(5_000); + }); + + it.each([ + [{ every: '5x', scope: 'machine' }, 'every', 'every must be milliseconds or a duration such as "30s", "5m", "1h"'], + [{ every: 0, scope: 'machine' }, 'every', 'every must be greater than 0'], + [{ every: -5, scope: 'machine' }, 'every', 'every must be greater than 0'], + [{ every: 1.5, scope: 'machine' }, 'every', 'every must be a whole number of milliseconds'], + [{ every: '30d', scope: 'machine' }, 'every', 'every must be at most 2147483647 ms (about 24.8 days)'], + [{ every: '1m', scope: 'galaxy' }, 'scope', "scope must be 'machine' or 'workspace'"], + [{ every: '1m' }, 'scope', "scope must be 'machine' or 'workspace'"], + [ + { every: '1m', scope: 'machine', leaseMs: 500 }, + 'leaseMs', + 'leaseMs must be at least 1000 (stores keep a TTL in whole seconds)', + ], + [{ every: '1m', scope: 'machine', leaseMs: 1_000.5 }, 'leaseMs', 'leaseMs must be a whole number of milliseconds'], + [{ every: '1m', scope: 'machine', name: '' }, 'name', 'name must not be empty'], + [{ every: '1m', scope: 'machine', cron: '* * * * *' }, '', 'Unrecognized key: "cron"'], + ])('refuses %j with InvalidDecoratorMetadataError on %p', (metadata, field, message) => { + const error = decorationError(metadata); + expect(error).toBeInstanceOf(InvalidDecoratorMetadataError); + expect((error as Error).message).toBe(`@Duty invalid metadata for "${field}": ${message}`); + }); + + it('refuses a class without a run() method', () => { + const decorateWithoutRun = () => { + // @ts-expect-error a duty needs run() + @Duty({ every: '1m', scope: 'machine' }) + class NoRunDuty {} + return NoRunDuty; + }; + expect(decorateWithoutRun).toThrow(InvalidDecoratorMetadataError); + expect(decorateWithoutRun).toThrow('@Duty invalid metadata for "run": NoRunDuty has no run() method'); + }); + + it('reads no duty metadata from other values', () => { + class PlainProvider {} + expect(dutyMetadataOf(new PlainProvider())).toBeUndefined(); + expect(dutyMetadataOf(undefined)).toBeUndefined(); + expect(dutyMetadataOf('text')).toBeUndefined(); + }); +}); diff --git a/libs/sdk/src/duty/__tests__/duty.lifecycle.spec.ts b/libs/sdk/src/duty/__tests__/duty.lifecycle.spec.ts new file mode 100644 index 000000000..3bcf6362e --- /dev/null +++ b/libs/sdk/src/duty/__tests__/duty.lifecycle.spec.ts @@ -0,0 +1,344 @@ +import 'reflect-metadata'; + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { resetRuntimeContext } from '@frontmcp/utils'; + +import { App, Duty, LogLevel, redisOptionsSchema, type OnBootstrap, type OnReady, type OnShutdown } from '../../common'; +import { findProjectRoot } from '../../common/types/options/sqlite/resolve'; +import type { DirectMcpServer } from '../../direct'; +import { DutyNameConflictError, UnsafeDutyStoreError } from '../../errors'; +import { FrontMcpInstance } from '../../front-mcp/front-mcp'; +import { createContainer, type FrontMcpContainer } from '../../provider/create-container'; +import { dutyStorageConfig } from '../duty.lifecycle'; + +jest.mock('../../common/types/options/sqlite/resolve', () => { + const actual = jest.requireActual('../../common/types/options/sqlite/resolve'); + return { ...actual, findProjectRoot: jest.fn(actual.findProjectRoot) }; +}); + +const quiet = { level: LogLevel.Off, enableConsole: false }; +const info = { name: 'duty-spec' }; +const describeOnPosix = process.platform === 'win32' ? describe.skip : describe; +const ran: object[] = []; +const shutdowns: string[] = []; + +@Duty({ every: 100, scope: 'machine' }) +class SweepDuty implements OnShutdown { + run(): void { + ran.push(this); + } + onShutdown(): void { + shutdowns.push('sweep'); + } +} + +@Duty({ name: 'index-project', every: 100, scope: 'workspace' }) +class IndexDuty { + run(): void { + ran.push(this); + } +} + +@App({ name: 'Sweeper', providers: [SweepDuty] }) +class SweeperApp {} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function until(condition: () => boolean, timeoutMs = 8_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() > deadline) throw new Error('condition not met in time'); + await sleep(20); + } +} + +describe('duties in a server and in createContainer()', () => { + let home: string; + let previousHome: string | undefined; + let open: Array; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-duties-')); + previousHome = process.env['FRONTMCP_HOME']; + process.env['FRONTMCP_HOME'] = home; + ran.length = 0; + shutdowns.length = 0; + open = []; + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await Promise.all(open.map((opened) => opened.dispose())); + if (previousHome === undefined) delete process.env['FRONTMCP_HOME']; + else process.env['FRONTMCP_HOME'] = previousHome; + fs.rmSync(home, { recursive: true, force: true }); + }); + + async function container(options: Parameters[0]): Promise { + const built = await createContainer({ logging: quiet, ...options }); + open.push(built); + return built; + } + + it('builds a duty in createContainer() without starting it, and still runs its own onShutdown()', async () => { + const built = await createContainer({ providers: [SweepDuty], logging: quiet }); + + expect(built.get(SweepDuty)).toBeInstanceOf(SweepDuty); + await sleep(300); + expect(ran).toEqual([]); + expect(fs.existsSync(path.join(home, 'duties.sqlite'))).toBe(false); + + await built.dispose(); + expect(shutdowns).toEqual(['sweep']); + }); + + it('starts the duties of a container that asks with runDuties, and stops them at dispose()', async () => { + const label = { name: 'label', provide: Symbol('label'), useValue: 'nightly' }; + const settings = { name: 'settings', provide: Symbol('settings'), useValue: { region: 'eu' } }; + const built = await createContainer({ + providers: [label, settings, SweepDuty], + runDuties: true, + info, + logging: quiet, + }); + + await until(() => ran.length >= 2); + await built.dispose(); + const runsAtDispose = ran.length; + await sleep(300); + + expect(ran.length).toBe(runsAtDispose); + expect(fs.existsSync(path.join(home, 'duties.sqlite'))).toBe(true); + expect(shutdowns).toEqual(['sweep']); + }); + + it('runs a machine duty in one of two servers, and the other takes it over when the first shuts down', async () => { + const config = { info: { name: 'duty-spec', version: '1.0.0' }, apps: [SweeperApp], logging: quiet }; + const first = await FrontMcpInstance.createDirect(config); + open.push(first); + await until(() => ran.length >= 1); + const second = await FrontMcpInstance.createDirect(config); + open.push(second); + + await sleep(400); + expect(new Set(ran).size).toBe(1); + const firstHolder = ran[0]; + + await first.dispose(); + await until(() => ran.some((duty) => duty !== firstHolder)); + const afterTakeover = new Set(ran.slice(ran.findIndex((duty) => duty !== firstHolder))); + expect(afterTakeover.has(firstHolder)).toBe(false); + }); + + it("keeps the duty's own hooks: onBootstrap() once built, onReady() before its first run", async () => { + const phases: string[] = []; + @Duty({ every: 100, scope: 'machine' }) + class HookedDuty implements OnBootstrap, OnReady { + onBootstrap(): void { + phases.push('bootstrap'); + } + onReady(): void { + phases.push('ready'); + } + run(): void { + phases.push('run'); + } + } + @App({ name: 'Hooked', providers: [HookedDuty] }) + class HookedApp {} + + const server = await FrontMcpInstance.createDirect({ + info: { name: 'duty-spec', version: '1.0.0' }, + apps: [HookedApp], + logging: quiet, + }); + open.push(server); + await until(() => phases.includes('run')); + + expect(phases.slice(0, 3)).toEqual(['bootstrap', 'ready', 'run']); + }); + + it('keys a workspace duty by its project root, and every lease by the server name', async () => { + const rootA = fs.mkdtempSync(path.join(home, 'project-a-')); + const rootB = fs.mkdtempSync(path.join(home, 'project-b-')); + fs.writeFileSync(path.join(rootA, 'package.json'), '{}'); + fs.writeFileSync(path.join(rootB, 'package.json'), '{}'); + const cwd = jest.spyOn(process, 'cwd'); + const inProject = async (dir: string, name = 'duty-spec') => { + cwd.mockReturnValue(dir); + return (await container({ providers: [IndexDuty], runDuties: true, info: { name } })).get(IndexDuty); + }; + + const inA = await inProject(rootA); + const inB = await inProject(rootB); + const inASubdirectory = await inProject(path.join(rootA, 'src')); + const otherServerInA = await inProject(rootA, 'other-server'); + await until(() => [inA, inB, otherServerInA].every((duty) => ran.includes(duty))); + await sleep(300); + + expect(ran).not.toContain(inASubdirectory); + }); + + it("runs the duty's own onReady() before its first run in a container that runs duties", async () => { + const phases: string[] = []; + @Duty({ name: 'hooked-in-container', every: 100, scope: 'machine' }) + class HookedDuty implements OnReady { + onReady(): void { + phases.push('ready'); + } + run(): void { + phases.push('run'); + } + } + + await container({ providers: [HookedDuty], runDuties: true, info }); + await until(() => phases.includes('run')); + + expect(phases[0]).toBe('ready'); + }); + + it('needs the server name to run duties in a container, since leases are per server name', async () => { + await expect(createContainer({ providers: [SweepDuty], runDuties: true, logging: quiet })).rejects.toThrow( + 'runDuties needs info.name', + ); + expect(fs.existsSync(path.join(home, 'duties.sqlite'))).toBe(false); + }); + + it('refuses a second duty class that would share the lease of another in this process', async () => { + @Duty({ name: 'shared', every: 100, scope: 'machine' }) + class FirstShared { + run(): void { + ran.push(this); + } + } + @Duty({ name: 'shared', every: 100, scope: 'machine' }) + class SecondShared { + run(): void { + ran.push(this); + } + } + + const first = (await container({ providers: [FirstShared], runDuties: true, info })).get(FirstShared); + const conflict = createContainer({ providers: [SecondShared], runDuties: true, info, logging: quiet }); + await expect(conflict).rejects.toBeInstanceOf(DutyNameConflictError); + await expect(conflict).rejects.toThrow( + 'Duty "shared" of server "duty-spec" is declared by two classes in this process (FirstShared and SecondShared)', + ); + const again = (await container({ providers: [FirstShared], runDuties: true, info })).get(FirstShared); + await until(() => ran.includes(first) || ran.includes(again)); + }); + + it('keeps the leases of names that contain colons apart', async () => { + @Duty({ name: 'c', every: 100, scope: 'machine' }) + class NamedC { + run(): void { + ran.push(this); + } + } + @Duty({ name: 'b:c', every: 100, scope: 'machine' }) + class NamedBC { + run(): void { + ran.push(this); + } + } + + const c = (await container({ providers: [NamedC], runDuties: true, info: { name: 'a:b' } })).get(NamedC); + const bc = (await container({ providers: [NamedBC], runDuties: true, info: { name: 'a' } })).get(NamedBC); + + await until(() => ran.includes(c) && ran.includes(bc), 3_000); + }); + + it('opens no lease store when the project root cannot be read', async () => { + jest.mocked(findProjectRoot).mockImplementationOnce(() => { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + }); + + await expect(createContainer({ providers: [IndexDuty], runDuties: true, info, logging: quiet })).rejects.toThrow( + 'permission denied', + ); + expect(fs.existsSync(path.join(home, 'duties.sqlite'))).toBe(false); + }); + + it('does not start duties in a serverless deployment', async () => { + process.env['FRONTMCP_DEPLOYMENT_MODE'] = 'serverless'; + resetRuntimeContext(); + try { + await container({ providers: [SweepDuty], runDuties: true, info }); + await sleep(300); + } finally { + delete process.env['FRONTMCP_DEPLOYMENT_MODE']; + resetRuntimeContext(); + } + + expect(ran).toEqual([]); + expect(fs.existsSync(path.join(home, 'duties.sqlite'))).toBe(false); + }); + + describeOnPosix('the lease file', () => { + it('is created owner-only (0600) in a folder created owner-only (0700)', async () => { + const parent = path.join(home, 'nested'); + const leaseHome = path.join(parent, 'home'); + process.env['FRONTMCP_HOME'] = leaseHome; + await container({ providers: [SweepDuty], runDuties: true, info }); + + expect(fs.statSync(parent).mode & 0o777).toBe(0o700); + expect(fs.statSync(leaseHome).mode & 0o777).toBe(0o700); + const files = fs.readdirSync(leaseHome); + expect(files).toContain('duties.sqlite'); + for (const file of files) expect(fs.statSync(path.join(leaseHome, file)).mode & 0o777).toBe(0o600); + }); + + it.each([0o777, 0o1777, 0o770])( + 'is refused in a folder the group or others can write to (mode %o)', + async (mode) => { + fs.chmodSync(home, mode); + + const refused = createContainer({ providers: [SweepDuty], runDuties: true, info, logging: quiet }); + await expect(refused).rejects.toBeInstanceOf(UnsafeDutyStoreError); + await expect(refused).rejects.toThrow( + `Refusing to keep duty leases under ${home} (mode 0${mode.toString(8)}): the group or others can write to it`, + ); + expect(fs.existsSync(path.join(home, 'duties.sqlite'))).toBe(false); + }, + ); + }); + + it('names the other user that owns a lease folder it refuses', () => { + expect(new UnsafeDutyStoreError('/srv/shared', 0o40755, 501).message).toBe( + 'Refusing to keep duty leases under /srv/shared (mode 0755): it belongs to another user (uid 501). ' + + 'Set FRONTMCP_HOME to a folder only you can write to.', + ); + }); + + it("keeps leases in the server's Redis when one is configured, and in the machine's SQLite file otherwise", () => { + const redis = redisOptionsSchema.parse({ url: 'rediss://:secret@cache.internal:6380/2' }); + expect(dutyStorageConfig(redis)).toEqual({ + type: 'redis', + redis: { config: { host: 'cache.internal', port: 6380, password: 'secret', db: 2, tls: true } }, + prefix: `frontmcp:duty:${os.hostname()}:`, + fallback: 'error', + }); + + const sqlite = { + type: 'sqlite', + sqlite: { path: path.join(home, 'duties.sqlite') }, + prefix: 'frontmcp:duty:', + fallback: 'error', + }; + expect(dutyStorageConfig(undefined)).toEqual(sqlite); + expect(dutyStorageConfig(redisOptionsSchema.parse({ provider: 'vercel-kv' }))).toEqual(sqlite); + + expect(dutyStorageConfig({ url: 'redis://cache.internal:6379' })).toMatchObject({ + type: 'redis', + redis: { config: { host: 'cache.internal', port: 6379, db: 0, tls: false } }, + }); + + delete process.env['FRONTMCP_HOME']; + expect(dutyStorageConfig(undefined).sqlite?.path).toBe(path.join(os.homedir(), '.frontmcp', 'duties.sqlite')); + }); +}); diff --git a/libs/sdk/src/duty/__tests__/duty.runner.spec.ts b/libs/sdk/src/duty/__tests__/duty.runner.spec.ts new file mode 100644 index 000000000..f320d4ca5 --- /dev/null +++ b/libs/sdk/src/duty/__tests__/duty.runner.spec.ts @@ -0,0 +1,372 @@ +import { createMemoryStorage, type RootStorage } from '@frontmcp/utils'; + +import type { DutyRunContext } from '../../common'; +import { DutyRunner, type DutyLeaseStorage, type DutyLogger } from '../duty.runner'; + +const START = 1_700_000_000_000; +const EVERY_MS = 1_000; +const LEASE_MS = 3_000; +const KEY = 'demo:sweep'; + +interface Probe { + runner: DutyRunner; + runs: number[]; + logger: jest.Mocked; +} + +function elapsed(): number { + return Date.now() - START; +} + +function probe( + storage: DutyLeaseStorage, + run: (context: DutyRunContext) => unknown = () => undefined, + everyMs = EVERY_MS, +): Probe { + const runs: number[] = []; + const logger = { verbose: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }; + const runner = new DutyRunner({ + name: 'sweep', + key: KEY, + everyMs, + leaseMs: LEASE_MS, + storage, + logger, + run: (context) => { + runs.push(elapsed()); + return run(context); + }, + }); + return { runner, runs, logger }; +} + +/** A view of the store whose operations wait while paused, as a stopped (SIGSTOP) or dead process's would. */ +function pausable(storage: DutyLeaseStorage) { + let gate: Promise | undefined; + let open: () => void = () => undefined; + const through = (operation: () => Promise): Promise => (gate ? gate.then(operation) : operation()); + const view: DutyLeaseStorage = { + set: (key, value, options) => through(() => storage.set(key, value, options)), + ttl: (key) => through(() => storage.ttl(key)), + expireIfEquals: (key, value, ttlSeconds) => through(() => storage.expireIfEquals(key, value, ttlSeconds)), + deleteIfEquals: (key, value) => through(() => storage.deleteIfEquals(key, value)), + }; + return { + storage: view, + pause: () => { + gate = new Promise((resolve) => (open = resolve)); + }, + resume: () => { + gate = undefined; + open(); + }, + }; +} + +/** A view of the store that fails every operation while cut, as an unreachable Redis or a locked file would. */ +function cuttable(storage: DutyLeaseStorage) { + let cut = false; + const through = (operation: () => Promise): Promise => + cut ? Promise.reject(new Error('connection lost')) : operation(); + const view: DutyLeaseStorage = { + set: (key, value, options) => through(() => storage.set(key, value, options)), + ttl: (key) => through(() => storage.ttl(key)), + expireIfEquals: (key, value, ttlSeconds) => through(() => storage.expireIfEquals(key, value, ttlSeconds)), + deleteIfEquals: (key, value) => through(() => storage.deleteIfEquals(key, value)), + }; + return { storage: view, cut: () => (cut = true) }; +} + +function untilAborted(onAbort: () => void = () => undefined) { + return ({ signal }: DutyRunContext) => + new Promise((resolve) => + signal.addEventListener('abort', () => { + onAbort(); + resolve(); + }), + ); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('DutyRunner', () => { + let storage: RootStorage; + let started: DutyRunner[]; + + beforeEach(async () => { + jest.useFakeTimers({ now: START }); + storage = createMemoryStorage(); + await storage.connect(); + started = []; + }); + + afterEach(async () => { + const stopping = Promise.all(started.map((runner) => runner.stop())); + await jest.runOnlyPendingTimersAsync(); + await stopping; + await storage.disconnect(); + jest.useRealTimers(); + }); + + async function start(...probes: Probe[]): Promise { + for (const { runner } of probes) { + started.push(runner); + await runner.start(); + } + } + + it('runs on one holder only, once per interval, renewing the lease while it holds it', async () => { + const first = probe(storage); + const second = probe(storage); + await start(first, second); + + await jest.advanceTimersByTimeAsync(10 * EVERY_MS); + + expect(first.runs).toEqual([0, 1_000, 2_000, 3_000, 4_000, 5_000, 6_000, 7_000, 8_000, 9_000, 10_000]); + expect(second.runs).toEqual([]); + expect(first.logger.info).toHaveBeenCalledWith('Duty "sweep" runs in this process every 1000 ms'); + expect(second.logger.info.mock.calls).toEqual([ + ['Duty "sweep" already runs elsewhere (lease "demo:sweep"); this one takes it over if that one stops'], + ]); + }); + + it('skips a run that would overlap the one still running, instead of stacking it', async () => { + let inFlight = 0; + let mostInFlight = 0; + const holder = probe(storage, async () => { + inFlight++; + mostInFlight = Math.max(mostInFlight, inFlight); + await delay(2_500); + inFlight--; + }); + await start(holder); + + await jest.advanceTimersByTimeAsync(10 * EVERY_MS); + + expect(holder.runs).toEqual([0, 3_000, 6_000, 9_000]); + expect(mostInFlight).toBe(1); + expect(holder.logger.verbose).toHaveBeenCalledWith('Duty "sweep" is still running; skipping this run'); + }); + + it('logs a failed run and keeps the lease', async () => { + let calls = 0; + const holder = probe(storage, () => { + calls++; + if (calls === 1) throw new Error('disk full'); + }); + const other = probe(storage); + await start(holder, other); + + await jest.advanceTimersByTimeAsync(2 * LEASE_MS); + + expect(holder.logger.error).toHaveBeenCalledWith('Duty "sweep" failed: disk full'); + expect(holder.runs).toEqual([0, 1_000, 2_000, 3_000, 4_000, 5_000, 6_000]); + expect(other.runs).toEqual([]); + }); + + it('releases the lease on stop, and the next holder waits out the interval the last run started', async () => { + const first = probe(storage, () => undefined, 5_000); + const second = probe(storage, () => undefined, 5_000); + await start(first, second); + + await jest.advanceTimersByTimeAsync(200); + await first.runner.stop(); + await jest.advanceTimersByTimeAsync(12_000); + + expect(first.runs).toEqual([0]); + expect(second.runs).toEqual([5_000, 10_000]); + expect(second.logger.info).toHaveBeenCalledWith('Duty "sweep" runs in this process every 5000 ms'); + }); + + it('takes over from a holder that died once its lease runs out', async () => { + const dying = pausable(storage); + const first = probe(dying.storage); + const second = probe(storage); + await start(first, second); + + await jest.advanceTimersByTimeAsync(500); + dying.pause(); + await jest.advanceTimersByTimeAsync(6_000); + + expect(first.runs).toEqual([0]); + const [takeover] = second.runs; + expect(takeover).toBeGreaterThanOrEqual(LEASE_MS); + expect(takeover).toBeLessThanOrEqual(500 + LEASE_MS + LEASE_MS / 3); + expect(second.runs.map((at) => at - takeover)).toEqual([0, 1_000, 2_000, 3_000]); + dying.resume(); + }); + + it('never runs again in a holder that wakes from a pause after another process took the lease over', async () => { + const sleeper = pausable(storage); + const first = probe(sleeper.storage); + const second = probe(storage); + await start(first, second); + + await jest.advanceTimersByTimeAsync(500); + sleeper.pause(); + await jest.advanceTimersByTimeAsync(6_000); + sleeper.resume(); + await jest.advanceTimersByTimeAsync(10_000); + + expect(first.runs).toEqual([0]); + expect(first.logger.warn).toHaveBeenCalledWith( + 'Duty "sweep" could not renew its lease within 3000 ms; it stops here until it gets the lease again', + ); + expect(second.runs).toHaveLength(14); + }); + + it('aborts the run in flight when its lease is lost', async () => { + let abortedAt: number | undefined; + const holder = probe( + storage, + ({ signal }) => + new Promise((resolve) => + signal.addEventListener('abort', () => { + abortedAt = elapsed(); + resolve(); + }), + ), + ); + await start(holder); + + await jest.advanceTimersByTimeAsync(500); + await storage.set(KEY, 'another-process', { ttlSeconds: 3 }); + await jest.advanceTimersByTimeAsync(1_000); + + expect(abortedAt).toBe(1_000); + expect(holder.logger.warn).toHaveBeenCalledWith('Duty "sweep" lost its lease; another process runs it now'); + }); + + it('aborts the run in flight on stop, and releases the lease once the run has ended', async () => { + let runEnded = false; + const holder = probe( + storage, + ({ signal }) => + new Promise((resolve) => + signal.addEventListener('abort', () => + setTimeout(() => { + runEnded = true; + resolve(); + }, 100), + ), + ), + ); + await start(holder); + await jest.advanceTimersByTimeAsync(500); + + let stopped = false; + const stopping = holder.runner.stop().then(() => (stopped = true)); + await jest.advanceTimersByTimeAsync(50); + expect(stopped).toBe(false); + expect(await storage.get(KEY)).not.toBeNull(); + + await jest.advanceTimersByTimeAsync(50); + await stopping; + expect(runEnded).toBe(true); + expect(await storage.get(KEY)).toBeNull(); + }); + + it('skips the run when the lease store fails, and keeps the lease once the store answers again', async () => { + const holder = probe(storage); + const other = probe(storage); + await start(holder, other); + + await jest.advanceTimersByTimeAsync(900); + const failing = jest.spyOn(storage, 'expireIfEquals').mockRejectedValue(new Error('database is locked')); + await jest.advanceTimersByTimeAsync(200); + failing.mockRestore(); + await jest.advanceTimersByTimeAsync(1_900); + + expect(holder.runs).toEqual([0, 2_000, 3_000]); + expect(other.runs).toEqual([]); + expect(holder.logger.warn).toHaveBeenCalledWith('Duty "sweep": the lease store failed: database is locked'); + }); + + it('waits a whole interval before its first run when the store cannot say when the last run started', async () => { + jest.spyOn(storage, 'ttl').mockRejectedValueOnce(new Error('database is locked')); + const holder = probe(storage); + await start(holder); + + await jest.advanceTimersByTimeAsync(3 * EVERY_MS); + + expect(holder.runs).toEqual([1_000, 2_000, 3_000]); + }); + + it('aborts the run in flight once it could not renew for a whole lease, before another process can take over', async () => { + let abortedAt: number | undefined; + const unreachable = cuttable(storage); + const holder = probe( + unreachable.storage, + untilAborted(() => (abortedAt = elapsed())), + ); + const other = probe(storage); + await start(holder, other); + + await jest.advanceTimersByTimeAsync(500); + unreachable.cut(); + await jest.advanceTimersByTimeAsync(3 * LEASE_MS); + + expect(abortedAt).toBe(LEASE_MS); + expect(other.runs[0]).toBeGreaterThanOrEqual(LEASE_MS); + expect(holder.logger.warn).toHaveBeenCalledWith( + 'Duty "sweep" could not renew its lease within 3000 ms; it stops here until it gets the lease again', + ); + }); + + it('keeps its lease while the run it aborted at stop() winds down, so no other process runs meanwhile', async () => { + let endedAt: number | undefined; + const slowToStop = probe( + storage, + ({ signal }) => + new Promise((resolve) => + signal.addEventListener('abort', () => + setTimeout(() => { + endedAt = elapsed(); + resolve(); + }, 2 * LEASE_MS), + ), + ), + ); + const next = probe(storage); + await start(slowToStop, next); + + await jest.advanceTimersByTimeAsync(500); + const stopping = slowToStop.runner.stop(); + await jest.advanceTimersByTimeAsync(3 * LEASE_MS); + await stopping; + + expect(endedAt).toBe(500 + 2 * LEASE_MS); + expect(next.runs.length).toBeGreaterThan(0); + expect(next.runs[0]).toBeGreaterThanOrEqual(500 + 2 * LEASE_MS); + }); + + it('waits out the interval on a takeover even when it is longer than a timer can wait at once', async () => { + const longestEveryMs = 2_147_483_647; + const first = probe(storage, () => undefined, longestEveryMs); + const second = probe(storage, () => undefined, longestEveryMs); + await start(first); + + await jest.advanceTimersByTimeAsync(100); + await first.runner.stop(); + await start(second); + await jest.advanceTimersByTimeAsync(10_000); + + expect(first.runs).toEqual([0]); + expect(second.runs).toEqual([]); + }); + + it('releases a lease that its first check got after stop() was called', async () => { + const holder = probe(storage); + started.push(holder.runner); + + const starting = holder.runner.start(); + await holder.runner.stop(); + await starting; + await jest.advanceTimersByTimeAsync(5_000); + + expect(holder.runs).toEqual([]); + expect(await storage.get(KEY)).toBeNull(); + }); +}); diff --git a/libs/sdk/src/duty/duty.lifecycle.ts b/libs/sdk/src/duty/duty.lifecycle.ts new file mode 100644 index 000000000..aac9dd1d2 --- /dev/null +++ b/libs/sdk/src/duty/duty.lifecycle.ts @@ -0,0 +1,199 @@ +import { + createStorage, + getEnv, + getRuntimeContext, + mkdir, + realpath, + sha256Hex, + stat, + writeFile, + type RootStorage, + type StorageConfig, +} from '@frontmcp/utils'; + +import { + FrontMcpLogger, + isRedisProvider, + redisOptionsSchema, + type DutyInterface, + type OnBootstrap, + type OnReady, + type OnShutdown, + type ResolvedDutyMetadata, +} from '../common'; +import { findProjectRoot } from '../common/types/options/sqlite/resolve'; +import { DutyNameConflictError, UnsafeDutyStoreError } from '../errors/duty.errors'; +import { FrontMcpConfig } from '../front-mcp/front-mcp.tokens'; +import type ProviderRegistry from '../provider/provider.registry'; +import { findReplaceableDirectory } from '../server/security/unix-socket-guard'; +import { DutyRunner } from './duty.runner'; + +type DutyInstance = DutyInterface & Partial; + +interface StartedDuty { + runner: DutyRunner; + storage: RootStorage; +} + +const lifecycles = new WeakMap(); + +/** The duty class that holds each lease key in this process, and how many of its instances started. */ +const claims = new Map(); + +const OWNER_ONLY_DIRECTORY = 0o700; +const OWNER_ONLY_FILE = 0o600; +const GROUP_OR_OTHER_WRITE = 0o022; + +/** Node's `os` and `path`, loaded on first use: duties are Node-only and a browser bundle must not import them. */ +function nodeOs(): typeof import('os') { + return require('os'); +} + +function nodePath(): typeof import('path') { + return require('path'); +} + +/** The lifecycle the provider registry runs for a `@Duty` instance: its own hooks, plus starting and stopping it. */ +export function dutyLifecycleOf( + duty: object, + metadata: ResolvedDutyMetadata, + providers: ProviderRegistry, +): DutyLifecycle { + let lifecycle = lifecycles.get(duty); + if (!lifecycle) { + lifecycle = new DutyLifecycle(duty as DutyInstance, metadata, providers); + lifecycles.set(duty, lifecycle); + } + return lifecycle; +} + +/** Start the duties built in `providers` as a server does once it serves: each one's own `onReady()`, then its runs. */ +export async function startDuties(providers: ProviderRegistry): Promise { + for (const instance of providers.getAllSingletons().values()) { + if (typeof instance === 'object' && instance !== null) await lifecycles.get(instance)?.onReady(); + } +} + +/** Where the leases live: the server's Redis when it has one, otherwise a SQLite file every process on the machine opens. */ +export function dutyStorageConfig(redis: unknown): StorageConfig { + const parsed = redis === undefined ? undefined : redisOptionsSchema.safeParse(redis); + if (parsed?.success && isRedisProvider(parsed.data)) { + const { host, port, password, db, tls } = parsed.data; + return { + type: 'redis', + redis: { config: { host, port, password, db, tls } }, + prefix: `frontmcp:duty:${nodeOs().hostname()}:`, + fallback: 'error', + }; + } + const home = getEnv('FRONTMCP_HOME') || nodePath().join(nodeOs().homedir(), '.frontmcp'); + return { + type: 'sqlite', + sqlite: { path: nodePath().join(home, 'duties.sqlite') }, + prefix: 'frontmcp:duty:', + fallback: 'error', + }; +} + +/** One lease per server name and duty; a workspace duty's is also per project root. */ +function leaseKey({ name, scope }: ResolvedDutyMetadata, serverName: string): string { + const key = [serverName, name].map(encodeURIComponent).join(':'); + if (scope === 'machine') return key; + const cwd = process.cwd(); + return `${key}:${sha256Hex(findProjectRoot(cwd) ?? cwd)}`; +} + +/** Create the lease file and its folder owner-only, and refuse a folder (or one above it) another user can write to. */ +async function prepareLeaseFile(file: string): Promise { + const directory = nodePath().dirname(file); + await mkdir(directory, { recursive: true, mode: OWNER_ONLY_DIRECTORY }); + if (process.platform !== 'win32') { + const realDirectory = await realpath(directory); + const replaceable = + (await findReplaceableDirectory(directory)) ?? + (realDirectory === directory ? undefined : await findReplaceableDirectory(realDirectory)); + if (replaceable) throw new UnsafeDutyStoreError(replaceable.directory, replaceable.mode, replaceable.ownerUid); + const { mode } = await stat(realDirectory); + if (mode & GROUP_OR_OTHER_WRITE) throw new UnsafeDutyStoreError(directory, mode); + } + await writeFile(file, '', { mode: OWNER_ONLY_FILE, flag: 'a' }); +} + +export class DutyLifecycle implements OnBootstrap, OnReady, OnShutdown { + private started?: Promise; + private claimedKey?: string; + + constructor( + private readonly duty: DutyInstance, + private readonly metadata: ResolvedDutyMetadata, + private readonly providers: ProviderRegistry, + ) {} + + async onBootstrap(): Promise { + await this.duty.onBootstrap?.(); + } + + async onReady(): Promise { + await this.duty.onReady?.(); + this.started ??= this.startRunner(); + await this.started; + } + + async onShutdown(): Promise { + const started = await this.started?.catch(() => undefined); + if (started) { + await started.runner.stop(); + await started.storage.disconnect(); + } + this.releaseClaim(); + await this.duty.onShutdown?.(); + } + + private async startRunner(): Promise { + const { name, everyMs, leaseMs } = this.metadata; + const logger = this.providers.get(FrontMcpLogger); + const { runtime, deployment } = getRuntimeContext(); + if (deployment === 'serverless' || runtime === 'edge' || runtime === 'browser') { + logger.warn(`Duty "${name}" does not run in a serverless or edge deployment: it needs a long-lived process`); + return undefined; + } + const config = this.providers.get(FrontMcpConfig); + const serverName = config.info?.name ?? 'frontmcp'; + const key = leaseKey(this.metadata, serverName); + this.claim(key, serverName); + const storageConfig = dutyStorageConfig(config.redis); + if (storageConfig.sqlite) await prepareLeaseFile(storageConfig.sqlite.path); + const storage = await createStorage(storageConfig); + const runner = new DutyRunner({ + name, + everyMs, + leaseMs, + storage, + logger, + key, + run: (context) => this.duty.run(context), + }); + await runner.start(); + return { runner, storage }; + } + + /** Take `key` for this duty's class in this process: another class with the same key would never run here. */ + private claim(key: string, serverName: string): void { + const type = this.duty.constructor; + const claim = claims.get(key); + if (claim && claim.type !== type) { + throw new DutyNameConflictError(this.metadata.name, serverName, claim.typeName, type.name); + } + claims.set(key, { type, typeName: type.name, instances: (claim?.instances ?? 0) + 1 }); + this.claimedKey = key; + } + + private releaseClaim(): void { + const key = this.claimedKey; + this.claimedKey = undefined; + const claim = key === undefined ? undefined : claims.get(key); + if (key === undefined || !claim) return; + claim.instances--; + if (claim.instances === 0) claims.delete(key); + } +} diff --git a/libs/sdk/src/duty/duty.runner.ts b/libs/sdk/src/duty/duty.runner.ts new file mode 100644 index 000000000..19f11763f --- /dev/null +++ b/libs/sdk/src/duty/duty.runner.ts @@ -0,0 +1,200 @@ +import { randomUUID, type NamespacedStorage } from '@frontmcp/utils'; + +import type { DutyRunContext, FrontMcpLogger } from '../common'; + +/** The store operations a lease needs; the SQLite, Redis and memory stores run each one atomically. */ +export type DutyLeaseStorage = Pick; + +export type DutyLogger = Pick; + +export interface DutyRunnerOptions { + name: string; + key: string; + everyMs: number; + leaseMs: number; + storage: DutyLeaseStorage; + logger: DutyLogger; + run(context: DutyRunContext): unknown; +} + +interface RunInFlight { + controller: AbortController; + done: Promise; +} + +/** + * Runs a duty every `everyMs` while this process holds its lease, and contends for the lease otherwise. The lease is a + * key holding this runner's id with a TTL, so it expires by the store's clock when the holder stops renewing it. The + * holder renews it every third of `leaseMs`, and again before each run, so a holder that wakes after another process + * took the lease over never runs again. + */ +export class DutyRunner { + private readonly holder: string; + private readonly leaseSeconds: number; + private readonly ranKey: string; + private holding = false; + private stopped = false; + private reportedHeldElsewhere = false; + private leaseCheck?: Promise; + private leaseTimer?: ReturnType; + private leaseDeadline?: ReturnType; + private tickTimer?: ReturnType; + private inFlight?: RunInFlight; + + constructor(private readonly options: DutyRunnerOptions) { + this.holder = randomUUID(); + this.leaseSeconds = Math.ceil(options.leaseMs / 1_000); + this.ranKey = `${options.key}:ran`; + } + + /** Contend for the lease once now, then renew or contend again every third of the lease. */ + async start(): Promise { + this.leaseCheck = this.checkLease(); + await this.leaseCheck; + } + + /** Stop contending, abort the run in flight and wait for it while still renewing the lease, then release it. */ + async stop(): Promise { + this.stopped = true; + clearTimeout(this.tickTimer); + this.inFlight?.controller.abort(); + await this.inFlight?.done; + clearTimeout(this.leaseTimer); + await this.leaseCheck; + clearTimeout(this.leaseDeadline); + if (!this.holding) return; + this.holding = false; + try { + await this.options.storage.deleteIfEquals(this.options.key, this.holder); + } catch (error) { + this.options.logger.warn(`Duty "${this.options.name}": the lease store failed: ${messageOf(error)}`); + } + } + + private async checkLease(): Promise { + try { + if (this.holding) { + if (!(await this.renew())) this.lose('lost its lease; another process runs it now'); + } else if (!this.stopped) { + if (await this.acquire()) await this.hold(); + else this.reportHeldElsewhere(); + } + } catch (error) { + this.options.logger.warn(`Duty "${this.options.name}": the lease store failed: ${messageOf(error)}`); + } + if (this.stopped && !this.inFlight) return; + this.leaseTimer = setTimeout(() => { + this.leaseCheck = this.checkLease(); + }, this.options.leaseMs / 3); + this.leaseTimer.unref?.(); + } + + private async acquire(): Promise { + await this.options.storage.set(this.options.key, this.holder, { ttlSeconds: this.leaseSeconds, ifNotExists: true }); + this.holding = await this.renew(); + return this.holding; + } + + /** Extend the lease while it holds this runner's id, and give up holding it by the time the store could end it. */ + private async renew(): Promise { + const sentAt = performance.now(); + const renewed = await this.options.storage.expireIfEquals(this.options.key, this.holder, this.leaseSeconds); + if (renewed) this.holdUntil(sentAt + this.options.leaseMs); + return renewed; + } + + /** + * Stop at `deadline` (monotonic) unless a renew succeeds first: the store ends the lease no sooner, so this process + * never runs past the point where another one could take the lease over, even while the store can't be reached. + */ + private holdUntil(deadline: number): void { + clearTimeout(this.leaseDeadline); + this.leaseDeadline = setTimeout( + () => + this.lose( + `could not renew its lease within ${this.options.leaseMs} ms; it stops here until it gets the lease again`, + ), + deadline - performance.now(), + ); + this.leaseDeadline.unref?.(); + } + + private reportHeldElsewhere(): void { + if (this.reportedHeldElsewhere) return; + this.reportedHeldElsewhere = true; + this.options.logger.info( + `Duty "${this.options.name}" already runs elsewhere (lease "${this.options.key}"); this one takes it over if that one stops`, + ); + } + + /** + * Start the runs, the first once the interval the last run (in any process) started has passed, or a whole interval + * from now when the store can't say when that was. + */ + private async hold(): Promise { + this.options.logger.info(`Duty "${this.options.name}" runs in this process every ${this.options.everyMs} ms`); + const remainingSeconds = await this.options.storage.ttl(this.ranKey).catch(() => undefined); + if (this.stopped || !this.holding) return; + if (remainingSeconds === undefined) return this.scheduleTick(this.options.everyMs); + this.scheduleTick(Math.min(Math.max(0, remainingSeconds ?? 0) * 1_000, this.options.everyMs)); + } + + private lose(reason: string): void { + if (!this.holding) return; + this.holding = false; + clearTimeout(this.tickTimer); + clearTimeout(this.leaseDeadline); + this.inFlight?.controller.abort(); + this.options.logger.warn(`Duty "${this.options.name}" ${reason}`); + } + + private scheduleTick(delayMs: number): void { + this.tickTimer = setTimeout(() => { + this.scheduleTick(this.options.everyMs); + void this.tick(); + }, delayMs); + this.tickTimer.unref?.(); + } + + private async tick(): Promise { + if (this.inFlight) { + this.options.logger.verbose(`Duty "${this.options.name}" is still running; skipping this run`); + return; + } + const controller = new AbortController(); + const done = this.runIfHeld(controller.signal); + this.inFlight = { controller, done }; + await done; + this.inFlight = undefined; + } + + private async runIfHeld(signal: AbortSignal): Promise { + if (!(await this.claimRun())) return; + try { + await this.options.run({ signal }); + } catch (error) { + this.options.logger.error(`Duty "${this.options.name}" failed: ${messageOf(error)}`); + } + } + + /** Renew the lease right before a run, so a holder that lost it while paused never runs, and mark the run. */ + private async claimRun(): Promise { + try { + if (!(await this.renew())) { + this.lose('lost its lease; another process runs it now'); + return false; + } + if (this.stopped) return false; + const intervalSeconds = Math.max(1, Math.ceil(this.options.everyMs / 1_000)); + await this.options.storage.set(this.ranKey, this.holder, { ttlSeconds: intervalSeconds }); + return true; + } catch (error) { + this.options.logger.warn(`Duty "${this.options.name}": the lease store failed: ${messageOf(error)}`); + return false; + } + } +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/libs/sdk/src/errors/duty.errors.ts b/libs/sdk/src/errors/duty.errors.ts new file mode 100644 index 000000000..93a9a08d7 --- /dev/null +++ b/libs/sdk/src/errors/duty.errors.ts @@ -0,0 +1,25 @@ +import { InternalMcpError } from './mcp.error'; + +/** Thrown when a duty starts with the lease of a duty of another class in this process: one of them would never run. */ +export class DutyNameConflictError extends InternalMcpError { + constructor(dutyName: string, serverName: string, firstClass: string, secondClass: string) { + super( + `Duty "${dutyName}" of server "${serverName}" is declared by two classes in this process (${firstClass} and ` + + `${secondClass}): give one of them its own name with @Duty({ name })`, + 'DUTY_NAME_CONFLICT', + ); + } +} + +/** Thrown when another user could write to the folder that holds the duty lease file, or to one above it. */ +export class UnsafeDutyStoreError extends InternalMcpError { + constructor(directory: string, mode: number, ownerUid?: number) { + const reason = + ownerUid === undefined ? 'the group or others can write to it' : `it belongs to another user (uid ${ownerUid})`; + super( + `Refusing to keep duty leases under ${directory} (mode 0${(mode & 0o7777).toString(8)}): ${reason}. ` + + 'Set FRONTMCP_HOME to a folder only you can write to.', + 'UNSAFE_DUTY_STORE', + ); + } +} diff --git a/libs/sdk/src/errors/index.ts b/libs/sdk/src/errors/index.ts index 3fa21f076..66275c99f 100644 --- a/libs/sdk/src/errors/index.ts +++ b/libs/sdk/src/errors/index.ts @@ -215,6 +215,9 @@ export { // Export decorator errors export { InvalidDecoratorMetadataError, HookTargetNotDefinedError } from './decorator.errors'; +// Export duty errors +export { DutyNameConflictError, UnsafeDutyStoreError } from './duty.errors'; + // Export transport errors export { MethodNotImplementedError, diff --git a/libs/sdk/src/provider/create-container.ts b/libs/sdk/src/provider/create-container.ts index 71e692afc..1f14f6f97 100644 --- a/libs/sdk/src/provider/create-container.ts +++ b/libs/sdk/src/provider/create-container.ts @@ -26,6 +26,10 @@ export interface CreateContainerOptions { apps?: AppType[]; logging?: LoggingOptionsInput; lifecycle?: LifecycleOptionsInput; + /** Start the `@Duty` providers, as a server does once it serves; `dispose()` stops them. @default false */ + runDuties?: boolean; + /** The server's name, as in `@FrontMcp({ info })`: duties share their leases with that server's. Needed with `runDuties`. */ + info?: { name: string }; } /** The providers of a server, built without its tools, resources, prompts, flows or transport. */ @@ -38,16 +42,26 @@ export interface FrontMcpContainer { dispose(): Promise; } -const createContainerOptionsSchema = z.object({ - providers: z.array(annotatedFrontMcpProvidersSchema).optional().default([]), - apps: z.array(annotatedFrontMcpAppSchema).optional().default([]), - logging: loggingOptionsSchema.optional(), - lifecycle: lifecycleOptionsSchema.optional(), -} satisfies RawZodShape); +const createContainerOptionsSchema = z + .object({ + providers: z.array(annotatedFrontMcpProvidersSchema).optional().default([]), + apps: z.array(annotatedFrontMcpAppSchema).optional().default([]), + logging: loggingOptionsSchema.optional(), + lifecycle: lifecycleOptionsSchema.optional(), + runDuties: z.boolean({ message: 'runDuties must be true or false' }).optional(), + info: z.object({ name: z.string().min(1, 'info.name must not be empty') }).optional(), + } satisfies RawZodShape) + .refine((options) => !options.runDuties || options.info !== undefined, { + message: 'runDuties needs info.name: duties share their leases by server name', + path: ['info'], + }); -/** Build the providers a server would (same tokens, scopes and `onBootstrap()` order) and nothing else; no `onReady()`. */ +/** + * Build the providers a server would (same tokens, scopes and `onBootstrap()` order) and nothing else; no `onReady()`, + * and no duty runs unless `runDuties` asks. + */ export async function createContainer(options: CreateContainerOptions): Promise { - const { providers, apps, logging, lifecycle } = createContainerOptionsSchema.parse(options); + const { providers, apps, logging, lifecycle, runDuties } = createContainerOptionsSchema.parse(options); const root = new ProviderRegistry( [{ name: 'frontmcp:config', provide: FrontMcpConfig, useValue: { ...options, logging, lifecycle } }], undefined, @@ -73,6 +87,10 @@ export async function createContainer(options: CreateContainerOptions): Promise< registries.push(appProviders); await appProviders.ready; } + if (runDuties) { + const { startDuties } = await import('../duty/duty.lifecycle.js'); + for (const registry of registries) await startDuties(registry); + } } catch (error) { await dispose(); throw error; diff --git a/libs/sdk/src/provider/provider.registry.ts b/libs/sdk/src/provider/provider.registry.ts index 692aababd..faa3613b6 100644 --- a/libs/sdk/src/provider/provider.registry.ts +++ b/libs/sdk/src/provider/provider.registry.ts @@ -15,6 +15,7 @@ import { } from '@frontmcp/di'; import { + dutyMetadataOf, FrontMcpServer, type ProviderEntry, type ProviderRegistryInterface, @@ -339,7 +340,8 @@ export default class ProviderRegistry /** Run `onBootstrap()` on the instance just built for `token`, and keep it for the ready and shutdown phases. */ private async bootstrapBuilt(token: Token): Promise { - const instance = lifecycleHooksOf(this.instances.get(token)); + const built = this.instances.get(token); + const instance = lifecycleHooksOf((await this.dutyLifecycleOf(built)) ?? built); if (!instance || this.heldEntries.some((held) => held.instance === instance)) return; const entry = { token, instance }; const shared = this.sharedLifecycles.get(instance); @@ -351,6 +353,14 @@ export default class ProviderRegistry this.heldEntries.push(entry); } + /** A `@Duty` instance's lifecycle, which starts it on `onReady()`; the duty runtime loads only when one is built. */ + private async dutyLifecycleOf(built: unknown): Promise { + const metadata = dutyMetadataOf(built); + if (!metadata || typeof built !== 'object' || built === null) return undefined; + const { dutyLifecycleOf } = await import('../duty/duty.lifecycle.js'); + return dutyLifecycleOf(built, metadata, this); + } + /** Run `onReady()` on the providers built here, then on those of the registries below, in build order; once. */ runReadyHooks(logger: LifecycleLogger): Promise { this.readyHooksRun ??= this.readyHereAndBelow(logger); diff --git a/libs/sdk/src/server/security/unix-socket-guard.ts b/libs/sdk/src/server/security/unix-socket-guard.ts index a00ca0ddc..cf11f1280 100644 --- a/libs/sdk/src/server/security/unix-socket-guard.ts +++ b/libs/sdk/src/server/security/unix-socket-guard.ts @@ -47,16 +47,28 @@ export async function assertSafeSocketDirectory(socketPath: string): Promise { +/** A directory whose entries another user could replace: `ownerUid` when another user owns it. */ +export interface ReplaceableDirectory { + directory: string; + mode: number; + ownerUid?: number; +} + +/** The first of `start` and the directories above it that another user (not root) owns or the group or others can write without the sticky bit. */ +export async function findReplaceableDirectory(start: string): Promise { for (let directory = start; ; directory = path.dirname(directory)) { const { mode, uid } = await stat(directory); - if (uid !== ROOT_UID && uid !== process.getuid?.()) throw new UnsafeSocketDirectoryError(directory, mode, uid); - if (mode & GROUP_OR_OTHER_WRITE && !(mode & STICKY_BIT)) throw new UnsafeSocketDirectoryError(directory, mode); - if (path.dirname(directory) === directory) return; + if (uid !== ROOT_UID && uid !== process.getuid?.()) return { directory, mode, ownerUid: uid }; + if (mode & GROUP_OR_OTHER_WRITE && !(mode & STICKY_BIT)) return { directory, mode }; + if (path.dirname(directory) === directory) return undefined; } } +async function assertNoOtherUserCanReplace(start: string): Promise { + const replaceable = await findReplaceableDirectory(start); + if (replaceable) throw new UnsafeSocketDirectoryError(replaceable.directory, replaceable.mode, replaceable.ownerUid); +} + /** Whether this thread binds the socket under the umask it sets: a worker thread cannot set it, and a cluster primary binds a worker's socket. */ function canBindOwnerOnly(): boolean { if (cluster.isWorker) return false; diff --git a/libs/skills/catalog/frontmcp-development/SKILL.md b/libs/skills/catalog/frontmcp-development/SKILL.md index 3a90aa007..6901e215f 100644 --- a/libs/skills/catalog/frontmcp-development/SKILL.md +++ b/libs/skills/catalog/frontmcp-development/SKILL.md @@ -1,13 +1,13 @@ --- name: frontmcp-development -description: 'Use when building any FrontMCP server component other than a tool (for tools, use create-tool). Covers @Resource static resources and parameterized URI templates; @Prompt reusable prompts (RAG, multi-turn); @Provider singleton dependency-injection providers (database pools, API clients); @Agent autonomous LLM agents (Anthropic, OpenAI) and swarms; @Job background jobs (retry, progress, permissions) and @Workflow DAG pipelines; framework adapters and the OpenAPI adapter (turn OpenAPI 3.x specs into MCP tools with auth, polling, transforms); plugins, plugin lifecycle hooks (before / after / around / stage), and the official plugins; instruction-only skills and skills that reference tools; and the hierarchical decorator system from @FrontMcp down to @App. Triggers: create a resource, build a prompt, write a provider, add an agent, job, workflow, plugin, adapter, or OpenAPI integration.' +description: 'Use when building any FrontMCP server component other than a tool (for tools, use create-tool). Covers @Resource static resources and parameterized URI templates; @Prompt reusable prompts (RAG, multi-turn); @Provider singleton dependency-injection providers (database pools, API clients); @Agent autonomous LLM agents (Anthropic, OpenAI) and swarms; @Job background jobs (retry, progress, permissions) and @Workflow DAG pipelines; @Duty recurring work that one process of the machine or project runs under a lease; framework adapters and the OpenAPI adapter (turn OpenAPI 3.x specs into MCP tools with auth, polling, transforms); plugins, plugin lifecycle hooks (before / after / around / stage), and the official plugins; instruction-only skills and skills that reference tools; and the hierarchical decorator system from @FrontMcp down to @App. Triggers: create a resource, build a prompt, write a provider, add an agent, job, workflow, duty, plugin, adapter, or OpenAPI integration.' when_to_use: | Trigger when creating or editing a non-tool FrontMCP component: a *.resource.ts, *.prompt.ts, *.provider.ts, *.agent.ts, *.job.ts, - *.workflow.ts, *.plugin.ts, or *.skill.ts file, or adding @Resource, @Prompt, - @Provider, @Agent, @Job, @Workflow, a plugin, an adapter, or an OpenAPI - integration. For tools (*.tool.ts) use create-tool instead. -paths: '**/*.resource.ts, **/*.prompt.ts, **/*.provider.ts, **/*.agent.ts, **/*.job.ts, **/*.workflow.ts, **/*.plugin.ts, **/*.skill.ts' + *.workflow.ts, *.duty.ts, *.plugin.ts, or *.skill.ts file, or adding @Resource, + @Prompt, @Provider, @Agent, @Job, @Workflow, @Duty, a plugin, an adapter, or an + OpenAPI integration. For tools (*.tool.ts) use create-tool instead. +paths: '**/*.resource.ts, **/*.prompt.ts, **/*.provider.ts, **/*.agent.ts, **/*.job.ts, **/*.workflow.ts, **/*.duty.ts, **/*.plugin.ts, **/*.skill.ts' tags: [router, development, tools, resources, prompts, agents, skills, guide] category: development targets: [all] @@ -71,6 +71,7 @@ This is a router skill. The "steps" here are how to choose the right reference ( | Register shared services or configuration via DI | `create-provider` | Dependency injection tokens, `onBootstrap` / `onReady` / `onShutdown` hooks, factory providers, `createContainer()` | | Run a background task with progress and retries | `create-job` | Job entries with attempt tracking, retry config, and progress | | Chain multiple jobs into a sequential pipeline | `create-workflow` | Workflow entries that compose jobs with data passing | +| Run recurring work in one process of the machine or project | `create-duty` | `@Duty({ every, scope, leaseMs })` providers: a lease one process holds, renewed while held, taken over when the holder dies | | Write instruction-only AI guidance (no code execution) | `create-skill` | Skill entries with markdown instructions from files, strings, or URLs | | Write AI guidance that also orchestrates tools | `create-skill-with-tools` | Skill entries that combine instructions with registered tools | | Look up any decorator signature or option | `decorators-guide` | Complete reference for @Tool, @Resource, @Prompt, @Agent, @App, @FrontMcp, and more | @@ -91,7 +92,7 @@ This is a router skill. The "steps" here are how to choose the right reference ( 4. **`create-prompt`** — Add reusable conversation templates 5. **`create-provider`** — Share services across tools and resources via DI 6. **`create-agent`** — Build autonomous AI loops (advanced) -7. **`create-job`** / **`create-workflow`** — Background processing (advanced) +7. **`create-job`** / **`create-workflow`** / **`create-duty`** — Background processing and recurring work (advanced) 8. **`create-skill`** / **`create-skill-with-tools`** — Author your own skills (meta) 9. **`official-adapters`** / **`openapi-adapter`** — Integrate external APIs via OpenAPI specs 10. **`official-plugins`** — Add caching, session memory, feature flags, and more @@ -121,6 +122,7 @@ This is a router skill. The "steps" here are how to choose the right reference ( | Shared logic | Extract to a `@Provider` and inject via DI | Duplicate code across multiple tools | Providers are testable, lifecycle-managed, and scoped | | Complex orchestration | Use `@Agent` with inner tools | Chain tool calls manually in a single tool | Agents handle LLM loops, retries, and tool selection automatically | | Background work | Use `@Job` with retry config | Run long tasks inside a tool's `execute()` | Jobs have progress tracking, attempt awareness, and timeout handling | +| Recurring work | Use a `@Duty` provider | `setInterval()` in every copy of the server | A duty runs in one process, and another takes over when it exits | ## Verification Checklist diff --git a/libs/skills/catalog/frontmcp-development/references/create-duty.md b/libs/skills/catalog/frontmcp-development/references/create-duty.md new file mode 100644 index 000000000..7b46cafff --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/create-duty.md @@ -0,0 +1,215 @@ +--- +name: create-duty +description: Run recurring work in exactly one process of the machine or project with @Duty, a lease that one process holds and another takes over when the holder dies +--- + +# Duties: Recurring Work in One Process + +A duty is a provider class with a `run()` method that the server calls every `every`, in one process at a time. When +several copies of a server run on one machine (a stdio server per client, several HTTP workers), all of them start the +duty, and the one holding its lease runs it. The lease lives in a store every process shares, so when the holder exits +or hangs, another process takes the duty over once the lease runs out. + +## When to Use This Skill + +### Must Use + +- Recurring work that must not run in every process: pruning a cache the servers on a machine share, compacting a + SQLite file, re-indexing a project +- Several copies of the server run at once on one machine, and the work must run once per interval among them + +### Recommended + +- Replacing a `setInterval()` started in a provider's `onReady()` when the copies of the server would repeat the work + or conflict over it + +### Skip When + +- Each process needs its own copy of the work, such as refreshing an in-memory cache: start a timer in a provider's + `onReady()` (see `create-provider`) +- A client triggers the work and needs retries or progress (see `create-job`) +- The server runs serverless or on the edge: a duty needs a long-lived process, and is not started there + +> **Decision:** Use `@Duty` when work must run on a schedule in one process of the machine (`scope: 'machine'`) or of +> the project (`scope: 'workspace'`). + +## Prerequisites + +- `@frontmcp/storage-sqlite` installed, where the leases live by default. A server with a top-level `redis` keeps them + in Redis instead. +- A distinctive server `info.name`: leases are per server name. Two servers with different names never share a lease; + two different servers with the same name on one machine (two projects both called `demo`) share every duty of the same + name, so only one of them runs it, just as they share the `~/./` data folder. + +## Steps + +### Step 1: Write the duty + +```typescript +import { Duty, type DutyInterface, type DutyRunContext } from '@frontmcp/sdk'; + +import { CacheStore } from './cache.store'; + +@Duty({ name: 'prune-cache', every: '10m', scope: 'machine' }) +export class PruneCacheDuty implements DutyInterface { + constructor(private readonly cache: CacheStore) {} + + async run({ signal }: DutyRunContext): Promise { + for (const key of await this.cache.expiredKeys()) { + if (signal.aborted) return; // the lease was lost or the server is shutting down + await this.cache.delete(key); + } + } +} +``` + +A duty is a GLOBAL provider: its constructor gets its dependencies injected, and it can implement `onBootstrap()`, +`onReady()` and `onShutdown()` like any provider. + +### Step 2: Register it in `providers` + +```typescript +@App({ name: 'cache', providers: [CacheStore, PruneCacheDuty] }) +class CacheApp {} +``` + +`@FrontMcp({ providers })` and a plugin's `providers` work too. The duty starts once the server serves (the +`onReady()` phase) and stops at shutdown, before the providers it depends on. + +### Step 3 (optional): Run duties from a script + +`createContainer()` builds a duty without starting it. To run duties without serving MCP, ask for it. Each duty's own +`onReady()` runs before its first run, as in a server: + +```typescript +const container = await createContainer({ + apps: [CacheApp], + runDuties: true, + info: { name: 'cache-server' }, // required with runDuties: the server's info.name, so they share its leases +}); +// ... +await container.dispose(); // stops the duties and releases their leases +``` + +## `@Duty` Options + +| Field | Type | Default | Notes | +| --------- | ----------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `name` | `string` | The class name | The lease's name in the server. Set it when the build renames classes | +| `every` | `number` (ms) or `'500ms'`, `'30s'`, `'5m'`, `'1h'`, `'1d'` | Required | From 1 ms to 2147483647 ms (about 24.8 days) | +| `scope` | `'machine'` or `'workspace'` | Required | `'workspace'`: one holder per project root: the nearest folder at or above the working directory with a `package.json` | +| `leaseMs` | `number` | Three intervals, between 3000 and 60000 | At least 1000 (stores keep a TTL in whole seconds). How soon another process takes over from a holder that died | + +An invalid option throws `InvalidDecoratorMetadataError` (`INVALID_DECORATOR_METADATA`) when the class is decorated, +for example `@Duty invalid metadata for "every": every must be milliseconds or a duration such as "30s", "5m", "1h"`. A +class without `run()` fails to compile, and throws `@Duty invalid metadata for "run": has no run() method`. + +Two different classes whose duties share a lease in one process (both `name: 'sweep'` in one server, or two plugins' +`CacheSweeper` classes) would leave one of them never running, so the second one to start fails with +`DutyNameConflictError` (`DUTY_NAME_CONFLICT`): `Duty "sweep" of server "demo" is declared by two classes in this +process (A and B): give one of them its own name with @Duty({ name })`. The same class built twice (a top-level +provider of a `splitByApp` server is built once per app) is not a conflict: its copies share one lease. + +## How the Lease Works + +- **Acquire.** Each process sets the lease key to its own id with a TTL of `leaseMs`, only if the key is absent. The one + whose id is stored holds the duty. +- **Renew.** The holder extends the TTL every third of `leaseMs`, only while the key still holds its id. The others try + to acquire at the same pace. +- **Expire.** The store's TTL decides when a lease ends; processes never compare their clocks. A killed or hung holder + stops renewing, and another process takes the duty over within `leaseMs` plus a third of it. +- **Give up.** A holder that could not renew for a whole `leaseMs` (Redis unreachable, a locked file, a hung store call) + stops holding the duty, aborts the run in flight and contends again: by then the store may have ended its lease. It + measures this on a monotonic clock, so unless the system clock jumps (see Clocks) it never trusts its lease longer + than the store keeps it. +- **Release.** At shutdown the holder aborts the run in flight and keeps renewing the lease until the run returns, so + no other process starts meanwhile; then it deletes the key only while it still holds its id. Another process takes + over at its next check. A run that ignores `signal` holds the shutdown up to `lifecycle.shutdownHookTimeoutMs`. +- **Before each run** the holder renews the lease. A holder paused past its lease (SIGSTOP, a long GC pause) finds the + key taken when it wakes, and does not run. A run in flight when the lease is lost sees `signal` aborted. +- **Once per interval.** A run that would start while the last one is still running is skipped, not queued. A run + failure is logged and the holder keeps the lease. A process that takes the lease over waits until an interval has + passed since the last run started, in whichever process ran it. +- **Clocks.** On one machine every process reads the same system clock, so they never disagree about a SQLite lease. + A forward jump of that clock (an NTP step, a laptop waking from sleep) by more than `leaseMs` ends the lease and the + last-run marker early: another process may take over and run at once. A backward jump delays a takeover after a crash + by the size of the jump. Redis keeps its TTLs by its own clock. + +## Where Leases Live + +| Server config | Store | Key | +| ------------------------------------ | ------------------------------------------------------------------------ | -------------------------------------------------- | +| No top-level `redis` (the default) | SQLite file `~/.frontmcp/duties.sqlite` (`$FRONTMCP_HOME/duties.sqlite`) | `frontmcp:duty::` | +| A top-level `redis` (Redis provider) | That Redis | `frontmcp:duty:::` | + +A `'workspace'` duty's key ends with the SHA-256 of the project root. The server and duty names are URI-encoded in the +key, so a `:` in either never makes two leases one. The store never falls back to memory: a missing +`@frontmcp/storage-sqlite` or an unreachable Redis is logged as `Provider "" failed in onReady(): `, and +the duty does not run. + +The SQLite file and its folder are created owner-only (`0600` and `0700`). A folder that the group or others can write +to, or that belongs to another user (or one above it, unless it has the sticky bit, like `/tmp`), is refused with +`UnsafeDutyStoreError` (`UNSAFE_DUTY_STORE`): `Refusing to keep duty leases under (mode ): . Set +FRONTMCP_HOME to a folder only you can write to.` So two users of a machine never share leases, and nobody else can +hold or delete them. + +With Redis, `'machine'` means one hostname. Containers and Kubernetes pods get their own hostnames; replicas given the +same `hostname:` (Docker Compose) share one lease, so only one of them runs a `'machine'` duty. + +## Log Lines + +| Level | Message | +| ------- | ---------------------------------------------------------------------------------------------------------- | +| info | `Duty "" runs in this process every ms` (this process took the lease) | +| info | `Duty "" already runs elsewhere (lease ""); this one takes it over if that one stops` | +| warn | `Duty "" lost its lease; another process runs it now` | +| warn | `Duty "" could not renew its lease within ms; it stops here until it gets the lease again` | +| warn | `Duty "": the lease store failed: ` (that check or run is skipped and retried) | +| error | `Duty "" failed: ` (the run threw; the lease is kept) | +| verbose | `Duty "" is still running; skipping this run` | +| warn | `Duty "" does not run in a serverless or edge deployment: it needs a long-lived process` | + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| -------------------- | --------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------ | +| Shared periodic work | `@Duty({ every, scope })` in `providers` | `setInterval()` in a provider's `onReady()` | Every copy of the server would run the interval | +| Long runs | Check `signal.aborted` between steps | Ignore `signal` | A run that lost its lease keeps going until it looks | +| Stable names | `@Duty({ name: 'prune-cache', ... })` in a minified build | Rely on the class name after minification | The lease key comes from the name | +| Scripts | `createContainer({ runDuties: true, info: { name } })` | Expect `createContainer()` to start duties on its own | A container never runs duties unless asked, so scripts and workers don't | +| `splitByApp` servers | The duty in one `@App`'s `providers` | The duty in the top-level `providers` | Every app's scope builds a top-level provider: one lease, several copies | + +## Verification Checklist + +### Configuration + +- [ ] The duty class has `run()` and is in a `providers` array +- [ ] `scope` matches who shares the work: the whole machine, or one project +- [ ] `@frontmcp/storage-sqlite` is installed, or the server has a top-level `redis` +- [ ] A `name` is set when the build minifies class names +- [ ] The server's `info.name` is distinctive on the machine: another server with the same name shares its leases + +### Runtime + +- [ ] Started several times, the server logs `runs in this process` once +- [ ] Killing that process makes another log it within `leaseMs` plus a third of it +- [ ] Long runs return when `signal` is aborted + +## Troubleshooting + +| Problem | Cause | Solution | +| ----------------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| The duty never runs | Not in `providers`; a `createContainer()` without `runDuties`; serverless | Register it; pass `runDuties: true`; run it in a long-lived process | +| `already runs elsewhere` with one server | Another server on the machine has the same `info.name` and duty name | Give the server a distinctive `info.name`, or the duty its own `name` | +| `failed in onReady()` naming the duty | The lease store can't be opened | Install `@frontmcp/storage-sqlite`, or check the `redis` connection | +| `DutyNameConflictError` | Two duty classes with the same `name` in one server | Give one of them its own `name` | +| `UnsafeDutyStoreError` | `FRONTMCP_HOME` (or a folder above it) is writable by others | Point `FRONTMCP_HOME` at a folder only you can write to, or `chmod 700` it | +| It runs in two processes at once | Different `info.name` or duty `name`, or `'workspace'` in two projects | Give them the same names; use `'machine'` to share across projects | +| A takeover is slow after a crash | The lease must expire first | Lower `leaseMs` (at least 1000) | +| `lost its lease` while the holder is busy | The event loop was blocked longer than the lease | Keep runs asynchronous, move CPU-heavy work off the main thread, raise `leaseMs` | + +## Reference + +- [Providers](https://frontmcp.dev/reference/sdk/provider) +- Related skills: `create-provider` (lifecycle hooks, `createContainer()`), `create-job`, `decorators-guide`, + `setup-sqlite` diff --git a/libs/skills/catalog/frontmcp-development/references/create-job.md b/libs/skills/catalog/frontmcp-development/references/create-job.md index cbc338182..ddf693c67 100644 --- a/libs/skills/catalog/frontmcp-development/references/create-job.md +++ b/libs/skills/catalog/frontmcp-development/references/create-job.md @@ -17,7 +17,6 @@ Jobs are long-running background tasks with built-in retry policies, progress tr ### Recommended -- Scheduled maintenance tasks or periodic data synchronization - Operations requiring permission controls (role-based, scope-based access) - Work that must persist state across retries and server restarts @@ -26,6 +25,7 @@ Jobs are long-running background tasks with built-in retry policies, progress tr - The work completes in a few seconds and needs no retry or progress tracking (see `create-tool`) - You need to expose read-only data at a URI (see `create-resource`) - The task requires autonomous LLM reasoning rather than a deterministic pipeline (see `create-agent`) +- The work runs on a schedule rather than when a client asks: a job has no schedule (see `create-duty`) > **Decision:** Use this skill when you need a long-running background task with retry policies, progress tracking, or permission controls. diff --git a/libs/skills/catalog/frontmcp-development/references/create-provider.md b/libs/skills/catalog/frontmcp-development/references/create-provider.md index 00855ab4d..b53956b90 100644 --- a/libs/skills/catalog/frontmcp-development/references/create-provider.md +++ b/libs/skills/catalog/frontmcp-development/references/create-provider.md @@ -363,6 +363,10 @@ class SearchIndex implements OnBootstrap, OnReady, OnShutdown { class Server {} ``` +Each process runs its own `onReady()` timer, which suits a per-process cache. Work that must run in one process of the +machine or project at a time (pruning a shared cache, compacting a file) belongs in a `@Duty` provider instead (see +`create-duty`). + `lifecycle.exitWhenOrphaned` decides whether the server also shuts down this way when it is orphaned: its parent process exits or, under stdio, stdin ends. It is on by default for `runStdio()`, so a stdio server whose client dies runs `onShutdown()` and exits 0 instead of running on; `false` turns it off. It is off by default for the HTTP server and `runUnixSocket()`, whose daemons usually outlive the process that started them; `true` turns it on. | Setup need | Pattern | Cleanup | @@ -399,6 +403,7 @@ try { - It builds the server-level `providers`, then the `providers` of each local `@App` in `apps`. Remote apps, plugins and the other options of a server config are ignored. - `get(token)` looks at the server-level providers first, then each app's in order, and throws `ProviderNotAvailableError` when nothing registers the token; `tryGet(token)` returns `undefined` instead. A CONTEXT-scoped provider throws `ProviderScopedAccessError`: only a request builds it. - `onReady()` never runs: nothing is served. `FrontMcpLogger` can be injected, and `FrontMcpConfig` is the object you passed; `Scope` cannot, since there is none. +- A `@Duty` provider is built but never started, so scripts and workers don't run duties. `runDuties: true` starts them (each duty's own `onReady()` first) and needs `info: { name }`, the server's name, so they share its leases; `dispose()` stops them (see `create-duty`). - A failing `onBootstrap()` rejects with `ProviderBootstrapError` after the providers that bootstrapped are shut down. - It runs in Node, and in the browser when the providers do. - Import the config from a module without `@FrontMcp`: importing a decorated class starts the server. diff --git a/libs/skills/catalog/frontmcp-development/references/decorators-guide.md b/libs/skills/catalog/frontmcp-development/references/decorators-guide.md index 46c5c33dd..032e7b8f2 100644 --- a/libs/skills/catalog/frontmcp-development/references/decorators-guide.md +++ b/libs/skills/catalog/frontmcp-development/references/decorators-guide.md @@ -23,6 +23,7 @@ FrontMCP uses a hierarchical decorator system. The nesting order is: +-- @Adapter (external source adapter) +-- @Job (long-running job) +-- @Workflow (multi-step workflow) + +-- @Duty (recurring work in one process, a provider) +-- @Flow (custom flow) +-- @Hook (@Will, @Did, @Stage, @Around, @Finally) ``` @@ -557,9 +558,10 @@ class ApprovalFlow {} ## 13. @Job -**Purpose:** Declares a long-running or scheduled background job. +**Purpose:** Declares a long-running background job. -**When to use:** When you need recurring tasks (cron), background processing, or deferred work. +**When to use:** When you need background processing or deferred work with retries and progress. A job has no +schedule: for recurring work, use `@Duty`. **Key fields:** @@ -719,6 +721,39 @@ A hook declared on a `@Tool`, `@Resource` or `@Prompt` class runs only for that --- +## 16. @Duty + +**Purpose:** Marks a provider class whose `run()` the server calls every `every`, in one process of the machine or +project: the one that holds the duty's lease. Another process takes it over when the holder dies. + +**When to use:** When recurring work must not run in every copy of the server (see `create-duty`). + +**Key fields:** + +| Field | Description | +| ---------- | ----------------------------------------------------------------------------------------- | +| `name?` | Lease name in the server; defaults to the class name | +| `every` | Milliseconds, or a duration: `'500ms'`, `'30s'`, `'5m'`, `'1h'`, `'1d'` | +| `scope` | `'machine'` (one holder per machine) or `'workspace'` (one per project root) | +| `leaseMs?` | How soon another process takes over from a dead holder; default three intervals, 3 s–60 s | + +```typescript +import { Duty, type DutyRunContext } from '@frontmcp/sdk'; + +@Duty({ name: 'prune-cache', every: '10m', scope: 'machine' }) +class PruneCacheDuty { + constructor(private readonly cache: CacheStore) {} + + async run({ signal }: DutyRunContext) { + await this.cache.pruneExpired({ signal }); + } +} + +// Register it like a provider: @App({ providers: [CacheStore, PruneCacheDuty] }) +``` + +--- + ## Quick Reference Table | Decorator | Extends | Registered In | Purpose | @@ -737,6 +772,7 @@ A hook declared on a `@Tool`, `@Resource` or `@Prompt` class runs only for that | `@Flow` | - | `@App` | Custom flow | | `@Job` | `JobContext` | `@App.jobs` | Background task | | `@Workflow` | - | `@App.workflows` | Multi-step orchestration | +| `@Duty` | - | `@App.providers` | Recurring work | | `@Will/@Did/@Stage/@Around/@Finally` | - | Plugin, provider or entry class | Flow stage hooks | --- @@ -748,7 +784,8 @@ A hook declared on a `@Tool`, `@Resource` or `@Prompt` class runs only for that | Grouping tools into modules | Place tools inside `@App({ tools: [...] })` | Register tools directly in `@FrontMcp({ tools: [...] })` for large servers | Apps provide logical grouping, scoped providers, and isolation; standalone tools in `@FrontMcp` are only appropriate for small servers or global utilities | | Exposing data to the LLM | Use `@Resource` for fixed URIs, `@ResourceTemplate` for parameterized URIs | Using `@Tool` to return static data that never changes | Resources are the MCP-standard way to expose readable data; tools are for actions with side effects or dynamic computation | | Cross-cutting concerns | Create a `@Plugin` with providers and context extensions | Adding logging/caching logic directly inside every tool's `execute()` method | Plugins centralize shared behavior, reduce duplication, and can be reused across servers | -| Background processing | Use `@Job` with a cron schedule for recurring work | Using `setTimeout` or manual polling inside a tool | Jobs integrate with the scheduler, support persistence, and are visible in server diagnostics | +| Background processing | Use `@Job` for work a client starts, with retries and progress | Using `setTimeout` or manual polling inside a tool | Jobs track attempts and progress, and are visible in server diagnostics | +| Recurring work | Use a `@Duty({ every, scope })` provider | `setInterval()` in a provider, which every copy of the server runs | A duty runs in one process of the machine or project, and another takes over when it exits | | Multi-step orchestration | Use `@Workflow` with ordered steps referencing `@Job` classes | Chaining multiple tool calls manually from the LLM | Workflows provide built-in ordering, error handling, and rollback semantics | | Injecting services | Decorate a service class with `@Provider({ name, scope })` and inject it via `this.get(MyClass)`, or use `AsyncProvider({ provide, useFactory })` for token-based factories | Passing `useFactory`/`useClass` directly to `@Provider` (the schema is strict and rejects them), or importing singletons / using global state | DI providers support testability, lifecycle management, and per-scope isolation | diff --git a/libs/skills/catalog/skills-manifest.json b/libs/skills/catalog/skills-manifest.json index e220e1fcb..dc3bf949b 100644 --- a/libs/skills/catalog/skills-manifest.json +++ b/libs/skills/catalog/skills-manifest.json @@ -1718,7 +1718,7 @@ { "name": "frontmcp-development", "category": "development", - "description": "Use when building any FrontMCP server component other than a tool (for tools, use create-tool). Covers @Resource static resources and parameterized URI templates; @Prompt reusable prompts (RAG, multi-turn); @Provider singleton dependency-injection providers (database pools, API clients); @Agent autonomous LLM agents (Anthropic, OpenAI) and swarms; @Job background jobs (retry, progress, permissions) and @Workflow DAG pipelines; framework adapters and the OpenAPI adapter (turn OpenAPI 3.x specs into MCP tools with auth, polling, transforms); plugins, plugin lifecycle hooks (before / after / around / stage), and the official plugins; instruction-only skills and skills that reference tools; and the hierarchical decorator system from @FrontMcp down to @App. Triggers: create a resource, build a prompt, write a provider, add an agent, job, workflow, plugin, adapter, or OpenAPI integration.", + "description": "Use when building any FrontMCP server component other than a tool (for tools, use create-tool). Covers @Resource static resources and parameterized URI templates; @Prompt reusable prompts (RAG, multi-turn); @Provider singleton dependency-injection providers (database pools, API clients); @Agent autonomous LLM agents (Anthropic, OpenAI) and swarms; @Job background jobs (retry, progress, permissions) and @Workflow DAG pipelines; @Duty recurring work that one process of the machine or project runs under a lease; framework adapters and the OpenAPI adapter (turn OpenAPI 3.x specs into MCP tools with auth, polling, transforms); plugins, plugin lifecycle hooks (before / after / around / stage), and the official plugins; instruction-only skills and skills that reference tools; and the hierarchical decorator system from @FrontMcp down to @App. Triggers: create a resource, build a prompt, write a provider, add an agent, job, workflow, duty, plugin, adapter, or OpenAPI integration.", "path": "frontmcp-development", "targets": ["all"], "hasResources": true, @@ -1831,6 +1831,11 @@ } ] }, + { + "name": "create-duty", + "description": "Run recurring work in exactly one process of the machine or project with @Duty, a lease that one process holds and another takes over when the holder dies", + "examples": [] + }, { "name": "create-job", "description": "Create long-running background jobs with retry policies, progress tracking, and permissions", diff --git a/libs/utils/src/fs/fs.spec.ts b/libs/utils/src/fs/fs.spec.ts index 7feff5eec..ca44b11e1 100644 --- a/libs/utils/src/fs/fs.spec.ts +++ b/libs/utils/src/fs/fs.spec.ts @@ -305,6 +305,16 @@ describe('FS Utils', () => { const content = await fs.promises.readFile(filePath, 'utf8'); expect(content).toBe('updated'); }); + + it('appends with the append flag, creating the file with the mode only when it is missing', async () => { + const filePath = path.join(tempDir, 'append-test.txt'); + + await writeFile(filePath, 'first', { flag: 'a', mode: 0o600 }); + await writeFile(filePath, '', { flag: 'a', mode: 0o644 }); + + expect(await fs.promises.readFile(filePath, 'utf8')).toBe('first'); + if (process.platform !== 'win32') expect((await fs.promises.stat(filePath)).mode & 0o777).toBe(0o600); + }); }); describe('mkdir', () => { diff --git a/libs/utils/src/fs/fs.ts b/libs/utils/src/fs/fs.ts index 4b5ee9b92..15a1bc095 100644 --- a/libs/utils/src/fs/fs.ts +++ b/libs/utils/src/fs/fs.ts @@ -115,14 +115,14 @@ export async function readFileBuffer(p: string): Promise { * * @param p - Path to write to * @param content - Content to write - * @param options - Optional mode (permissions) + * @param options - Optional mode (permissions, applied when the file is created) and flag (e.g. `'a'` to append) * * @example * await writeFile('/path/to/file.txt', 'hello', { mode: 0o600 }); */ -export async function writeFile(p: string, content: string, options?: { mode?: number }): Promise { +export async function writeFile(p: string, content: string, options?: { mode?: number; flag?: string }): Promise { const fsp = getFsp(); - await fsp.writeFile(p, content, { encoding: 'utf8', mode: options?.mode }); + await fsp.writeFile(p, content, { encoding: 'utf8', mode: options?.mode, flag: options?.flag }); } /**