Merge origin/master into fix/issue-1255

- findMentionedAgents: keep normalizeAgentMentionToken + extractAgentMentionIds
- decode @mention tokens with entities.decodeHTMLStrict (full HTML entities)
- Add entities dependency; expand unit tests for Greptile follow-ups

Made-with: Cursor
This commit is contained in:
amit221 2026-03-24 10:03:15 +02:00
commit 53f0988006
334 changed files with 98279 additions and 9577 deletions

View file

@ -83,6 +83,20 @@ export function accessService(db: Db) {
.orderBy(sql`${companyMemberships.createdAt} desc`);
}
async function listActiveUserMemberships(companyId: string) {
return db
.select()
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.status, "active"),
),
)
.orderBy(sql`${companyMemberships.createdAt} asc`);
}
async function setMemberPermissions(
companyId: string,
memberId: string,
@ -251,6 +265,20 @@ export function accessService(db: Db) {
});
}
async function copyActiveUserMemberships(sourceCompanyId: string, targetCompanyId: string) {
const sourceMemberships = await listActiveUserMemberships(sourceCompanyId);
for (const membership of sourceMemberships) {
await ensureMembership(
targetCompanyId,
"user",
membership.principalId,
membership.membershipRole,
"active",
);
}
return sourceMemberships;
}
async function listPrincipalGrants(
companyId: string,
principalType: PrincipalType,
@ -338,6 +366,8 @@ export function accessService(db: Db) {
getMembership,
ensureMembership,
listMembers,
listActiveUserMemberships,
copyActiveUserMemberships,
setMemberPermissions,
promoteInstanceAdmin,
demoteInstanceAdmin,

View file

@ -8,6 +8,7 @@ import { redactCurrentUserValue } from "../log-redaction.js";
import { sanitizeRecord } from "../redaction.js";
import { logger } from "../middleware/logger.js";
import type { PluginEventBus } from "./plugin-event-bus.js";
import { instanceSettingsService } from "./instance-settings.js";
const PLUGIN_EVENT_SET: ReadonlySet<string> = new Set(PLUGIN_EVENT_TYPES);
@ -34,8 +35,13 @@ export interface LogActivityInput {
}
export async function logActivity(db: Db, input: LogActivityInput) {
const currentUserRedactionOptions = {
enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs,
};
const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null;
const redactedDetails = sanitizedDetails ? redactCurrentUserValue(sanitizedDetails) : null;
const redactedDetails = sanitizedDetails
? redactCurrentUserValue(sanitizedDetails, currentUserRedactionOptions)
: null;
await db.insert(activityLog).values({
companyId: input.companyId,
actorType: input.actorType,

View file

@ -0,0 +1,734 @@
import fs from "node:fs/promises";
import path from "node:path";
import { notFound, unprocessable } from "../errors.js";
import { resolveHomeAwarePath, resolvePaperclipInstanceRoot } from "../home-paths.js";
const ENTRY_FILE_DEFAULT = "AGENTS.md";
const MODE_KEY = "instructionsBundleMode";
const ROOT_KEY = "instructionsRootPath";
const ENTRY_KEY = "instructionsEntryFile";
const FILE_KEY = "instructionsFilePath";
const PROMPT_KEY = "promptTemplate";
const BOOTSTRAP_PROMPT_KEY = "bootstrapPromptTemplate";
const LEGACY_PROMPT_TEMPLATE_PATH = "promptTemplate.legacy.md";
const IGNORED_INSTRUCTIONS_FILE_NAMES = new Set([".DS_Store", "Thumbs.db", "Desktop.ini"]);
const IGNORED_INSTRUCTIONS_DIRECTORY_NAMES = new Set([
".git",
".nox",
".pytest_cache",
".ruff_cache",
".tox",
".venv",
"__pycache__",
"node_modules",
"venv",
]);
type BundleMode = "managed" | "external";
type AgentLike = {
id: string;
companyId: string;
name: string;
adapterConfig: unknown;
};
type AgentInstructionsFileSummary = {
path: string;
size: number;
language: string;
markdown: boolean;
isEntryFile: boolean;
editable: boolean;
deprecated: boolean;
virtual: boolean;
};
type AgentInstructionsFileDetail = AgentInstructionsFileSummary & {
content: string;
editable: boolean;
};
type AgentInstructionsBundle = {
agentId: string;
companyId: string;
mode: BundleMode | null;
rootPath: string | null;
managedRootPath: string;
entryFile: string;
resolvedEntryPath: string | null;
editable: boolean;
warnings: string[];
legacyPromptTemplateActive: boolean;
legacyBootstrapPromptTemplateActive: boolean;
files: AgentInstructionsFileSummary[];
};
type BundleState = {
config: Record<string, unknown>;
mode: BundleMode | null;
rootPath: string | null;
entryFile: string;
resolvedEntryPath: string | null;
warnings: string[];
legacyPromptTemplateActive: boolean;
legacyBootstrapPromptTemplateActive: boolean;
};
function asRecord(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
function asString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function isBundleMode(value: unknown): value is BundleMode {
return value === "managed" || value === "external";
}
function inferLanguage(relativePath: string): string {
const lower = relativePath.toLowerCase();
if (lower.endsWith(".md")) return "markdown";
if (lower.endsWith(".json")) return "json";
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "yaml";
if (lower.endsWith(".ts") || lower.endsWith(".tsx")) return "typescript";
if (lower.endsWith(".js") || lower.endsWith(".jsx") || lower.endsWith(".mjs") || lower.endsWith(".cjs")) {
return "javascript";
}
if (lower.endsWith(".sh")) return "bash";
if (lower.endsWith(".py")) return "python";
if (lower.endsWith(".toml")) return "toml";
if (lower.endsWith(".txt")) return "text";
return "text";
}
function isMarkdown(relativePath: string) {
return relativePath.toLowerCase().endsWith(".md");
}
function normalizeRelativeFilePath(candidatePath: string): string {
const normalized = path.posix.normalize(candidatePath.replaceAll("\\", "/")).replace(/^\/+/, "");
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) {
throw unprocessable("Instructions file path must stay within the bundle root");
}
return normalized;
}
function resolvePathWithinRoot(rootPath: string, relativePath: string): string {
const normalizedRelativePath = normalizeRelativeFilePath(relativePath);
const absoluteRoot = path.resolve(rootPath);
const absolutePath = path.resolve(absoluteRoot, normalizedRelativePath);
const relativeToRoot = path.relative(absoluteRoot, absolutePath);
if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${path.sep}`)) {
throw unprocessable("Instructions file path must stay within the bundle root");
}
return absolutePath;
}
function resolveManagedInstructionsRoot(agent: AgentLike): string {
return path.resolve(
resolvePaperclipInstanceRoot(),
"companies",
agent.companyId,
"agents",
agent.id,
"instructions",
);
}
function resolveLegacyInstructionsPath(candidatePath: string, config: Record<string, unknown>): string {
if (path.isAbsolute(candidatePath)) return candidatePath;
const cwd = asString(config.cwd);
if (!cwd || !path.isAbsolute(cwd)) {
throw unprocessable(
"Legacy relative instructionsFilePath requires adapterConfig.cwd to be set to an absolute path",
);
}
return path.resolve(cwd, candidatePath);
}
async function statIfExists(targetPath: string) {
return fs.stat(targetPath).catch(() => null);
}
function shouldIgnoreInstructionsEntry(entry: { name: string; isDirectory(): boolean; isFile(): boolean }) {
if (entry.name === "." || entry.name === "..") return true;
if (entry.isDirectory()) {
return IGNORED_INSTRUCTIONS_DIRECTORY_NAMES.has(entry.name);
}
if (!entry.isFile()) return false;
return (
IGNORED_INSTRUCTIONS_FILE_NAMES.has(entry.name)
|| entry.name.startsWith("._")
|| entry.name.endsWith(".pyc")
|| entry.name.endsWith(".pyo")
);
}
async function listFilesRecursive(rootPath: string): Promise<string[]> {
const output: string[] = [];
async function walk(currentPath: string, relativeDir: string) {
const entries = await fs.readdir(currentPath, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (shouldIgnoreInstructionsEntry(entry)) continue;
const absolutePath = path.join(currentPath, entry.name);
const relativePath = normalizeRelativeFilePath(
relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name,
);
if (entry.isDirectory()) {
await walk(absolutePath, relativePath);
continue;
}
if (!entry.isFile()) continue;
output.push(relativePath);
}
}
await walk(rootPath, "");
return output.sort((left, right) => left.localeCompare(right));
}
async function readFileSummary(rootPath: string, relativePath: string, entryFile: string): Promise<AgentInstructionsFileSummary> {
const absolutePath = resolvePathWithinRoot(rootPath, relativePath);
const stat = await fs.stat(absolutePath);
return {
path: relativePath,
size: stat.size,
language: inferLanguage(relativePath),
markdown: isMarkdown(relativePath),
isEntryFile: relativePath === entryFile,
editable: true,
deprecated: false,
virtual: false,
};
}
async function readLegacyInstructions(agent: AgentLike, config: Record<string, unknown>): Promise<string> {
const instructionsFilePath = asString(config[FILE_KEY]);
if (instructionsFilePath) {
try {
const resolvedPath = resolveLegacyInstructionsPath(instructionsFilePath, config);
return await fs.readFile(resolvedPath, "utf8");
} catch {
// Fall back to promptTemplate below.
}
}
return asString(config[PROMPT_KEY]) ?? "";
}
function deriveBundleState(agent: AgentLike): BundleState {
const config = asRecord(agent.adapterConfig);
const warnings: string[] = [];
const storedModeRaw = config[MODE_KEY];
const storedRootRaw = asString(config[ROOT_KEY]);
const legacyInstructionsPath = asString(config[FILE_KEY]);
let mode: BundleMode | null = isBundleMode(storedModeRaw) ? storedModeRaw : null;
let rootPath = storedRootRaw ? resolveHomeAwarePath(storedRootRaw) : null;
let entryFile = ENTRY_FILE_DEFAULT;
const storedEntryRaw = asString(config[ENTRY_KEY]);
if (storedEntryRaw) {
try {
entryFile = normalizeRelativeFilePath(storedEntryRaw);
} catch {
warnings.push(`Ignored invalid instructions entry file "${storedEntryRaw}".`);
}
}
if (!rootPath && legacyInstructionsPath) {
try {
const resolvedLegacyPath = resolveLegacyInstructionsPath(legacyInstructionsPath, config);
rootPath = path.dirname(resolvedLegacyPath);
entryFile = path.basename(resolvedLegacyPath);
mode = resolvedLegacyPath.startsWith(`${resolveManagedInstructionsRoot(agent)}${path.sep}`)
|| resolvedLegacyPath === path.join(resolveManagedInstructionsRoot(agent), entryFile)
? "managed"
: "external";
if (!path.isAbsolute(legacyInstructionsPath)) {
warnings.push("Using legacy relative instructionsFilePath; migrate this agent to a managed or absolute external bundle.");
}
} catch (err) {
warnings.push(err instanceof Error ? err.message : String(err));
}
}
const resolvedEntryPath = rootPath ? path.resolve(rootPath, entryFile) : null;
return {
config,
mode,
rootPath,
entryFile,
resolvedEntryPath,
warnings,
legacyPromptTemplateActive: Boolean(asString(config[PROMPT_KEY])),
legacyBootstrapPromptTemplateActive: Boolean(asString(config[BOOTSTRAP_PROMPT_KEY])),
};
}
async function recoverManagedBundleState(agent: AgentLike, state: BundleState): Promise<BundleState> {
const managedRootPath = resolveManagedInstructionsRoot(agent);
const stat = await statIfExists(managedRootPath);
if (!stat?.isDirectory()) return state;
const files = await listFilesRecursive(managedRootPath);
if (files.length === 0) return state;
const recoveredEntryFile = files.includes(state.entryFile)
? state.entryFile
: files.includes(ENTRY_FILE_DEFAULT)
? ENTRY_FILE_DEFAULT
: files[0]!;
if (!state.rootPath) {
return {
...state,
mode: "managed",
rootPath: managedRootPath,
entryFile: recoveredEntryFile,
resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile),
};
}
if (state.mode === "external") return state;
const resolvedConfiguredRoot = path.resolve(state.rootPath);
const configuredRootMatchesManaged = resolvedConfiguredRoot === managedRootPath;
const hasEntryMismatch = recoveredEntryFile !== state.entryFile;
if (configuredRootMatchesManaged && !hasEntryMismatch) {
return state;
}
const warnings = [...state.warnings];
if (!configuredRootMatchesManaged) {
warnings.push(
`Recovered managed instructions from disk at ${managedRootPath}; ignoring stale configured root ${state.rootPath}.`,
);
}
if (hasEntryMismatch) {
warnings.push(
`Recovered managed instructions entry file from disk as ${recoveredEntryFile}; previous entry ${state.entryFile} was missing.`,
);
}
return {
...state,
mode: "managed",
rootPath: managedRootPath,
entryFile: recoveredEntryFile,
resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile),
warnings,
};
}
function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructionsFileSummary[]): AgentInstructionsBundle {
const nextFiles = [...files];
if (state.legacyPromptTemplateActive && !nextFiles.some((file) => file.path === LEGACY_PROMPT_TEMPLATE_PATH)) {
const legacyPromptTemplate = asString(state.config[PROMPT_KEY]) ?? "";
nextFiles.push({
path: LEGACY_PROMPT_TEMPLATE_PATH,
size: legacyPromptTemplate.length,
language: "markdown",
markdown: true,
isEntryFile: false,
editable: true,
deprecated: true,
virtual: true,
});
}
nextFiles.sort((left, right) => left.path.localeCompare(right.path));
return {
agentId: agent.id,
companyId: agent.companyId,
mode: state.mode,
rootPath: state.rootPath,
managedRootPath: resolveManagedInstructionsRoot(agent),
entryFile: state.entryFile,
resolvedEntryPath: state.resolvedEntryPath,
editable: Boolean(state.rootPath),
warnings: state.warnings,
legacyPromptTemplateActive: state.legacyPromptTemplateActive,
legacyBootstrapPromptTemplateActive: state.legacyBootstrapPromptTemplateActive,
files: nextFiles,
};
}
function applyBundleConfig(
config: Record<string, unknown>,
input: {
mode: BundleMode;
rootPath: string;
entryFile: string;
clearLegacyPromptTemplate?: boolean;
},
): Record<string, unknown> {
const next: Record<string, unknown> = {
...config,
[MODE_KEY]: input.mode,
[ROOT_KEY]: input.rootPath,
[ENTRY_KEY]: input.entryFile,
[FILE_KEY]: path.resolve(input.rootPath, input.entryFile),
};
if (input.clearLegacyPromptTemplate) {
delete next[PROMPT_KEY];
delete next[BOOTSTRAP_PROMPT_KEY];
}
return next;
}
function buildPersistedBundleConfig(
derived: BundleState,
current: BundleState,
options?: { clearLegacyPromptTemplate?: boolean },
): Record<string, unknown> {
const currentRootPath = current.rootPath ? path.resolve(current.rootPath) : null;
const derivedRootPath = derived.rootPath ? path.resolve(derived.rootPath) : null;
const configMatchesRecoveredState =
derived.mode === current.mode
&& derivedRootPath !== null
&& currentRootPath !== null
&& derivedRootPath === currentRootPath
&& derived.entryFile === current.entryFile;
if (configMatchesRecoveredState && !options?.clearLegacyPromptTemplate) {
return current.config;
}
if (!current.rootPath || !current.mode) {
return current.config;
}
return applyBundleConfig(current.config, {
mode: current.mode,
rootPath: current.rootPath,
entryFile: current.entryFile,
clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate,
});
}
async function writeBundleFiles(
rootPath: string,
files: Record<string, string>,
options?: { overwriteExisting?: boolean },
) {
for (const [relativePath, content] of Object.entries(files)) {
const normalizedPath = normalizeRelativeFilePath(relativePath);
const absolutePath = resolvePathWithinRoot(rootPath, normalizedPath);
const existingStat = await statIfExists(absolutePath);
if (existingStat?.isFile() && !options?.overwriteExisting) continue;
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, content, "utf8");
}
}
export function syncInstructionsBundleConfigFromFilePath(
agent: AgentLike,
adapterConfig: Record<string, unknown>,
): Record<string, unknown> {
const instructionsFilePath = asString(adapterConfig[FILE_KEY]);
const next = { ...adapterConfig };
if (!instructionsFilePath) {
delete next[MODE_KEY];
delete next[ROOT_KEY];
delete next[ENTRY_KEY];
return next;
}
const resolvedPath = resolveLegacyInstructionsPath(instructionsFilePath, adapterConfig);
const rootPath = path.dirname(resolvedPath);
const entryFile = path.basename(resolvedPath);
const mode: BundleMode = resolvedPath.startsWith(`${resolveManagedInstructionsRoot(agent)}${path.sep}`)
|| resolvedPath === path.join(resolveManagedInstructionsRoot(agent), entryFile)
? "managed"
: "external";
return applyBundleConfig(next, { mode, rootPath, entryFile });
}
export function agentInstructionsService() {
async function getBundle(agent: AgentLike): Promise<AgentInstructionsBundle> {
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
if (!state.rootPath) return toBundle(agent, state, []);
const stat = await statIfExists(state.rootPath);
if (!stat?.isDirectory()) {
return toBundle(agent, {
...state,
warnings: [...state.warnings, `Instructions root does not exist: ${state.rootPath}`],
}, []);
}
const files = await listFilesRecursive(state.rootPath);
const summaries = await Promise.all(files.map((relativePath) => readFileSummary(state.rootPath!, relativePath, state.entryFile)));
return toBundle(agent, state, summaries);
}
async function readFile(agent: AgentLike, relativePath: string): Promise<AgentInstructionsFileDetail> {
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
const content = asString(state.config[PROMPT_KEY]);
if (content === null) throw notFound("Instructions file not found");
return {
path: LEGACY_PROMPT_TEMPLATE_PATH,
size: content.length,
language: "markdown",
markdown: true,
isEntryFile: false,
editable: true,
deprecated: true,
virtual: true,
content,
};
}
if (!state.rootPath) throw notFound("Agent instructions bundle is not configured");
const absolutePath = resolvePathWithinRoot(state.rootPath, relativePath);
const [content, stat] = await Promise.all([
fs.readFile(absolutePath, "utf8").catch(() => null),
fs.stat(absolutePath).catch(() => null),
]);
if (content === null || !stat?.isFile()) throw notFound("Instructions file not found");
const normalizedPath = normalizeRelativeFilePath(relativePath);
return {
path: normalizedPath,
size: stat.size,
language: inferLanguage(normalizedPath),
markdown: isMarkdown(normalizedPath),
isEntryFile: normalizedPath === state.entryFile,
editable: true,
deprecated: false,
virtual: false,
content,
};
}
async function ensureWritableBundle(
agent: AgentLike,
options?: { clearLegacyPromptTemplate?: boolean },
): Promise<{ adapterConfig: Record<string, unknown>; state: BundleState }> {
const derived = deriveBundleState(agent);
const current = await recoverManagedBundleState(agent, derived);
if (current.rootPath && current.mode) {
const adapterConfig = buildPersistedBundleConfig(derived, current, options);
return {
adapterConfig,
state: deriveBundleState({ ...agent, adapterConfig }),
};
}
const managedRoot = resolveManagedInstructionsRoot(agent);
const entryFile = current.entryFile || ENTRY_FILE_DEFAULT;
const nextConfig = applyBundleConfig(current.config, {
mode: "managed",
rootPath: managedRoot,
entryFile,
clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate,
});
await fs.mkdir(managedRoot, { recursive: true });
const entryPath = resolvePathWithinRoot(managedRoot, entryFile);
const entryStat = await statIfExists(entryPath);
if (!entryStat?.isFile()) {
const legacyInstructions = await readLegacyInstructions(agent, current.config);
if (legacyInstructions.trim().length > 0) {
await fs.mkdir(path.dirname(entryPath), { recursive: true });
await fs.writeFile(entryPath, legacyInstructions, "utf8");
}
}
return {
adapterConfig: nextConfig,
state: deriveBundleState({ ...agent, adapterConfig: nextConfig }),
};
}
async function updateBundle(
agent: AgentLike,
input: {
mode?: BundleMode;
rootPath?: string | null;
entryFile?: string;
clearLegacyPromptTemplate?: boolean;
},
): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> {
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
const nextMode = input.mode ?? state.mode ?? "managed";
const nextEntryFile = input.entryFile ? normalizeRelativeFilePath(input.entryFile) : state.entryFile;
let nextRootPath: string;
if (nextMode === "managed") {
nextRootPath = resolveManagedInstructionsRoot(agent);
} else {
const rootPath = asString(input.rootPath) ?? state.rootPath;
if (!rootPath) {
throw unprocessable("External instructions bundles require an absolute rootPath");
}
const resolvedRoot = resolveHomeAwarePath(rootPath);
if (!path.isAbsolute(resolvedRoot)) {
throw unprocessable("External instructions bundles require an absolute rootPath");
}
nextRootPath = resolvedRoot;
}
await fs.mkdir(nextRootPath, { recursive: true });
const existingFiles = await listFilesRecursive(nextRootPath);
const exported = await exportFiles(agent);
if (existingFiles.length === 0) {
await writeBundleFiles(nextRootPath, exported.files);
}
const refreshedFiles = existingFiles.length === 0 ? await listFilesRecursive(nextRootPath) : existingFiles;
if (!refreshedFiles.includes(nextEntryFile)) {
const nextEntryContent = exported.files[nextEntryFile] ?? exported.files[exported.entryFile] ?? "";
await writeBundleFiles(nextRootPath, { [nextEntryFile]: nextEntryContent });
}
const nextConfig = applyBundleConfig(state.config, {
mode: nextMode,
rootPath: nextRootPath,
entryFile: nextEntryFile,
clearLegacyPromptTemplate: input.clearLegacyPromptTemplate,
});
const nextBundle = await getBundle({ ...agent, adapterConfig: nextConfig });
return { bundle: nextBundle, adapterConfig: nextConfig };
}
async function writeFile(
agent: AgentLike,
relativePath: string,
content: string,
options?: { clearLegacyPromptTemplate?: boolean },
): Promise<{
bundle: AgentInstructionsBundle;
file: AgentInstructionsFileDetail;
adapterConfig: Record<string, unknown>;
}> {
const current = deriveBundleState(agent);
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
const adapterConfig: Record<string, unknown> = {
...current.config,
[PROMPT_KEY]: content,
};
const nextAgent = { ...agent, adapterConfig };
const [bundle, file] = await Promise.all([
getBundle(nextAgent),
readFile(nextAgent, LEGACY_PROMPT_TEMPLATE_PATH),
]);
return { bundle, file, adapterConfig };
}
const prepared = await ensureWritableBundle(agent, options);
const absolutePath = resolvePathWithinRoot(prepared.state.rootPath!, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, content, "utf8");
const nextAgent = { ...agent, adapterConfig: prepared.adapterConfig };
const [bundle, file] = await Promise.all([
getBundle(nextAgent),
readFile(nextAgent, relativePath),
]);
return { bundle, file, adapterConfig: prepared.adapterConfig };
}
async function deleteFile(agent: AgentLike, relativePath: string): Promise<{
bundle: AgentInstructionsBundle;
adapterConfig: Record<string, unknown>;
}> {
const derived = deriveBundleState(agent);
const state = await recoverManagedBundleState(agent, derived);
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
throw unprocessable("Cannot delete the legacy promptTemplate pseudo-file");
}
if (!state.rootPath) throw notFound("Agent instructions bundle is not configured");
const normalizedPath = normalizeRelativeFilePath(relativePath);
if (normalizedPath === state.entryFile) {
throw unprocessable("Cannot delete the bundle entry file");
}
const absolutePath = resolvePathWithinRoot(state.rootPath, normalizedPath);
await fs.rm(absolutePath, { force: true });
const adapterConfig = buildPersistedBundleConfig(derived, state);
const bundle = await getBundle({ ...agent, adapterConfig });
return { bundle, adapterConfig };
}
async function exportFiles(agent: AgentLike): Promise<{
files: Record<string, string>;
entryFile: string;
warnings: string[];
}> {
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
if (state.rootPath) {
const stat = await statIfExists(state.rootPath);
if (stat?.isDirectory()) {
const relativePaths = await listFilesRecursive(state.rootPath);
const files = Object.fromEntries(await Promise.all(relativePaths.map(async (relativePath) => {
const absolutePath = resolvePathWithinRoot(state.rootPath!, relativePath);
const content = await fs.readFile(absolutePath, "utf8");
return [relativePath, content] as const;
})));
if (Object.keys(files).length > 0) {
return { files, entryFile: state.entryFile, warnings: state.warnings };
}
}
}
const legacyBody = await readLegacyInstructions(agent, state.config);
return {
files: { [state.entryFile]: legacyBody || "_No AGENTS instructions were resolved from current agent config._" },
entryFile: state.entryFile,
warnings: state.warnings,
};
}
async function materializeManagedBundle(
agent: AgentLike,
files: Record<string, string>,
options?: {
clearLegacyPromptTemplate?: boolean;
replaceExisting?: boolean;
entryFile?: string;
},
): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> {
const rootPath = resolveManagedInstructionsRoot(agent);
const entryFile = options?.entryFile ? normalizeRelativeFilePath(options.entryFile) : ENTRY_FILE_DEFAULT;
if (options?.replaceExisting) {
await fs.rm(rootPath, { recursive: true, force: true });
}
await fs.mkdir(rootPath, { recursive: true });
const normalizedEntries = Object.entries(files).map(([relativePath, content]) => [
normalizeRelativeFilePath(relativePath),
content,
] as const);
for (const [relativePath, content] of normalizedEntries) {
const absolutePath = resolvePathWithinRoot(rootPath, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, content, "utf8");
}
if (!normalizedEntries.some(([relativePath]) => relativePath === entryFile)) {
await fs.writeFile(resolvePathWithinRoot(rootPath, entryFile), "", "utf8");
}
const adapterConfig = applyBundleConfig(asRecord(agent.adapterConfig), {
mode: "managed",
rootPath,
entryFile,
clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate,
});
const bundle = await getBundle({ ...agent, adapterConfig });
return { bundle, adapterConfig };
}
return {
getBundle,
readFile,
updateBundle,
writeFile,
deleteFile,
exportFiles,
ensureManagedBundle: ensureWritableBundle,
materializeManagedBundle,
};
}

View file

@ -6,22 +6,24 @@ import { redactCurrentUserText } from "../log-redaction.js";
import { agentService } from "./agents.js";
import { budgetService } from "./budgets.js";
import { notifyHireApproved } from "./hire-hook.js";
function redactApprovalComment<T extends { body: string }>(comment: T): T {
return {
...comment,
body: redactCurrentUserText(comment.body),
};
}
import { instanceSettingsService } from "./instance-settings.js";
export function approvalService(db: Db) {
const agentsSvc = agentService(db);
const budgets = budgetService(db);
const instanceSettings = instanceSettingsService(db);
const canResolveStatuses = new Set(["pending", "revision_requested"]);
const resolvableStatuses = Array.from(canResolveStatuses);
type ApprovalRecord = typeof approvals.$inferSelect;
type ResolutionResult = { approval: ApprovalRecord; applied: boolean };
function redactApprovalComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
return {
...comment,
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
};
}
async function getExistingApproval(id: string) {
const existing = await db
.select()
@ -230,6 +232,7 @@ export function approvalService(db: Db) {
listComments: async (approvalId: string) => {
const existing = await getExistingApproval(approvalId);
const { censorUsernameInLogs } = await instanceSettings.getGeneral();
return db
.select()
.from(approvalComments)
@ -240,7 +243,7 @@ export function approvalService(db: Db) {
),
)
.orderBy(asc(approvalComments.createdAt))
.then((comments) => comments.map(redactApprovalComment));
.then((comments) => comments.map((comment) => redactApprovalComment(comment, censorUsernameInLogs)));
},
addComment: async (
@ -249,7 +252,10 @@ export function approvalService(db: Db) {
actor: { agentId?: string; userId?: string },
) => {
const existing = await getExistingApproval(approvalId);
const redactedBody = redactCurrentUserText(body);
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
return db
.insert(approvalComments)
.values({
@ -260,7 +266,7 @@ export function approvalService(db: Db) {
body: redactedBody,
})
.returning()
.then((rows) => redactApprovalComment(rows[0]));
.then((rows) => redactApprovalComment(rows[0], currentUserRedactionOptions.enabled));
},
};
}

View file

@ -0,0 +1,354 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { and, eq, isNull, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
authUsers,
boardApiKeys,
cliAuthChallenges,
companies,
companyMemberships,
instanceUserRoles,
} from "@paperclipai/db";
import { conflict, forbidden, notFound } from "../errors.js";
export const BOARD_API_KEY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
export const CLI_AUTH_CHALLENGE_TTL_MS = 10 * 60 * 1000;
export type CliAuthChallengeStatus = "pending" | "approved" | "cancelled" | "expired";
export function hashBearerToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export function tokenHashesMatch(left: string, right: string) {
const leftBytes = Buffer.from(left, "utf8");
const rightBytes = Buffer.from(right, "utf8");
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
}
export function createBoardApiToken() {
return `pcp_board_${randomBytes(24).toString("hex")}`;
}
export function createCliAuthSecret() {
return `pcp_cli_auth_${randomBytes(24).toString("hex")}`;
}
export function boardApiKeyExpiresAt(nowMs: number = Date.now()) {
return new Date(nowMs + BOARD_API_KEY_TTL_MS);
}
export function cliAuthChallengeExpiresAt(nowMs: number = Date.now()) {
return new Date(nowMs + CLI_AUTH_CHALLENGE_TTL_MS);
}
function challengeStatusForRow(row: typeof cliAuthChallenges.$inferSelect): CliAuthChallengeStatus {
if (row.cancelledAt) return "cancelled";
if (row.expiresAt.getTime() <= Date.now()) return "expired";
if (row.approvedAt && row.boardApiKeyId) return "approved";
return "pending";
}
export function boardAuthService(db: Db) {
async function resolveBoardAccess(userId: string) {
const [user, memberships, adminRole] = await Promise.all([
db
.select({
id: authUsers.id,
name: authUsers.name,
email: authUsers.email,
})
.from(authUsers)
.where(eq(authUsers.id, userId))
.then((rows) => rows[0] ?? null),
db
.select({ companyId: companyMemberships.companyId })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
eq(companyMemberships.status, "active"),
),
)
.then((rows) => rows.map((row) => row.companyId)),
db
.select({ id: instanceUserRoles.id })
.from(instanceUserRoles)
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
.then((rows) => rows[0] ?? null),
]);
return {
user,
companyIds: memberships,
isInstanceAdmin: Boolean(adminRole),
};
}
async function resolveBoardActivityCompanyIds(input: {
userId: string;
requestedCompanyId?: string | null;
boardApiKeyId?: string | null;
}) {
const access = await resolveBoardAccess(input.userId);
const companyIds = new Set(access.companyIds);
if (companyIds.size === 0 && input.requestedCompanyId?.trim()) {
companyIds.add(input.requestedCompanyId.trim());
}
if (companyIds.size === 0 && input.boardApiKeyId?.trim()) {
const challengeCompanyIds = await db
.select({ requestedCompanyId: cliAuthChallenges.requestedCompanyId })
.from(cliAuthChallenges)
.where(eq(cliAuthChallenges.boardApiKeyId, input.boardApiKeyId.trim()))
.then((rows) =>
rows
.map((row) => row.requestedCompanyId?.trim() ?? null)
.filter((value): value is string => Boolean(value)),
);
for (const companyId of challengeCompanyIds) {
companyIds.add(companyId);
}
}
if (companyIds.size === 0 && access.isInstanceAdmin) {
const allCompanyIds = await db
.select({ id: companies.id })
.from(companies)
.then((rows) => rows.map((row) => row.id));
for (const companyId of allCompanyIds) {
companyIds.add(companyId);
}
}
return Array.from(companyIds);
}
async function findBoardApiKeyByToken(token: string) {
const tokenHash = hashBearerToken(token);
const now = new Date();
return db
.select()
.from(boardApiKeys)
.where(
and(
eq(boardApiKeys.keyHash, tokenHash),
isNull(boardApiKeys.revokedAt),
),
)
.then((rows) => rows.find((row) => !row.expiresAt || row.expiresAt.getTime() > now.getTime()) ?? null);
}
async function touchBoardApiKey(id: string) {
await db.update(boardApiKeys).set({ lastUsedAt: new Date() }).where(eq(boardApiKeys.id, id));
}
async function revokeBoardApiKey(id: string) {
const now = new Date();
return db
.update(boardApiKeys)
.set({ revokedAt: now, lastUsedAt: now })
.where(and(eq(boardApiKeys.id, id), isNull(boardApiKeys.revokedAt)))
.returning()
.then((rows) => rows[0] ?? null);
}
async function createCliAuthChallenge(input: {
command: string;
clientName?: string | null;
requestedAccess: "board" | "instance_admin_required";
requestedCompanyId?: string | null;
}) {
const challengeSecret = createCliAuthSecret();
const pendingBoardToken = createBoardApiToken();
const expiresAt = cliAuthChallengeExpiresAt();
const labelBase = input.clientName?.trim() || "paperclipai cli";
const pendingKeyName =
input.requestedAccess === "instance_admin_required"
? `${labelBase} (instance admin)`
: `${labelBase} (board)`;
const created = await db
.insert(cliAuthChallenges)
.values({
secretHash: hashBearerToken(challengeSecret),
command: input.command.trim(),
clientName: input.clientName?.trim() || null,
requestedAccess: input.requestedAccess,
requestedCompanyId: input.requestedCompanyId?.trim() || null,
pendingKeyHash: hashBearerToken(pendingBoardToken),
pendingKeyName,
expiresAt,
})
.returning()
.then((rows) => rows[0]);
return {
challenge: created,
challengeSecret,
pendingBoardToken,
};
}
async function getCliAuthChallenge(id: string) {
return db
.select()
.from(cliAuthChallenges)
.where(eq(cliAuthChallenges.id, id))
.then((rows) => rows[0] ?? null);
}
async function getCliAuthChallengeBySecret(id: string, token: string) {
const challenge = await getCliAuthChallenge(id);
if (!challenge) return null;
if (!tokenHashesMatch(challenge.secretHash, hashBearerToken(token))) return null;
return challenge;
}
async function describeCliAuthChallenge(id: string, token: string) {
const challenge = await getCliAuthChallengeBySecret(id, token);
if (!challenge) return null;
const [company, approvedBy] = await Promise.all([
challenge.requestedCompanyId
? db
.select({ id: companies.id, name: companies.name })
.from(companies)
.where(eq(companies.id, challenge.requestedCompanyId))
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
challenge.approvedByUserId
? db
.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email })
.from(authUsers)
.where(eq(authUsers.id, challenge.approvedByUserId))
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
]);
return {
id: challenge.id,
status: challengeStatusForRow(challenge),
command: challenge.command,
clientName: challenge.clientName ?? null,
requestedAccess: challenge.requestedAccess as "board" | "instance_admin_required",
requestedCompanyId: challenge.requestedCompanyId ?? null,
requestedCompanyName: company?.name ?? null,
approvedAt: challenge.approvedAt?.toISOString() ?? null,
cancelledAt: challenge.cancelledAt?.toISOString() ?? null,
expiresAt: challenge.expiresAt.toISOString(),
approvedByUser: approvedBy
? {
id: approvedBy.id,
name: approvedBy.name,
email: approvedBy.email,
}
: null,
};
}
async function approveCliAuthChallenge(id: string, token: string, userId: string) {
const access = await resolveBoardAccess(userId);
return db.transaction(async (tx) => {
await tx.execute(
sql`select ${cliAuthChallenges.id} from ${cliAuthChallenges} where ${cliAuthChallenges.id} = ${id} for update`,
);
const challenge = await tx
.select()
.from(cliAuthChallenges)
.where(eq(cliAuthChallenges.id, id))
.then((rows) => rows[0] ?? null);
if (!challenge || !tokenHashesMatch(challenge.secretHash, hashBearerToken(token))) {
throw notFound("CLI auth challenge not found");
}
const status = challengeStatusForRow(challenge);
if (status === "expired") return { status, challenge };
if (status === "cancelled") return { status, challenge };
if (challenge.requestedAccess === "instance_admin_required" && !access.isInstanceAdmin) {
throw forbidden("Instance admin required");
}
let boardKeyId = challenge.boardApiKeyId;
if (!boardKeyId) {
const createdKey = await tx
.insert(boardApiKeys)
.values({
userId,
name: challenge.pendingKeyName,
keyHash: challenge.pendingKeyHash,
expiresAt: boardApiKeyExpiresAt(),
})
.returning()
.then((rows) => rows[0]);
boardKeyId = createdKey.id;
}
const approvedAt = challenge.approvedAt ?? new Date();
const updated = await tx
.update(cliAuthChallenges)
.set({
approvedByUserId: userId,
boardApiKeyId: boardKeyId,
approvedAt,
updatedAt: new Date(),
})
.where(eq(cliAuthChallenges.id, challenge.id))
.returning()
.then((rows) => rows[0] ?? challenge);
return { status: "approved" as const, challenge: updated };
});
}
async function cancelCliAuthChallenge(id: string, token: string) {
const challenge = await getCliAuthChallengeBySecret(id, token);
if (!challenge) throw notFound("CLI auth challenge not found");
const status = challengeStatusForRow(challenge);
if (status === "approved") return { status, challenge };
if (status === "expired") return { status, challenge };
if (status === "cancelled") return { status, challenge };
const updated = await db
.update(cliAuthChallenges)
.set({
cancelledAt: new Date(),
updatedAt: new Date(),
})
.where(eq(cliAuthChallenges.id, challenge.id))
.returning()
.then((rows) => rows[0] ?? challenge);
return { status: "cancelled" as const, challenge: updated };
}
async function assertCurrentBoardKey(keyId: string | undefined, userId: string | undefined) {
if (!keyId || !userId) throw conflict("Board API key context is required");
const key = await db
.select()
.from(boardApiKeys)
.where(and(eq(boardApiKeys.id, keyId), eq(boardApiKeys.userId, userId)))
.then((rows) => rows[0] ?? null);
if (!key || key.revokedAt) throw notFound("Board API key not found");
return key;
}
return {
resolveBoardAccess,
findBoardApiKeyByToken,
touchBoardApiKey,
revokeBoardApiKey,
createCliAuthChallenge,
getCliAuthChallengeBySecret,
describeCliAuthChallenge,
approveCliAuthChallenge,
cancelCliAuthChallenge,
assertCurrentBoardKey,
resolveBoardActivityCompanyIds,
};
}

View file

@ -0,0 +1,172 @@
/**
* Generates README.md with Mermaid org chart for company exports.
*/
import type { CompanyPortabilityManifest } from "@paperclipai/shared";
const ROLE_LABELS: Record<string, string> = {
ceo: "CEO",
cto: "CTO",
cmo: "CMO",
cfo: "CFO",
coo: "COO",
vp: "VP",
manager: "Manager",
engineer: "Engineer",
agent: "Agent",
};
/**
* Generate a Mermaid flowchart (TD = top-down) representing the org chart.
* Returns null if there are no agents.
*/
export function generateOrgChartMermaid(agents: CompanyPortabilityManifest["agents"]): string | null {
if (agents.length === 0) return null;
const lines: string[] = [];
lines.push("```mermaid");
lines.push("graph TD");
// Node definitions with role labels
for (const agent of agents) {
const roleLabel = ROLE_LABELS[agent.role] ?? agent.role;
const id = mermaidId(agent.slug);
lines.push(` ${id}["${mermaidEscape(agent.name)}<br/><small>${mermaidEscape(roleLabel)}</small>"]`);
}
// Edges from parent to child
const slugSet = new Set(agents.map((a) => a.slug));
for (const agent of agents) {
if (agent.reportsToSlug && slugSet.has(agent.reportsToSlug)) {
lines.push(` ${mermaidId(agent.reportsToSlug)} --> ${mermaidId(agent.slug)}`);
}
}
lines.push("```");
return lines.join("\n");
}
/** Sanitize slug for use as a Mermaid node ID (alphanumeric + underscore). */
function mermaidId(slug: string): string {
return slug.replace(/[^a-zA-Z0-9_]/g, "_");
}
/** Escape text for Mermaid node labels. */
function mermaidEscape(s: string): string {
return s.replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** Build a display label for a skill's source, linking to GitHub when available. */
function skillSourceLabel(skill: CompanyPortabilityManifest["skills"][number]): string {
if (skill.sourceLocator) {
// For GitHub or URL sources, render as a markdown link
if (skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url") {
return `[${skill.sourceType}](${skill.sourceLocator})`;
}
return skill.sourceLocator;
}
if (skill.sourceType === "local") return "local";
return skill.sourceType ?? "\u2014";
}
/**
* Generate the README.md content for a company export.
*/
export function generateReadme(
manifest: CompanyPortabilityManifest,
options: {
companyName: string;
companyDescription: string | null;
},
): string {
const lines: string[] = [];
lines.push(`# ${options.companyName}`);
lines.push("");
if (options.companyDescription) {
lines.push(`> ${options.companyDescription}`);
lines.push("");
}
// Org chart image (generated during export as images/org-chart.png)
if (manifest.agents.length > 0) {
lines.push("![Org Chart](images/org-chart.png)");
lines.push("");
}
// What's Inside table
lines.push("## What's Inside");
lines.push("");
lines.push("> This is an [Agent Company](https://agentcompanies.io) package from [Paperclip](https://paperclip.ing)");
lines.push("");
const counts: Array<[string, number]> = [];
if (manifest.agents.length > 0) counts.push(["Agents", manifest.agents.length]);
if (manifest.projects.length > 0) counts.push(["Projects", manifest.projects.length]);
if (manifest.skills.length > 0) counts.push(["Skills", manifest.skills.length]);
if (manifest.issues.length > 0) counts.push(["Tasks", manifest.issues.length]);
if (counts.length > 0) {
lines.push("| Content | Count |");
lines.push("|---------|-------|");
for (const [label, count] of counts) {
lines.push(`| ${label} | ${count} |`);
}
lines.push("");
}
// Agents table
if (manifest.agents.length > 0) {
lines.push("### Agents");
lines.push("");
lines.push("| Agent | Role | Reports To |");
lines.push("|-------|------|------------|");
for (const agent of manifest.agents) {
const roleLabel = ROLE_LABELS[agent.role] ?? agent.role;
const reportsTo = agent.reportsToSlug ?? "\u2014";
lines.push(`| ${agent.name} | ${roleLabel} | ${reportsTo} |`);
}
lines.push("");
}
// Projects list
if (manifest.projects.length > 0) {
lines.push("### Projects");
lines.push("");
for (const project of manifest.projects) {
const desc = project.description ? ` \u2014 ${project.description}` : "";
lines.push(`- **${project.name}**${desc}`);
}
lines.push("");
}
// Skills list
if (manifest.skills.length > 0) {
lines.push("### Skills");
lines.push("");
lines.push("| Skill | Description | Source |");
lines.push("|-------|-------------|--------|");
for (const skill of manifest.skills) {
const desc = skill.description ?? "\u2014";
const source = skillSourceLabel(skill);
lines.push(`| ${skill.name} | ${desc} | ${source} |`);
}
lines.push("");
}
// Getting Started
lines.push("## Getting Started");
lines.push("");
lines.push("```bash");
lines.push("pnpm paperclipai company import this-github-url-or-folder");
lines.push("```");
lines.push("");
lines.push("See [Paperclip](https://paperclip.ing) for more information.");
lines.push("");
// Footer
lines.push("---");
lines.push(`Exported from [Paperclip](https://paperclip.ing) on ${new Date().toISOString().split("T")[0]}`);
lines.push("");
return lines.join("\n");
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
import fs from "node:fs/promises";
const DEFAULT_AGENT_BUNDLE_FILES = {
default: ["AGENTS.md"],
ceo: ["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"],
} as const;
type DefaultAgentBundleRole = keyof typeof DEFAULT_AGENT_BUNDLE_FILES;
function resolveDefaultAgentBundleUrl(role: DefaultAgentBundleRole, fileName: string) {
return new URL(`../onboarding-assets/${role}/${fileName}`, import.meta.url);
}
export async function loadDefaultAgentInstructionsBundle(role: DefaultAgentBundleRole): Promise<Record<string, string>> {
const fileNames = DEFAULT_AGENT_BUNDLE_FILES[role];
const entries = await Promise.all(
fileNames.map(async (fileName) => {
const content = await fs.readFile(resolveDefaultAgentBundleUrl(role, fileName), "utf8");
return [fileName, content] as const;
}),
);
return Object.fromEntries(entries);
}
export function resolveDefaultAgentInstructionsBundleRole(role: string): DefaultAgentBundleRole {
return role === "ceo" ? "ceo" : "default";
}

View file

@ -132,6 +132,21 @@ export function defaultIssueExecutionWorkspaceSettingsForProject(
};
}
export function issueExecutionWorkspaceModeForPersistedWorkspace(
mode: string | null | undefined,
): IssueExecutionWorkspaceSettings["mode"] {
if (mode === null || mode === undefined) {
return "agent_default";
}
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") {
return mode;
}
if (mode === "adapter_managed" || mode === "cloud_sandbox") {
return "agent_default";
}
return "shared_workspace";
}
export function resolveExecutionWorkspaceMode(input: {
projectPolicy: ProjectExecutionWorkspacePolicy | null;
issueSettings: IssueExecutionWorkspaceSettings | null;

View file

@ -25,6 +25,7 @@ import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
import { costService } from "./costs.js";
import { companySkillService } from "./company-skills.js";
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
import { secretService } from "./secrets.js";
import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js";
@ -44,6 +45,7 @@ import { workspaceOperationService } from "./workspace-operations.js";
import {
buildExecutionWorkspaceAdapterConfig,
gateProjectExecutionWorkspacePolicy,
issueExecutionWorkspaceModeForPersistedWorkspace,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode,
@ -324,6 +326,51 @@ async function resolveLedgerScopeForRun(
};
}
type ResumeSessionRow = {
sessionParamsJson: Record<string, unknown> | null;
sessionDisplayId: string | null;
lastRunId: string | null;
};
export function buildExplicitResumeSessionOverride(input: {
resumeFromRunId: string;
resumeRunSessionIdBefore: string | null;
resumeRunSessionIdAfter: string | null;
taskSession: ResumeSessionRow | null;
sessionCodec: AdapterSessionCodec;
}) {
const desiredDisplayId = truncateDisplayId(
input.resumeRunSessionIdAfter ?? input.resumeRunSessionIdBefore,
);
const taskSessionParams = normalizeSessionParams(
input.sessionCodec.deserialize(input.taskSession?.sessionParamsJson ?? null),
);
const taskSessionDisplayId = truncateDisplayId(
input.taskSession?.sessionDisplayId ??
(input.sessionCodec.getDisplayId ? input.sessionCodec.getDisplayId(taskSessionParams) : null) ??
readNonEmptyString(taskSessionParams?.sessionId),
);
const canReuseTaskSessionParams =
input.taskSession != null &&
(
input.taskSession.lastRunId === input.resumeFromRunId ||
(!!desiredDisplayId && taskSessionDisplayId === desiredDisplayId)
);
const sessionParams =
canReuseTaskSessionParams
? taskSessionParams
: desiredDisplayId
? { sessionId: desiredDisplayId }
: null;
const sessionDisplayId = desiredDisplayId ?? (canReuseTaskSessionParams ? taskSessionDisplayId : null);
if (!sessionDisplayId && !sessionParams) return null;
return {
sessionDisplayId,
sessionParams,
};
}
function normalizeUsageTotals(usage: UsageSummary | null | undefined): UsageTotals | null {
if (!usage) return null;
return {
@ -720,9 +767,13 @@ function resolveNextSessionState(input: {
export function heartbeatService(db: Db) {
const instanceSettings = instanceSettingsService(db);
const getCurrentUserRedactionOptions = async () => ({
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
});
const runLogStore = getRunLogStore();
const secretsSvc = secretService(db);
const companySkills = companySkillService(db);
const issuesSvc = issueService(db);
const executionWorkspacesSvc = executionWorkspaceService(db);
const workspaceOperationsSvc = workspaceOperationService(db);
@ -972,6 +1023,57 @@ export function heartbeatService(db: Db) {
return runtimeForRun?.sessionId ?? null;
}
async function resolveExplicitResumeSessionOverride(
agent: typeof agents.$inferSelect,
payload: Record<string, unknown> | null,
taskKey: string | null,
) {
const resumeFromRunId = readNonEmptyString(payload?.resumeFromRunId);
if (!resumeFromRunId) return null;
const resumeRun = await db
.select({
id: heartbeatRuns.id,
contextSnapshot: heartbeatRuns.contextSnapshot,
sessionIdBefore: heartbeatRuns.sessionIdBefore,
sessionIdAfter: heartbeatRuns.sessionIdAfter,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, resumeFromRunId),
eq(heartbeatRuns.companyId, agent.companyId),
eq(heartbeatRuns.agentId, agent.id),
),
)
.then((rows) => rows[0] ?? null);
if (!resumeRun) return null;
const resumeContext = parseObject(resumeRun.contextSnapshot);
const resumeTaskKey = deriveTaskKey(resumeContext, null) ?? taskKey;
const resumeTaskSession = resumeTaskKey
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, resumeTaskKey)
: null;
const sessionCodec = getAdapterSessionCodec(agent.adapterType);
const sessionOverride = buildExplicitResumeSessionOverride({
resumeFromRunId,
resumeRunSessionIdBefore: resumeRun.sessionIdBefore,
resumeRunSessionIdAfter: resumeRun.sessionIdAfter,
taskSession: resumeTaskSession,
sessionCodec,
});
if (!sessionOverride) return null;
return {
resumeFromRunId,
taskKey: resumeTaskKey,
issueId: readNonEmptyString(resumeContext.issueId),
taskId: readNonEmptyString(resumeContext.taskId) ?? readNonEmptyString(resumeContext.issueId),
sessionDisplayId: sessionOverride.sessionDisplayId,
sessionParams: sessionOverride.sessionParams,
};
}
async function resolveWorkspaceForRun(
agent: typeof agents.$inferSelect,
context: Record<string, unknown>,
@ -1318,8 +1420,13 @@ export function heartbeatService(db: Db) {
payload?: Record<string, unknown>;
},
) {
const sanitizedMessage = event.message ? redactCurrentUserText(event.message) : event.message;
const sanitizedPayload = event.payload ? redactCurrentUserValue(event.payload) : event.payload;
const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
const sanitizedMessage = event.message
? redactCurrentUserText(event.message, currentUserRedactionOptions)
: event.message;
const sanitizedPayload = event.payload
? redactCurrentUserValue(event.payload, currentUserRedactionOptions)
: event.payload;
await db.insert(heartbeatRunEvents).values({
companyId: run.companyId,
@ -1910,9 +2017,18 @@ export function heartbeatService(db: Db) {
const resetTaskSession = shouldResetTaskSessionForWake(context);
const sessionResetReason = describeSessionResetReason(context);
const taskSessionForRun = resetTaskSession ? null : taskSession;
const previousSessionParams = normalizeSessionParams(
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null),
const explicitResumeSessionParams = normalizeSessionParams(
sessionCodec.deserialize(parseObject(context.resumeSessionParams)),
);
const explicitResumeSessionDisplayId = truncateDisplayId(
readNonEmptyString(context.resumeSessionDisplayId) ??
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(explicitResumeSessionParams) : null) ??
readNonEmptyString(explicitResumeSessionParams?.sessionId),
);
const previousSessionParams =
explicitResumeSessionParams ??
(explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ??
normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null));
const config = parseObject(agent.adapterConfig);
const executionWorkspaceMode = resolveExecutionWorkspaceMode({
projectPolicy: projectExecutionWorkspacePolicy,
@ -1939,6 +2055,11 @@ export function heartbeatService(db: Db) {
agent.companyId,
mergedConfig,
);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
const runtimeConfig = {
...resolvedConfig,
paperclipRuntimeSkills: runtimeSkillEntries,
};
const issueRef = issueContext
? {
id: issueContext.id,
@ -1966,7 +2087,7 @@ export function heartbeatService(db: Db) {
repoUrl: resolvedWorkspace.repoUrl,
repoRef: resolvedWorkspace.repoRef,
},
config: resolvedConfig,
config: runtimeConfig,
issue: issueRef,
agent: {
id: agent.id,
@ -2083,11 +2204,29 @@ export function heartbeatService(db: Db) {
cleanupReason: null,
});
}
if (issueId && persistedExecutionWorkspace && issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) {
await issuesSvc.update(issueId, {
executionWorkspaceId: persistedExecutionWorkspace.id,
...(resolvedProjectWorkspaceId ? { projectWorkspaceId: resolvedProjectWorkspaceId } : {}),
});
if (issueId && persistedExecutionWorkspace) {
const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode);
const shouldSwitchIssueToExistingWorkspace =
issueRef?.executionWorkspacePreference === "reuse_existing" ||
executionWorkspaceMode === "isolated_workspace" ||
executionWorkspaceMode === "operator_branch";
const nextIssuePatch: Record<string, unknown> = {};
if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) {
nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id;
}
if (resolvedProjectWorkspaceId && issueRef?.projectWorkspaceId !== resolvedProjectWorkspaceId) {
nextIssuePatch.projectWorkspaceId = resolvedProjectWorkspaceId;
}
if (shouldSwitchIssueToExistingWorkspace) {
nextIssuePatch.executionWorkspacePreference = "reuse_existing";
nextIssuePatch.executionWorkspaceSettings = {
...(issueExecutionWorkspaceSettings ?? {}),
mode: nextIssueWorkspaceMode,
};
}
if (Object.keys(nextIssuePatch).length > 0) {
await issuesSvc.update(issueId, nextIssuePatch);
}
}
if (persistedExecutionWorkspace) {
context.executionWorkspaceId = persistedExecutionWorkspace.id;
@ -2131,7 +2270,11 @@ export function heartbeatService(db: Db) {
repoRef: executionWorkspace.repoRef,
branchName: executionWorkspace.branchName,
worktreePath: executionWorkspace.worktreePath,
agentHome: resolveDefaultAgentWorkspaceDir(agent.id),
agentHome: await (async () => {
const home = resolveDefaultAgentWorkspaceDir(agent.id);
await fs.mkdir(home, { recursive: true });
return home;
})(),
};
context.paperclipWorkspaces = resolvedWorkspace.workspaceHints;
const runtimeServiceIntents = (() => {
@ -2152,7 +2295,8 @@ export function heartbeatService(db: Db) {
}
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
let previousSessionDisplayId = truncateDisplayId(
taskSessionForRun?.sessionDisplayId ??
explicitResumeSessionDisplayId ??
taskSessionForRun?.sessionDisplayId ??
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ??
readNonEmptyString(runtimeSessionParams?.sessionId) ??
runtimeSessionFallback,
@ -2252,8 +2396,9 @@ export function heartbeatService(db: Db) {
})
.where(eq(heartbeatRuns.id, runId));
const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
const sanitizedChunk = redactCurrentUserText(chunk);
const sanitizedChunk = redactCurrentUserText(chunk, currentUserRedactionOptions);
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
const ts = new Date().toISOString();
@ -2371,7 +2516,7 @@ export function heartbeatService(db: Db) {
runId: run.id,
agent,
runtime: runtimeForAdapter,
config: resolvedConfig,
config: runtimeConfig,
context,
onLog,
onMeta: onAdapterMeta,
@ -2503,6 +2648,7 @@ export function heartbeatService(db: Db) {
? null
: redactCurrentUserText(
adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
currentUserRedactionOptions,
),
errorCode:
outcome === "timed_out"
@ -2570,7 +2716,10 @@ export function heartbeatService(db: Db) {
}
await finalizeAgentStatus(agent.id, outcome);
} catch (err) {
const message = redactCurrentUserText(err instanceof Error ? err.message : "Unknown adapter failure");
const message = redactCurrentUserText(
err instanceof Error ? err.message : "Unknown adapter failure",
await getCurrentUserRedactionOptions(),
);
logger.error({ err, runId }, "heartbeat execution failed");
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
@ -2758,7 +2907,9 @@ export function heartbeatService(db: Db) {
payload: promotedPayload,
});
const sessionBefore = await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey);
const sessionBefore =
readNonEmptyString(promotedContextSnapshot.resumeSessionDisplayId) ??
await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey);
const now = new Date();
const newRun = await tx
.insert(heartbeatRuns)
@ -2837,10 +2988,30 @@ export function heartbeatService(db: Db) {
triggerDetail,
payload,
});
const issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload;
let issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload;
const agent = await getAgent(agentId);
if (!agent) throw notFound("Agent not found");
const explicitResumeSession = await resolveExplicitResumeSessionOverride(agent, payload, taskKey);
if (explicitResumeSession) {
enrichedContextSnapshot.resumeFromRunId = explicitResumeSession.resumeFromRunId;
enrichedContextSnapshot.resumeSessionDisplayId = explicitResumeSession.sessionDisplayId;
enrichedContextSnapshot.resumeSessionParams = explicitResumeSession.sessionParams;
if (!readNonEmptyString(enrichedContextSnapshot.issueId) && explicitResumeSession.issueId) {
enrichedContextSnapshot.issueId = explicitResumeSession.issueId;
}
if (!readNonEmptyString(enrichedContextSnapshot.taskId) && explicitResumeSession.taskId) {
enrichedContextSnapshot.taskId = explicitResumeSession.taskId;
}
if (!readNonEmptyString(enrichedContextSnapshot.taskKey) && explicitResumeSession.taskKey) {
enrichedContextSnapshot.taskKey = explicitResumeSession.taskKey;
}
issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueId;
}
const effectiveTaskKey = readNonEmptyString(enrichedContextSnapshot.taskKey) ?? taskKey;
const sessionBefore =
explicitResumeSession?.sessionDisplayId ??
await resolveSessionBeforeForWakeup(agent, effectiveTaskKey);
const writeSkippedRequest = async (skipReason: string) => {
await db.insert(agentWakeupRequests).values({
@ -2904,7 +3075,6 @@ export function heartbeatService(db: Db) {
if (issueId && !bypassIssueExecutionLock) {
const agentNameKey = normalizeAgentNameKey(agent.name);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const outcome = await db.transaction(async (tx) => {
await tx.execute(
@ -3255,8 +3425,6 @@ export function heartbeatService(db: Db) {
.returning()
.then((rows) => rows[0]);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const newRun = await db
.insert(heartbeatRuns)
.values({
@ -3608,7 +3776,7 @@ export function heartbeatService(db: Db) {
store: run.logStore,
logRef: run.logRef,
...result,
content: redactCurrentUserText(result.content),
content: redactCurrentUserText(result.content, await getCurrentUserRedactionOptions()),
};
},

View file

@ -1,5 +1,7 @@
export { companyService } from "./companies.js";
export { companySkillService } from "./company-skills.js";
export { agentService, deduplicateAgentName } from "./agents.js";
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";
export { assetService } from "./assets.js";
export { documentService, extractLegacyPlanBody } from "./documents.js";
export { projectService } from "./projects.js";
@ -10,12 +12,14 @@ export { activityService, type ActivityFilters } from "./activity.js";
export { approvalService } from "./approvals.js";
export { budgetService } from "./budgets.js";
export { secretService } from "./secrets.js";
export { routineService } from "./routines.js";
export { costService } from "./costs.js";
export { financeService } from "./finance.js";
export { heartbeatService } from "./heartbeat.js";
export { dashboardService } from "./dashboard.js";
export { sidebarBadgeService } from "./sidebar-badges.js";
export { accessService } from "./access.js";
export { boardAuthService } from "./board-auth.js";
export { instanceSettingsService } from "./instance-settings.js";
export { companyPortabilityService } from "./company-portability.js";
export { executionWorkspaceService } from "./execution-workspaces.js";

View file

@ -1,8 +1,11 @@
import type { Db } from "@paperclipai/db";
import { companies, instanceSettings } from "@paperclipai/db";
import {
instanceGeneralSettingsSchema,
type InstanceGeneralSettings,
instanceExperimentalSettingsSchema,
type InstanceExperimentalSettings,
type PatchInstanceGeneralSettings,
type InstanceSettings,
type PatchInstanceExperimentalSettings,
} from "@paperclipai/shared";
@ -10,21 +13,36 @@ import { eq } from "drizzle-orm";
const DEFAULT_SINGLETON_KEY = "default";
function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
const parsed = instanceGeneralSettingsSchema.safeParse(raw ?? {});
if (parsed.success) {
return {
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
};
}
return {
censorUsernameInLogs: false,
};
}
function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings {
const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {});
if (parsed.success) {
return {
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
};
}
return {
enableIsolatedWorkspaces: false,
autoRestartDevServerWhenIdle: false,
};
}
function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings {
return {
id: row.id,
general: normalizeGeneralSettings(row.general),
experimental: normalizeExperimentalSettings(row.experimental),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
@ -45,6 +63,7 @@ export function instanceSettingsService(db: Db) {
.insert(instanceSettings)
.values({
singletonKey: DEFAULT_SINGLETON_KEY,
general: {},
experimental: {},
createdAt: now,
updatedAt: now,
@ -63,11 +82,34 @@ export function instanceSettingsService(db: Db) {
return {
get: async (): Promise<InstanceSettings> => toInstanceSettings(await getOrCreateRow()),
getGeneral: async (): Promise<InstanceGeneralSettings> => {
const row = await getOrCreateRow();
return normalizeGeneralSettings(row.general);
},
getExperimental: async (): Promise<InstanceExperimentalSettings> => {
const row = await getOrCreateRow();
return normalizeExperimentalSettings(row.experimental);
},
updateGeneral: async (patch: PatchInstanceGeneralSettings): Promise<InstanceSettings> => {
const current = await getOrCreateRow();
const nextGeneral = normalizeGeneralSettings({
...normalizeGeneralSettings(current.general),
...patch,
});
const now = new Date();
const [updated] = await db
.update(instanceSettings)
.set({
general: { ...nextGeneral },
updatedAt: now,
})
.where(eq(instanceSettings.id, current.id))
.returning();
return toInstanceSettings(updated ?? current);
},
updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise<InstanceSettings> => {
const current = await getOrCreateRow();
const nextExperimental = normalizeExperimentalSettings({

View file

@ -0,0 +1,48 @@
import { logger } from "../middleware/logger.js";
type WakeupTriggerDetail = "manual" | "ping" | "callback" | "system";
type WakeupSource = "timer" | "assignment" | "on_demand" | "automation";
export interface IssueAssignmentWakeupDeps {
wakeup: (
agentId: string,
opts: {
source?: WakeupSource;
triggerDetail?: WakeupTriggerDetail;
reason?: string | null;
payload?: Record<string, unknown> | null;
requestedByActorType?: "user" | "agent" | "system";
requestedByActorId?: string | null;
contextSnapshot?: Record<string, unknown>;
},
) => Promise<unknown>;
}
export function queueIssueAssignmentWakeup(input: {
heartbeat: IssueAssignmentWakeupDeps;
issue: { id: string; assigneeAgentId: string | null; status: string };
reason: string;
mutation: string;
contextSource: string;
requestedByActorType?: "user" | "agent" | "system";
requestedByActorId?: string | null;
rethrowOnError?: boolean;
}) {
if (!input.issue.assigneeAgentId || input.issue.status === "backlog") return;
return input.heartbeat
.wakeup(input.issue.assigneeAgentId, {
source: "assignment",
triggerDetail: "system",
reason: input.reason,
payload: { issueId: input.issue.id, mutation: input.mutation },
requestedByActorType: input.requestedByActorType,
requestedByActorId: input.requestedByActorId ?? null,
contextSnapshot: { issueId: input.issue.id, source: input.contextSource },
})
.catch((err) => {
logger.warn({ err, issueId: input.issue.id }, "failed to wake assignee on issue assignment");
if (input.rethrowOnError) throw err;
return null;
});
}

View file

@ -1,6 +1,7 @@
import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
agents,
assets,
companies,
@ -19,7 +20,8 @@ import {
projectWorkspaces,
projects,
} from "@paperclipai/db";
import { extractProjectMentionIds } from "@paperclipai/shared";
import { extractAgentMentionIds, extractProjectMentionIds } from "@paperclipai/shared";
import { decodeHTMLStrict } from "entities";
import { conflict, notFound, unprocessable } from "../errors.js";
import {
defaultIssueExecutionWorkspaceSettingsForProject,
@ -62,12 +64,16 @@ function applyStatusSideEffects(
export interface IssueFilters {
status?: string;
assigneeAgentId?: string;
participantAgentId?: string;
assigneeUserId?: string;
touchedByUserId?: string;
unreadForUserId?: string;
projectId?: string;
parentId?: string;
labelId?: string;
originKind?: string;
originId?: string;
includeRoutineExecutions?: boolean;
q?: string;
}
@ -97,13 +103,6 @@ type IssueUserContextInput = {
updatedAt: Date | string;
};
function redactIssueComment<T extends { body: string }>(comment: T): T {
return {
...comment,
body: redactCurrentUserText(comment.body),
};
}
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
if (actorRunId) return checkoutRunId === actorRunId;
return checkoutRunId == null;
@ -138,6 +137,30 @@ function touchedByUserCondition(companyId: string, userId: string) {
`;
}
function participatedByAgentCondition(companyId: string, agentId: string) {
return sql<boolean>`
(
${issues.createdByAgentId} = ${agentId}
OR ${issues.assigneeAgentId} = ${agentId}
OR EXISTS (
SELECT 1
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND ${issueComments.authorAgentId} = ${agentId}
)
OR EXISTS (
SELECT 1
FROM ${activityLog}
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
AND ${activityLog.entityId} = ${issues.id}::text
AND ${activityLog.agentId} = ${agentId}
)
)
`;
}
function myLastCommentAtExpr(companyId: string, userId: string) {
return sql<Date | null>`
(
@ -196,38 +219,12 @@ function unreadForUserCondition(companyId: string, userId: string) {
`;
}
/** Named entities the rich-text editor may emit in issue bodies; unknown names are left unchanged. */
const WELL_KNOWN_NAMED_HTML_ENTITIES: Readonly<Record<string, string>> = {
amp: "&",
apos: "'",
gt: ">",
lt: "<",
nbsp: "\u00A0",
quot: '"',
ensp: "\u2002",
emsp: "\u2003",
thinsp: "\u2009",
};
function decodeNumericHtmlEntity(digits: string, radix: 16 | 10): string | null {
const n = Number.parseInt(digits, radix);
if (Number.isNaN(n) || n < 0 || n > 0x10ffff) return null;
try {
return String.fromCodePoint(n);
} catch {
return null;
}
}
/** Decodes HTML entities in a raw @mention capture so UI-encoded bodies still match agent names. */
/**
* Decodes HTML character references in a raw @mention capture (WHATWG HTML, strict semicolon form)
* so rich-text / UI-encoded bodies still match agent names.
*/
export function normalizeAgentMentionToken(raw: string): string {
let s = raw.replace(/&#x([0-9a-fA-F]+);/gi, (full, hex: string) => decodeNumericHtmlEntity(hex, 16) ?? full);
s = s.replace(/&#([0-9]+);/g, (full, dec: string) => decodeNumericHtmlEntity(dec, 10) ?? full);
s = s.replace(/&([a-z][a-z0-9]*);/gi, (full, name: string) => {
const decoded = WELL_KNOWN_NAMED_HTML_ENTITIES[name.toLowerCase()];
return decoded !== undefined ? decoded : full;
});
return s.trim();
return decodeHTMLStrict(raw).trim();
}
export function deriveIssueUserContext(
@ -354,6 +351,13 @@ function withActiveRuns(
export function issueService(db: Db) {
const instanceSettings = instanceSettingsService(db);
function redactIssueComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
return {
...comment,
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
};
}
async function assertAssignableAgent(companyId: string, agentId: string) {
const assignee = await db
.select({
@ -539,6 +543,9 @@ export function issueService(db: Db) {
if (filters?.assigneeAgentId) {
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
}
if (filters?.participantAgentId) {
conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId));
}
if (filters?.assigneeUserId) {
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
}
@ -550,6 +557,8 @@ export function issueService(db: Db) {
}
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
if (filters?.labelId) {
const labeledIssueIds = await db
.select({ issueId: issueLabels.issueId })
@ -568,6 +577,9 @@ export function issueService(db: Db) {
)!,
);
}
if (!filters?.includeRoutineExecutions && !filters?.originKind && !filters?.originId) {
conditions.push(ne(issues.originKind, "routine_execution"));
}
conditions.push(isNull(issues.hiddenAt));
const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
@ -649,6 +661,7 @@ export function issueService(db: Db) {
eq(issues.companyId, companyId),
isNull(issues.hiddenAt),
unreadForUserCondition(companyId, userId),
ne(issues.originKind, "routine_execution"),
];
if (status) {
const statuses = status.split(",").map((s) => s.trim()).filter(Boolean);
@ -787,6 +800,7 @@ export function issueService(db: Db) {
const values = {
...issueData,
originKind: issueData.originKind ?? "manual",
goalId: resolveIssueGoalId({
projectId: issueData.projectId,
goalId: issueData.goalId,
@ -1249,7 +1263,8 @@ export function issueService(db: Db) {
);
const comments = limit ? await query.limit(limit) : await query;
return comments.map(redactIssueComment);
const { censorUsernameInLogs } = await instanceSettings.getGeneral();
return comments.map((comment) => redactIssueComment(comment, censorUsernameInLogs));
},
getCommentCursor: async (issueId: string) => {
@ -1281,14 +1296,15 @@ export function issueService(db: Db) {
},
getComment: (commentId: string) =>
db
instanceSettings.getGeneral().then(({ censorUsernameInLogs }) =>
db
.select()
.from(issueComments)
.where(eq(issueComments.id, commentId))
.then((rows) => {
const comment = rows[0] ?? null;
return comment ? redactIssueComment(comment) : null;
}),
return comment ? redactIssueComment(comment, censorUsernameInLogs) : null;
})),
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
const issue = await db
@ -1299,7 +1315,10 @@ export function issueService(db: Db) {
if (!issue) throw notFound("Issue not found");
const redactedBody = redactCurrentUserText(body);
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
const [comment] = await db
.insert(issueComments)
.values({
@ -1317,7 +1336,7 @@ export function issueService(db: Db) {
.set({ updatedAt: new Date() })
.where(eq(issues.id, issueId));
return redactIssueComment(comment);
return redactIssueComment(comment, currentUserRedactionOptions.enabled);
},
createAttachment: async (input: {
@ -1484,10 +1503,18 @@ export function issueService(db: Db) {
const normalized = normalizeAgentMentionToken(m[1]);
if (normalized) tokens.add(normalized.toLowerCase());
}
if (tokens.size === 0) return [];
const explicitAgentMentionIds = extractAgentMentionIds(body);
if (tokens.size === 0 && explicitAgentMentionIds.length === 0) return [];
const rows = await db.select({ id: agents.id, name: agents.name })
.from(agents).where(eq(agents.companyId, companyId));
return rows.filter(a => tokens.has(a.name.toLowerCase())).map(a => a.id);
const resolved = new Set<string>(explicitAgentMentionIds);
for (const agent of rows) {
if (tokens.has(agent.name.toLowerCase())) {
resolved.add(agent.id);
}
}
return [...resolved];
},
findMentionedProjectIds: async (issueId: string) => {

File diff suppressed because it is too large Load diff

View file

@ -159,6 +159,7 @@ export function secretService(db: Db) {
getById,
getByName,
resolveSecretValue,
create: async (
companyId: string,

View file

@ -5,6 +5,7 @@ import type { WorkspaceOperation, WorkspaceOperationPhase, WorkspaceOperationSta
import { asc, desc, eq, inArray, isNull, or, and } from "drizzle-orm";
import { notFound } from "../errors.js";
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
import { instanceSettingsService } from "./instance-settings.js";
import { getWorkspaceOperationLogStore } from "./workspace-operation-log-store.js";
type WorkspaceOperationRow = typeof workspaceOperations.$inferSelect;
@ -69,6 +70,7 @@ export interface WorkspaceOperationRecorder {
}
export function workspaceOperationService(db: Db) {
const instanceSettings = instanceSettingsService(db);
const logStore = getWorkspaceOperationLogStore();
async function getById(id: string) {
@ -105,6 +107,9 @@ export function workspaceOperationService(db: Db) {
},
async recordOperation(recordInput) {
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const startedAt = new Date();
const id = randomUUID();
const handle = await logStore.begin({
@ -116,7 +121,7 @@ export function workspaceOperationService(db: Db) {
let stderrExcerpt = "";
const append = async (stream: "stdout" | "stderr" | "system", chunk: string | null | undefined) => {
if (!chunk) return;
const sanitizedChunk = redactCurrentUserText(chunk);
const sanitizedChunk = redactCurrentUserText(chunk, currentUserRedactionOptions);
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
await logStore.append(handle, {
@ -137,7 +142,10 @@ export function workspaceOperationService(db: Db) {
status: "running",
logStore: handle.store,
logRef: handle.logRef,
metadata: redactCurrentUserValue(recordInput.metadata ?? null) as Record<string, unknown> | null,
metadata: redactCurrentUserValue(
recordInput.metadata ?? null,
currentUserRedactionOptions,
) as Record<string, unknown> | null,
startedAt,
});
createdIds.push(id);
@ -162,6 +170,7 @@ export function workspaceOperationService(db: Db) {
logCompressed: finalized.compressed,
metadata: redactCurrentUserValue(
combineMetadata(recordInput.metadata, result.metadata),
currentUserRedactionOptions,
) as Record<string, unknown> | null,
finishedAt,
updatedAt: finishedAt,
@ -241,7 +250,9 @@ export function workspaceOperationService(db: Db) {
store: operation.logStore,
logRef: operation.logRef,
...result,
content: redactCurrentUserText(result.content),
content: redactCurrentUserText(result.content, {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
}),
};
},
};