Merge remote-tracking branch 'public-gh/master' into paperclip-routines
* public-gh/master: (46 commits) chore(lockfile): refresh pnpm-lock.yaml (#1377) fix: manage codex home per company by default Ensure agent home directories exist before use Handle directory entries in imported zip archives Fix portability import and org chart test blockers Fix PR verify failures after merge fix: address greptile follow-up feedback Address remaining Greptile portability feedback docs: clarify quickstart npx usage Add guarded dev restart handling Fix PAP-576 settings toggles and transcript default Add username log censor setting fix: use standard toggle component for permission controls fix: add missing setPrincipalPermission mock in portability tests fix: use fixed 1280x640 dimensions for org chart export image Adjust default CEO onboarding task copy fix: link Agent Company to agentcompanies.io in export README fix: strip agents and projects sections from COMPANY.md export body fix: default company export page to README.md instead of first file Add default agent instructions bundle ... # Conflicts: # packages/adapters/pi-local/src/server/execute.ts # packages/db/src/migrations/meta/0039_snapshot.json # packages/db/src/migrations/meta/_journal.json # server/src/__tests__/agent-permissions-routes.test.ts # server/src/__tests__/agent-skills-routes.test.ts # server/src/services/company-portability.ts # skills/paperclip/references/company-skills.md # ui/src/api/agents.ts
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,6 +55,19 @@ 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.
|
||||
*/
|
||||
@@ -74,17 +87,16 @@ export function generateReadme(
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Org chart as Mermaid diagram
|
||||
const mermaid = generateOrgChartMermaid(manifest.agents);
|
||||
if (mermaid) {
|
||||
lines.push(mermaid);
|
||||
// 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://paperclip.ing) package.");
|
||||
lines.push("> This is an [Agent Company](https://agentcompanies.io) package from [Paperclip](https://paperclip.ing)");
|
||||
lines.push("");
|
||||
|
||||
const counts: Array<[string, number]> = [];
|
||||
@@ -127,6 +139,20 @@ export function generateReadme(
|
||||
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("");
|
||||
|
||||
@@ -42,16 +42,63 @@ import { agentService } from "./agents.js";
|
||||
import { agentInstructionsService } from "./agent-instructions.js";
|
||||
import { assetService } from "./assets.js";
|
||||
import { generateReadme } from "./company-export-readme.js";
|
||||
import { renderOrgChartPng, type OrgNode } from "../routes/org-chart-svg.js";
|
||||
import { companySkillService } from "./company-skills.js";
|
||||
import { companyService } from "./companies.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { projectService } from "./projects.js";
|
||||
|
||||
/** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */
|
||||
function buildOrgTreeFromManifest(agents: CompanyPortabilityManifest["agents"]): OrgNode[] {
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
ceo: "Chief Executive", cto: "Technology", cmo: "Marketing",
|
||||
cfo: "Finance", coo: "Operations", vp: "VP", manager: "Manager",
|
||||
engineer: "Engineer", agent: "Agent",
|
||||
};
|
||||
const bySlug = new Map(agents.map((a) => [a.slug, a]));
|
||||
const childrenOf = new Map<string | null, typeof agents>();
|
||||
for (const a of agents) {
|
||||
const parent = a.reportsToSlug ?? null;
|
||||
const list = childrenOf.get(parent) ?? [];
|
||||
list.push(a);
|
||||
childrenOf.set(parent, list);
|
||||
}
|
||||
const build = (parentSlug: string | null): OrgNode[] => {
|
||||
const members = childrenOf.get(parentSlug) ?? [];
|
||||
return members.map((m) => ({
|
||||
id: m.slug,
|
||||
name: m.name,
|
||||
role: ROLE_LABELS[m.role] ?? m.role,
|
||||
status: "active",
|
||||
reports: build(m.slug),
|
||||
}));
|
||||
};
|
||||
// Find roots: agents whose reportsToSlug is null or points to a non-existent slug
|
||||
const roots = agents.filter((a) => !a.reportsToSlug || !bySlug.has(a.reportsToSlug));
|
||||
const rootSlugs = new Set(roots.map((r) => r.slug));
|
||||
// Start from null parent, but also include orphans
|
||||
const tree = build(null);
|
||||
for (const root of roots) {
|
||||
if (root.reportsToSlug && !bySlug.has(root.reportsToSlug)) {
|
||||
// Orphan root (parent slug doesn't exist)
|
||||
tree.push({
|
||||
id: root.slug,
|
||||
name: root.name,
|
||||
role: ROLE_LABELS[root.role] ?? root.role,
|
||||
status: "active",
|
||||
reports: build(root.slug),
|
||||
});
|
||||
}
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
const DEFAULT_INCLUDE: CompanyPortabilityInclude = {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
skills: false,
|
||||
};
|
||||
|
||||
const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename";
|
||||
@@ -119,7 +166,7 @@ function deriveManifestSkillKey(
|
||||
const sourceKind = asString(metadata?.sourceKind);
|
||||
const owner = normalizeSkillSlug(asString(metadata?.owner));
|
||||
const repo = normalizeSkillSlug(asString(metadata?.repo));
|
||||
if ((sourceType === "github" || sourceKind === "github") && owner && repo) {
|
||||
if ((sourceType === "github" || sourceType === "skills_sh" || sourceKind === "github" || sourceKind === "skills_sh") && owner && repo) {
|
||||
return `${owner}/${repo}/${slug}`;
|
||||
}
|
||||
if (sourceKind === "paperclip_bundled") {
|
||||
@@ -246,10 +293,10 @@ function deriveSkillExportDirCandidates(
|
||||
pushSuffix("paperclip");
|
||||
}
|
||||
|
||||
if (skill.sourceType === "github") {
|
||||
if (skill.sourceType === "github" || skill.sourceType === "skills_sh") {
|
||||
pushSuffix(asString(metadata?.repo));
|
||||
pushSuffix(asString(metadata?.owner));
|
||||
pushSuffix("github");
|
||||
pushSuffix(skill.sourceType === "skills_sh" ? "skills_sh" : "github");
|
||||
} else if (skill.sourceType === "url") {
|
||||
try {
|
||||
pushSuffix(skill.sourceLocator ? new URL(skill.sourceLocator).host : null);
|
||||
@@ -304,10 +351,12 @@ function isSensitiveEnvKey(key: string) {
|
||||
normalized === "token" ||
|
||||
normalized.endsWith("_token") ||
|
||||
normalized.endsWith("-token") ||
|
||||
normalized.includes("apikey") ||
|
||||
normalized.includes("api_key") ||
|
||||
normalized.includes("api-key") ||
|
||||
normalized.includes("access_token") ||
|
||||
normalized.includes("access-token") ||
|
||||
normalized.includes("auth") ||
|
||||
normalized.includes("auth_token") ||
|
||||
normalized.includes("auth-token") ||
|
||||
normalized.includes("authorization") ||
|
||||
@@ -317,6 +366,7 @@ function isSensitiveEnvKey(key: string) {
|
||||
normalized.includes("password") ||
|
||||
normalized.includes("credential") ||
|
||||
normalized.includes("jwt") ||
|
||||
normalized.includes("privatekey") ||
|
||||
normalized.includes("private_key") ||
|
||||
normalized.includes("private-key") ||
|
||||
normalized.includes("cookie") ||
|
||||
@@ -515,6 +565,7 @@ function normalizeInclude(input?: Partial<CompanyPortabilityInclude>): CompanyPo
|
||||
agents: input?.agents ?? DEFAULT_INCLUDE.agents,
|
||||
projects: input?.projects ?? DEFAULT_INCLUDE.projects,
|
||||
issues: input?.issues ?? DEFAULT_INCLUDE.issues,
|
||||
skills: input?.skills ?? DEFAULT_INCLUDE.skills,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -826,6 +877,7 @@ function extractPortableEnvInputs(
|
||||
|
||||
if (isPlainRecord(binding) && binding.type === "plain") {
|
||||
const defaultValue = asString(binding.value);
|
||||
const isSensitive = isSensitiveEnvKey(key);
|
||||
const portability = defaultValue && isAbsoluteCommand(defaultValue)
|
||||
? "system_dependent"
|
||||
: "portable";
|
||||
@@ -836,9 +888,9 @@ function extractPortableEnvInputs(
|
||||
key,
|
||||
description: `Optional default for ${key} on agent ${agentSlug}`,
|
||||
agentSlug,
|
||||
kind: "plain",
|
||||
kind: isSensitive ? "secret" : "plain",
|
||||
requirement: "optional",
|
||||
defaultValue: defaultValue ?? "",
|
||||
defaultValue: isSensitive ? "" : defaultValue ?? "",
|
||||
portability,
|
||||
});
|
||||
continue;
|
||||
@@ -1147,6 +1199,7 @@ function applySelectedFilesToSource(source: ResolvedSource, selectedFiles?: stri
|
||||
agents: filtered.manifest.agents.length > 0,
|
||||
projects: filtered.manifest.projects.length > 0,
|
||||
issues: filtered.manifest.issues.length > 0,
|
||||
skills: filtered.manifest.skills.length > 0,
|
||||
};
|
||||
|
||||
return filtered;
|
||||
@@ -1178,7 +1231,7 @@ async function buildSkillSourceEntry(skill: CompanySkill) {
|
||||
};
|
||||
}
|
||||
|
||||
if (skill.sourceType === "github") {
|
||||
if (skill.sourceType === "github" || skill.sourceType === "skills_sh") {
|
||||
const owner = asString(metadata?.owner);
|
||||
const repo = asString(metadata?.repo);
|
||||
const repoSkillDir = asString(metadata?.repoSkillDir);
|
||||
@@ -1207,7 +1260,7 @@ function shouldReferenceSkillOnExport(skill: CompanySkill, expandReferencedSkill
|
||||
if (expandReferencedSkills) return false;
|
||||
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
||||
if (asString(metadata?.sourceKind) === "paperclip_bundled") return true;
|
||||
return skill.sourceType === "github" || skill.sourceType === "url";
|
||||
return skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url";
|
||||
}
|
||||
|
||||
async function buildReferencedSkillMarkdown(skill: CompanySkill) {
|
||||
@@ -1254,17 +1307,6 @@ async function withSkillSourceMetadata(skill: CompanySkill, markdown: string) {
|
||||
return buildMarkdown(frontmatter, parsed.body);
|
||||
}
|
||||
|
||||
function renderCompanyAgentsSection(agentSummaries: Array<{ slug: string; name: string }>) {
|
||||
const lines = ["# Agents", ""];
|
||||
if (agentSummaries.length === 0) {
|
||||
lines.push("- _none_");
|
||||
return lines.join("\n");
|
||||
}
|
||||
for (const agent of agentSummaries) {
|
||||
lines.push(`- ${agent.slug} - ${agent.name}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function parseYamlScalar(rawValue: string): unknown {
|
||||
const trimmed = rawValue.trim();
|
||||
@@ -1610,6 +1652,7 @@ function buildManifestFromPackageFiles(
|
||||
agents: true,
|
||||
projects: projectPaths.length > 0,
|
||||
issues: taskPaths.length > 0,
|
||||
skills: skillPaths.length > 0,
|
||||
},
|
||||
company: {
|
||||
path: resolvedCompanyPath,
|
||||
@@ -2005,6 +2048,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
(input.issues && input.issues.length > 0) || (input.projectIssues && input.projectIssues.length > 0)
|
||||
? true
|
||||
: input.include?.issues,
|
||||
skills: input.skills && input.skills.length > 0 ? true : input.include?.skills,
|
||||
});
|
||||
const company = await companies.getById(companyId);
|
||||
if (!company) throw notFound("Company not found");
|
||||
@@ -2017,7 +2061,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
|
||||
const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : [];
|
||||
const liveAgentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
|
||||
const companySkillRows = await companySkills.listFull(companyId);
|
||||
const companySkillRows = include.skills || include.agents ? await companySkills.listFull(companyId) : [];
|
||||
if (include.agents) {
|
||||
const skipped = allAgentRows.length - liveAgentRows.length;
|
||||
if (skipped > 0) {
|
||||
@@ -2159,19 +2203,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
}
|
||||
|
||||
const companyPath = "COMPANY.md";
|
||||
const companyBodySections: string[] = [];
|
||||
if (include.agents) {
|
||||
const companyAgentSummaries = agentRows.map((agent) => ({
|
||||
slug: idToSlug.get(agent.id) ?? "agent",
|
||||
name: agent.name,
|
||||
}));
|
||||
companyBodySections.push(renderCompanyAgentsSection(companyAgentSummaries));
|
||||
}
|
||||
if (selectedProjectRows.length > 0) {
|
||||
companyBodySections.push(
|
||||
["# Projects", "", ...selectedProjectRows.map((project) => `- ${projectSlugById.get(project.id) ?? project.id} - ${project.name}`)].join("\n"),
|
||||
);
|
||||
}
|
||||
files[companyPath] = buildMarkdown(
|
||||
{
|
||||
name: company.name,
|
||||
@@ -2179,7 +2210,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
schema: "agentcompanies/v1",
|
||||
slug: rootPath,
|
||||
},
|
||||
companyBodySections.join("\n\n").trim(),
|
||||
"",
|
||||
);
|
||||
|
||||
if (include.company && company.logoAssetId) {
|
||||
@@ -2418,10 +2449,22 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
agents: resolved.manifest.agents.length > 0,
|
||||
projects: resolved.manifest.projects.length > 0,
|
||||
issues: resolved.manifest.issues.length > 0,
|
||||
skills: resolved.manifest.skills.length > 0,
|
||||
};
|
||||
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
|
||||
resolved.warnings.unshift(...warnings);
|
||||
|
||||
// Generate org chart PNG from manifest agents
|
||||
if (resolved.manifest.agents.length > 0) {
|
||||
try {
|
||||
const orgNodes = buildOrgTreeFromManifest(resolved.manifest.agents);
|
||||
const pngBuffer = await renderOrgChartPng(orgNodes);
|
||||
finalFiles["images/org-chart.png"] = bufferToPortableBinaryFile(pngBuffer, "image/png");
|
||||
} catch {
|
||||
// Non-fatal: export still works without the org chart image
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.selectedFiles || input.selectedFiles.some((entry) => normalizePortablePath(entry) === "README.md")) {
|
||||
finalFiles["README.md"] = generateReadme(resolved.manifest, {
|
||||
companyName: company.name,
|
||||
@@ -2440,6 +2483,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
agents: resolved.manifest.agents.length > 0,
|
||||
projects: resolved.manifest.projects.length > 0,
|
||||
issues: resolved.manifest.issues.length > 0,
|
||||
skills: resolved.manifest.skills.length > 0,
|
||||
};
|
||||
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
|
||||
resolved.warnings.unshift(...warnings);
|
||||
@@ -2502,6 +2546,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
agents: requestedInclude.agents && manifest.agents.length > 0,
|
||||
projects: requestedInclude.projects && manifest.projects.length > 0,
|
||||
issues: requestedInclude.issues && manifest.issues.length > 0,
|
||||
skills: requestedInclude.skills && manifest.skills.length > 0,
|
||||
};
|
||||
const collisionStrategy = input.collisionStrategy ?? DEFAULT_COLLISION_STRATEGY;
|
||||
if (mode === "agent_safe" && collisionStrategy === "replace") {
|
||||
@@ -2962,9 +3007,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
existingProjectSlugToId.set(existing.urlKey, existing.id);
|
||||
}
|
||||
|
||||
const importedSkills = await companySkills.importPackageFiles(targetCompany.id, pickTextFiles(plan.source.files), {
|
||||
onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy),
|
||||
});
|
||||
const importedSkills = include.skills || include.agents
|
||||
? await companySkills.importPackageFiles(targetCompany.id, pickTextFiles(plan.source.files), {
|
||||
onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy),
|
||||
})
|
||||
: [];
|
||||
const desiredSkillRefMap = new Map<string, string>();
|
||||
for (const importedSkill of importedSkills) {
|
||||
desiredSkillRefMap.set(importedSkill.originalKey, importedSkill.skill.key);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companySkills } from "@paperclipai/db";
|
||||
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
||||
import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
|
||||
import type {
|
||||
CompanySkill,
|
||||
@@ -66,6 +66,7 @@ export type ImportPackageSkillResult = {
|
||||
type ParsedSkillImportSource = {
|
||||
resolvedSource: string;
|
||||
requestedSkillSlug: string | null;
|
||||
originalSkillsShUrl: string | null;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
@@ -251,7 +252,7 @@ function deriveCanonicalSkillKey(
|
||||
|
||||
const owner = normalizeSkillSlug(asString(metadata?.owner));
|
||||
const repo = normalizeSkillSlug(asString(metadata?.repo));
|
||||
if ((input.sourceType === "github" || sourceKind === "github") && owner && repo) {
|
||||
if ((input.sourceType === "github" || input.sourceType === "skills_sh" || sourceKind === "github" || sourceKind === "skills_sh") && owner && repo) {
|
||||
return `${owner}/${repo}/${slug}`;
|
||||
}
|
||||
|
||||
@@ -376,6 +377,28 @@ function parseYamlBlock(
|
||||
index = nested.nextIndex;
|
||||
continue;
|
||||
}
|
||||
const inlineObjectSeparator = remainder.indexOf(":");
|
||||
if (
|
||||
inlineObjectSeparator > 0 &&
|
||||
!remainder.startsWith("\"") &&
|
||||
!remainder.startsWith("{") &&
|
||||
!remainder.startsWith("[")
|
||||
) {
|
||||
const key = remainder.slice(0, inlineObjectSeparator).trim();
|
||||
const rawValue = remainder.slice(inlineObjectSeparator + 1).trim();
|
||||
const nextObject: Record<string, unknown> = {
|
||||
[key]: parseYamlScalar(rawValue),
|
||||
};
|
||||
if (index < lines.length && lines[index]!.indent > indentLevel) {
|
||||
const nested = parseYamlBlock(lines, index, indentLevel + 2);
|
||||
if (isPlainRecord(nested.value)) {
|
||||
Object.assign(nextObject, nested.value);
|
||||
}
|
||||
index = nested.nextIndex;
|
||||
}
|
||||
values.push(nextObject);
|
||||
continue;
|
||||
}
|
||||
values.push(parseYamlScalar(remainder));
|
||||
}
|
||||
return { value: values, nextIndex: index };
|
||||
@@ -561,11 +584,13 @@ export function parseSkillImportSourceInput(rawInput: string): ParsedSkillImport
|
||||
throw unprocessable("Skill source is required.");
|
||||
}
|
||||
|
||||
// Key-style imports (org/repo/skill) originate from the skills.sh registry
|
||||
if (!/^https?:\/\//i.test(normalizedSource) && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalizedSource)) {
|
||||
const [owner, repo, skillSlugRaw] = normalizedSource.split("/");
|
||||
return {
|
||||
resolvedSource: `https://github.com/${owner}/${repo}`,
|
||||
requestedSkillSlug: normalizeSkillSlug(skillSlugRaw),
|
||||
originalSkillsShUrl: `https://skills.sh/${owner}/${repo}/${skillSlugRaw}`,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -574,6 +599,19 @@ export function parseSkillImportSourceInput(rawInput: string): ParsedSkillImport
|
||||
return {
|
||||
resolvedSource: `https://github.com/${normalizedSource}`,
|
||||
requestedSkillSlug,
|
||||
originalSkillsShUrl: null,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
// Detect skills.sh URLs and resolve to GitHub: https://skills.sh/org/repo/skill → org/repo/skill key
|
||||
const skillsShMatch = normalizedSource.match(/^https?:\/\/(?:www\.)?skills\.sh\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\/([A-Za-z0-9_.-]+))?(?:[?#].*)?$/i);
|
||||
if (skillsShMatch) {
|
||||
const [, owner, repo, skillSlugRaw] = skillsShMatch;
|
||||
return {
|
||||
resolvedSource: `https://github.com/${owner}/${repo}`,
|
||||
requestedSkillSlug: skillSlugRaw ? normalizeSkillSlug(skillSlugRaw) : requestedSkillSlug,
|
||||
originalSkillsShUrl: normalizedSource,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -581,6 +619,7 @@ export function parseSkillImportSourceInput(rawInput: string): ParsedSkillImport
|
||||
return {
|
||||
resolvedSource: normalizedSource,
|
||||
requestedSkillSlug,
|
||||
originalSkillsShUrl: null,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -787,12 +826,11 @@ export async function readLocalSkillImportFromDirectory(
|
||||
const markdown = await fs.readFile(skillFilePath, "utf8");
|
||||
const parsed = parseFrontmatterMarkdown(markdown);
|
||||
const slug = deriveImportedSkillSlug(parsed.frontmatter, path.basename(resolvedSkillDir));
|
||||
const skillKey = readCanonicalSkillKey(
|
||||
parsed.frontmatter,
|
||||
isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null,
|
||||
);
|
||||
const parsedMetadata = isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null;
|
||||
const skillKey = readCanonicalSkillKey(parsed.frontmatter, parsedMetadata);
|
||||
const metadata = {
|
||||
...(skillKey ? { skillKey } : {}),
|
||||
...(parsedMetadata ?? {}),
|
||||
sourceKind: "local_path",
|
||||
...(options?.metadata ?? {}),
|
||||
};
|
||||
@@ -860,12 +898,11 @@ async function readLocalSkillImports(companyId: string, sourcePath: string): Pro
|
||||
const markdown = await fs.readFile(resolvedPath, "utf8");
|
||||
const parsed = parseFrontmatterMarkdown(markdown);
|
||||
const slug = deriveImportedSkillSlug(parsed.frontmatter, path.basename(path.dirname(resolvedPath)));
|
||||
const skillKey = readCanonicalSkillKey(
|
||||
parsed.frontmatter,
|
||||
isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null,
|
||||
);
|
||||
const parsedMetadata = isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null;
|
||||
const skillKey = readCanonicalSkillKey(parsed.frontmatter, parsedMetadata);
|
||||
const metadata = {
|
||||
...(skillKey ? { skillKey } : {}),
|
||||
...(parsedMetadata ?? {}),
|
||||
sourceKind: "local_path",
|
||||
};
|
||||
const inventory: CompanySkillFileInventoryEntry[] = [
|
||||
@@ -1281,6 +1318,18 @@ function deriveSkillSourceInfo(skill: CompanySkill): {
|
||||
};
|
||||
}
|
||||
|
||||
if (skill.sourceType === "skills_sh") {
|
||||
const owner = asString(metadata.owner) ?? null;
|
||||
const repo = asString(metadata.repo) ?? null;
|
||||
return {
|
||||
editable: false,
|
||||
editableReason: "Skills.sh-managed skills are read-only.",
|
||||
sourceLabel: skill.sourceLocator ?? (owner && repo ? `${owner}/${repo}` : null),
|
||||
sourceBadge: "skills_sh",
|
||||
sourcePath: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (skill.sourceType === "github") {
|
||||
const owner = asString(metadata.owner) ?? null;
|
||||
const repo = asString(metadata.repo) ?? null;
|
||||
@@ -1532,7 +1581,7 @@ export function companySkillService(db: Db) {
|
||||
const skill = await getById(skillId);
|
||||
if (!skill || skill.companyId !== companyId) return null;
|
||||
|
||||
if (skill.sourceType !== "github") {
|
||||
if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") {
|
||||
return {
|
||||
supported: false,
|
||||
reason: "Only GitHub-managed skills support update checks.",
|
||||
@@ -1592,7 +1641,7 @@ export function companySkillService(db: Db) {
|
||||
} else {
|
||||
throw notFound("Skill file not found");
|
||||
}
|
||||
} else if (skill.sourceType === "github") {
|
||||
} else if (skill.sourceType === "github" || skill.sourceType === "skills_sh") {
|
||||
const metadata = getSkillMeta(skill);
|
||||
const owner = asString(metadata.owner);
|
||||
const repo = asString(metadata.repo);
|
||||
@@ -2191,10 +2240,63 @@ export function companySkillService(db: Db) {
|
||||
: "No skills were found in the provided source.",
|
||||
);
|
||||
}
|
||||
// Override sourceType/sourceLocator for skills imported via skills.sh
|
||||
if (parsed.originalSkillsShUrl) {
|
||||
for (const skill of filteredSkills) {
|
||||
skill.sourceType = "skills_sh";
|
||||
skill.sourceLocator = parsed.originalSkillsShUrl;
|
||||
if (skill.metadata) {
|
||||
(skill.metadata as Record<string, unknown>).sourceKind = "skills_sh";
|
||||
}
|
||||
skill.key = deriveCanonicalSkillKey(companyId, skill);
|
||||
}
|
||||
}
|
||||
const imported = await upsertImportedSkills(companyId, filteredSkills);
|
||||
return { imported, warnings };
|
||||
}
|
||||
|
||||
async function deleteSkill(companyId: string, skillId: string): Promise<CompanySkill | null> {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(companySkills)
|
||||
.where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
|
||||
const skill = toCompanySkill(row);
|
||||
|
||||
// Remove from any agent desiredSkills that reference this skill
|
||||
const agentRows = await agents.list(companyId);
|
||||
const allSkills = await listFull(companyId);
|
||||
for (const agent of agentRows) {
|
||||
const config = agent.adapterConfig as Record<string, unknown>;
|
||||
const preference = readPaperclipSkillSyncPreference(config);
|
||||
const referencesSkill = preference.desiredSkills.some((ref) => {
|
||||
const resolved = resolveSkillReference(allSkills, ref);
|
||||
return resolved.skill?.id === skillId;
|
||||
});
|
||||
if (referencesSkill) {
|
||||
const filtered = preference.desiredSkills.filter((ref) => {
|
||||
const resolved = resolveSkillReference(allSkills, ref);
|
||||
return resolved.skill?.id !== skillId;
|
||||
});
|
||||
await agents.update(agent.id, {
|
||||
adapterConfig: writePaperclipSkillSyncPreference(config, filtered),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delete DB row
|
||||
await db
|
||||
.delete(companySkills)
|
||||
.where(eq(companySkills.id, skillId));
|
||||
|
||||
// Clean up materialized runtime files
|
||||
await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true });
|
||||
|
||||
return skill;
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
listFull,
|
||||
@@ -2209,6 +2311,7 @@ export function companySkillService(db: Db) {
|
||||
readFile,
|
||||
updateFile,
|
||||
createLocalSkill,
|
||||
deleteSkill,
|
||||
importFromSource,
|
||||
scanProjectWorkspaces,
|
||||
importPackageFiles,
|
||||
|
||||
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";
|
||||
}
|
||||
@@ -721,6 +721,9 @@ 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);
|
||||
@@ -1320,8 +1323,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,
|
||||
@@ -2138,7 +2146,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 = (() => {
|
||||
@@ -2259,8 +2271,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();
|
||||
@@ -2510,6 +2523,7 @@ export function heartbeatService(db: Db) {
|
||||
? null
|
||||
: redactCurrentUserText(
|
||||
adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
|
||||
currentUserRedactionOptions,
|
||||
),
|
||||
errorCode:
|
||||
outcome === "timed_out"
|
||||
@@ -2577,7 +2591,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;
|
||||
@@ -3615,7 +3632,7 @@ export function heartbeatService(db: Db) {
|
||||
store: run.logStore,
|
||||
logRef: run.logRef,
|
||||
...result,
|
||||
content: redactCurrentUserText(result.content),
|
||||
content: redactCurrentUserText(result.content, await getCurrentUserRedactionOptions()),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -100,13 +100,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;
|
||||
@@ -323,6 +316,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({
|
||||
@@ -1225,7 +1225,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) => {
|
||||
@@ -1257,14 +1258,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
|
||||
@@ -1275,7 +1277,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({
|
||||
@@ -1293,7 +1298,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: {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user