refactor(server): extract the active-run output watchdog into a feature module (#12853)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The server recovery service monitors active runs and applies
watchdog decisions
> - The watchdog rules and database operations lived in one large
recovery service
> - This structure made the rules harder to test and made company
scoping harder to inspect
> - This pull request moves the watchdog into domain, application, and
adapter layers
> - The benefit is a smaller recovery service, pure policy tests, and
clear company-scoped ports

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The active-run output watchdog that detects silence, suppression, and
terminal evidence.

**Current behavior**

The recovery service contains the watchdog policy, use cases, database
operations, and process control in one file.

**Proposed behavior**

A feature module separates pure policy, use cases and ports, and
Postgres and process adapters. The recovery service delegates its public
watchdog methods to this module.

**Reason and benefit**

The separation makes policy decisions easy to test. Company identifiers
on every reader and writer port make tenant scope clear. Smaller service
methods reduce change risk.

**Breaking changes**

None. The recovery service keeps its public methods and call sites.

Related public watchdog work includes
[#7043](https://github.com/paperclipai/paperclip/pull/7043) and
[#7770](https://github.com/paperclipai/paperclip/pull/7770).

## What Changed

- Add the `server/src/modules/active-run-watchdog/` feature module with
domain, application, and adapter layers.
- Move watchdog policy, use cases, Postgres access, and local process
control into the module.
- Keep the recovery service public methods and delegate them to the
module.
- Add 53 pure module test cases and retain 8 Postgres integration cases.
- Add company scoping and transaction rollback coverage.

## Verification

- Run `vitest run --config vitest.config.ts src/modules` and confirm 3
files and 53 cases pass.
- Run `vitest run --config vitest.config.ts
src/__tests__/heartbeat-active-run-output-watchdog.test.ts` and confirm
1 file and 8 cases pass.
- Run the full server suite in pull request CI.
- Compare the type-check result with a fresh baseline on the same
checkout.

## Risks

The main risk is a behavior change in recovery decisions during the move
across layers. The pure policy tests cover the moved rules. The
integration tests cover database behavior, company scope, and
transaction rollback. Pull request CI runs the full server suite.

## Model Used

OpenAI Codex, GPT-5, runtime-managed context window, tool use, code
execution, and repository review support.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-09-05 09:15:59 -07:00
1 parent d2d647c34b
commit 3ed5b7c5c8
17 files changed
+2640 -804

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env node
import { readdirSync, readFileSync } from "node:fs";
import { dirname, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const defaultServerSrc = resolve(repoRoot, "server/src");
const defaultModulesRoot = resolve(defaultServerSrc, "modules");
const layerNames = new Set(["domain", "application", "adapters"]);
const databasePackages = ["@paperclipai/db", "drizzle-orm", "embedded-postgres", "postgres"];
function normalizedRelative(from, to) {
return relative(from, to).split(sep).join("/");
}
function isInside(root, candidate) {
const rel = relative(root, candidate);
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== "..");
}
function isPackageOrSubpath(specifier, packageName) {
return specifier === packageName || specifier.startsWith(`${packageName}/`);
}
function isDatabasePackage(specifier) {
return databasePackages.some((packageName) => isPackageOrSubpath(specifier, packageName));
}
export function extractImportSpecifiers(sourceText) {
const specifiers = new Set();
const patterns = [
/\bimport\s+(?:type\s+)?(?:[^"'`;]*?\s+from\s+)?["']([^"']+)["']/g,
/\bexport\s+(?:type\s+)?(?:[^"'`;]*?\s+from\s+)?["']([^"']+)["']/g,
/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g,
/\brequire\s*\(\s*["']([^"']+)["']\s*\)/g,
];
for (const pattern of patterns) {
for (const match of sourceText.matchAll(pattern)) specifiers.add(match[1]);
}
return [...specifiers];
}
function listProductionSourceFiles(root) {
const files = [];
const walk = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.name === "node_modules" || entry.name === "dist") continue;
const entryPath = resolve(directory, entry.name);
if (entry.isDirectory()) walk(entryPath);
else if (
entry.isFile() &&
/\.(?:ts|tsx)$/.test(entry.name) &&
!/\.(?:test|spec)\.(?:ts|tsx)$/.test(entry.name) &&
!entry.name.endsWith(".d.ts")
) {
files.push(entryPath);
}
}
};
walk(root);
return files.sort();
}
function moduleLocation(modulesRoot, filePath) {
if (!isInside(modulesRoot, filePath)) return null;
const [moduleName, layer] = normalizedRelative(modulesRoot, filePath).split("/");
if (!moduleName) return null;
return { moduleName, layer: layerNames.has(layer) ? layer : null };
}
function resolvedTarget(sourceFile, specifier) {
return specifier.startsWith(".") ? resolve(dirname(sourceFile), specifier) : null;
}
function targetServerSegments(serverSrc, target) {
if (!target || !isInside(serverSrc, target)) return [];
return normalizedRelative(serverSrc, target).split("/");
}
function addViolation(violations, file, layer, specifier, reason) {
violations.push({ file, layer, specifier, reason });
}
export function scanModuleBoundaries({
serverSrc = defaultServerSrc,
modulesRoot = defaultModulesRoot,
} = {}) {
const violations = [];
for (const sourceFile of listProductionSourceFiles(serverSrc)) {
const sourceLocation = moduleLocation(modulesRoot, sourceFile);
const sourceLabel = normalizedRelative(repoRoot, sourceFile);
const sourceText = readFileSync(sourceFile, "utf8");
for (const specifier of extractImportSpecifiers(sourceText)) {
const target = resolvedTarget(sourceFile, specifier);
const targetSegments = targetServerSegments(serverSrc, target);
const targetLocation = target ? moduleLocation(modulesRoot, target) : null;
if (sourceLocation?.layer === "domain") {
if (isDatabasePackage(specifier)) {
addViolation(violations, sourceLabel, "domain", specifier, "domain cannot import database packages");
} else if (specifier.startsWith("node:")) {
addViolation(violations, sourceLabel, "domain", specifier, "domain cannot import Node.js runtime modules");
} else if (targetSegments.includes("services") || targetSegments.includes("routes")) {
addViolation(violations, sourceLabel, "domain", specifier, "domain cannot import server services or routes");
} else if (targetLocation?.layer === "application" || targetLocation?.layer === "adapters") {
addViolation(violations, sourceLabel, "domain", specifier, "domain cannot depend on outer module layers");
}
}
if (sourceLocation?.layer === "application") {
if (isDatabasePackage(specifier)) {
addViolation(violations, sourceLabel, "application", specifier, "application cannot import database packages");
} else if (targetLocation?.layer === "adapters") {
addViolation(violations, sourceLabel, "application", specifier, "application cannot import concrete adapters");
} else if (targetSegments.join("/") === "errors.js" || targetSegments.join("/") === "errors.ts") {
addViolation(violations, sourceLabel, "application", specifier, "application cannot import HTTP error helpers");
}
}
if (targetLocation && sourceLocation?.moduleName !== targetLocation.moduleName) {
const targetRelative = normalizedRelative(resolve(modulesRoot, targetLocation.moduleName), target);
if (targetRelative !== "index.js" && targetRelative !== "index.ts") {
addViolation(
violations,
sourceLabel,
sourceLocation?.layer ?? null,
specifier,
`imports inside module ${targetLocation.moduleName} instead of its index`,
);
}
}
}
}
return violations;
}
export function formatViolation(violation) {
const layer = violation.layer ? ` (${violation.layer})` : "";
return `${violation.file}${layer}: ${violation.reason}: ${JSON.stringify(violation.specifier)}`;
}
function main() {
const violations = scanModuleBoundaries();
if (violations.length > 0) {
console.error("Feature module boundary check failed:");
for (const violation of violations) console.error(`- ${formatViolation(violation)}`);
process.exitCode = 1;
return;
}
console.log("Feature module boundary check passed.");
}
if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) main();