mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
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:
commit
53f0988006
334 changed files with 98279 additions and 9577 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
734
server/src/services/agent-instructions.ts
Normal file
734
server/src/services/agent-instructions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
354
server/src/services/board-auth.ts
Normal file
354
server/src/services/board-auth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
172
server/src/services/company-export-readme.ts
Normal file
172
server/src/services/company-export-readme.ts
Normal 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, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** 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("");
|
||||
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
2355
server/src/services/company-skills.ts
Normal file
2355
server/src/services/company-skills.ts
Normal file
File diff suppressed because it is too large
Load diff
27
server/src/services/default-agent-instructions.ts
Normal file
27
server/src/services/default-agent-instructions.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
48
server/src/services/issue-assignment-wakeup.ts
Normal file
48
server/src/services/issue-assignment-wakeup.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
1268
server/src/services/routines.ts
Normal file
1268
server/src/services/routines.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -159,6 +159,7 @@ export function secretService(db: Db) {
|
|||
|
||||
getById,
|
||||
getByName,
|
||||
resolveSecretValue,
|
||||
|
||||
create: async (
|
||||
companyId: string,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue