mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 19:35:04 +02:00
## 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>
159 lines
6.1 KiB
JavaScript
159 lines
6.1 KiB
JavaScript
#!/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();
|