forked from farhoodlabs/paperclip
Merge upstream/master into dev (76 commits)
Resolved 5 conflicts: - .github/workflows/docker.yml, release.yml: kept fork stubs (CI handled by build-prod/build-dev) - server/src/routes/secrets.ts: kept fork's /usages route alongside upstream's /usage, /access-events - server/src/services/secrets.ts: kept fork's usages() function and in-use deletion guard, layered before upstream's soft-delete + provider cleanup in remove() - ui/src/api/secrets.ts: kept fork's usages() method alongside upstream's vault methods Typechecks pass on @paperclipai/shared, @paperclipai/server, @paperclipai/ui.
This commit is contained in:
@@ -16,6 +16,7 @@ import type {
|
||||
CompanyPortabilityImportResult,
|
||||
CompanyPortabilityInclude,
|
||||
CompanyPortabilityManifest,
|
||||
CompanyPortabilityIssueCommentManifestEntry,
|
||||
CompanyPortabilityPreview,
|
||||
CompanyPortabilityPreviewAgentPlan,
|
||||
CompanyPortabilityPreviewResult,
|
||||
@@ -44,13 +45,16 @@ import {
|
||||
ROUTINE_TRIGGER_SIGNING_MODES,
|
||||
deriveProjectUrlKey,
|
||||
envConfigSchema,
|
||||
issueCommentAuthorTypeSchema,
|
||||
issueCommentMetadataSchema,
|
||||
issueCommentPresentationSchema,
|
||||
normalizeAgentUrlKey,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
readPaperclipSkillSyncPreference,
|
||||
writePaperclipSkillSyncPreference,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
|
||||
import { requireOpenCodeModelId } from "@paperclipai/adapter-opencode-local/server";
|
||||
import { findServerAdapter } from "../adapters/index.js";
|
||||
import { forbidden, HttpError, notFound, unprocessable } from "../errors.js";
|
||||
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
|
||||
@@ -674,6 +678,96 @@ function asInteger(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
function hasOwn(record: Record<string, unknown>, key: string) {
|
||||
return Object.prototype.hasOwnProperty.call(record, key);
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const entries = value.filter((entry): entry is string => typeof entry === "string");
|
||||
return entries.length === value.length ? entries : null;
|
||||
}
|
||||
|
||||
function derivePortableCommentAuthorType(value: Record<string, unknown>) {
|
||||
const explicit = issueCommentAuthorTypeSchema.safeParse(value.authorType);
|
||||
if (explicit.success) return explicit.data;
|
||||
return asString(value.authorAgentSlug) ? "agent" : asString(value.authorUserId) ? "user" : "system";
|
||||
}
|
||||
|
||||
function readPortableIssueComments(
|
||||
value: unknown,
|
||||
warnings: string[],
|
||||
sourceLabel: string,
|
||||
): CompanyPortabilityIssueCommentManifestEntry[] {
|
||||
if (value === undefined || value === null) return [];
|
||||
if (!Array.isArray(value)) {
|
||||
warnings.push(`${sourceLabel} comments were ignored because they are not an array.`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const comments: CompanyPortabilityIssueCommentManifestEntry[] = [];
|
||||
for (const [index, entry] of value.entries()) {
|
||||
if (!isPlainRecord(entry)) {
|
||||
warnings.push(`${sourceLabel} comment ${index + 1} was ignored because it is not an object.`);
|
||||
continue;
|
||||
}
|
||||
const body = asString(entry.body);
|
||||
if (!body) {
|
||||
warnings.push(`${sourceLabel} comment ${index + 1} was ignored because it has no body.`);
|
||||
continue;
|
||||
}
|
||||
const presentation = entry.presentation == null ? null : issueCommentPresentationSchema.safeParse(entry.presentation);
|
||||
if (presentation && !presentation.success) {
|
||||
warnings.push(`${sourceLabel} comment ${index + 1} has invalid presentation metadata and was ignored.`);
|
||||
continue;
|
||||
}
|
||||
const metadata = entry.metadata == null ? null : issueCommentMetadataSchema.safeParse(entry.metadata);
|
||||
if (metadata && !metadata.success) {
|
||||
warnings.push(`${sourceLabel} comment ${index + 1} has invalid hidden metadata and was ignored.`);
|
||||
continue;
|
||||
}
|
||||
const createdAt = asString(entry.createdAt);
|
||||
comments.push({
|
||||
body,
|
||||
authorType: derivePortableCommentAuthorType(entry),
|
||||
authorAgentSlug: asString(entry.authorAgentSlug),
|
||||
authorUserId: asString(entry.authorUserId),
|
||||
presentation: presentation ? presentation.data : null,
|
||||
metadata: metadata ? metadata.data : null,
|
||||
createdAt: createdAt && Number.isNaN(Date.parse(createdAt)) ? null : createdAt,
|
||||
});
|
||||
}
|
||||
return comments;
|
||||
}
|
||||
|
||||
function appendCodexImportArg(adapterConfig: Record<string, unknown>, arg: string) {
|
||||
const extraArgs = readStringArray(adapterConfig.extraArgs);
|
||||
if (extraArgs) {
|
||||
if (!extraArgs.includes(arg)) adapterConfig.extraArgs = [...extraArgs, arg];
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyArgs = readStringArray(adapterConfig.args);
|
||||
if (legacyArgs && legacyArgs.length > 0) {
|
||||
if (!legacyArgs.includes(arg)) adapterConfig.args = [...legacyArgs, arg];
|
||||
return;
|
||||
}
|
||||
|
||||
if (legacyArgs?.includes(arg)) return;
|
||||
adapterConfig.extraArgs = [arg];
|
||||
}
|
||||
|
||||
function applyImportAdapterRunDefaults(
|
||||
adapterType: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
) {
|
||||
const next = { ...adapterConfig };
|
||||
if (adapterType === "codex_local") {
|
||||
appendCodexImportArg(next, "--skip-git-repo-check");
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeRoutineTriggerExtension(value: unknown): CompanyPortabilityIssueRoutineTriggerManifestEntry | null {
|
||||
if (!isPlainRecord(value)) return null;
|
||||
const kind = asString(value.kind);
|
||||
@@ -2746,6 +2840,7 @@ function buildManifestFromPackageFiles(
|
||||
assigneeAdapterOverrides: isPlainRecord(extension.assigneeAdapterOverrides)
|
||||
? extension.assigneeAdapterOverrides
|
||||
: null,
|
||||
comments: readPortableIssueComments(extension.comments, warnings, `Task ${slug}`),
|
||||
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
|
||||
});
|
||||
if (frontmatter.kind && frontmatter.kind !== "task") {
|
||||
@@ -2842,20 +2937,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
}
|
||||
|
||||
async function assertImportAdapterConfigConstraints(
|
||||
companyId: string,
|
||||
adapterType: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
) {
|
||||
if (adapterType !== "opencode_local") return;
|
||||
const { config: runtimeConfig } = await secrets.resolveAdapterConfigForRuntime(companyId, adapterConfig);
|
||||
const runtimeEnv = isPlainRecord(runtimeConfig.env) ? runtimeConfig.env : {};
|
||||
try {
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model: runtimeConfig.model,
|
||||
command: runtimeConfig.command,
|
||||
cwd: runtimeConfig.cwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
requireOpenCodeModelId(adapterConfig.model);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);
|
||||
@@ -2873,7 +2960,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) {
|
||||
throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`);
|
||||
}
|
||||
const nextAdapterConfig = writePaperclipSkillSyncPreference({ ...adapterConfig }, desiredSkills);
|
||||
const nextAdapterConfig = writePaperclipSkillSyncPreference(
|
||||
applyImportAdapterRunDefaults(effectiveAdapterType, adapterConfig),
|
||||
desiredSkills,
|
||||
);
|
||||
delete nextAdapterConfig.promptTemplate;
|
||||
delete nextAdapterConfig.bootstrapPromptTemplate;
|
||||
delete nextAdapterConfig.instructionsFilePath;
|
||||
@@ -2885,7 +2975,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
nextAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertImportAdapterConfigConstraints(companyId, effectiveAdapterType, normalizedAdapterConfig);
|
||||
await assertImportAdapterConfigConstraints(effectiveAdapterType, normalizedAdapterConfig);
|
||||
return {
|
||||
adapterType: effectiveAdapterType,
|
||||
adapterConfig: normalizedAdapterConfig,
|
||||
@@ -3455,6 +3545,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
});
|
||||
}
|
||||
}
|
||||
const comments = await issuesSvc.listComments(issue.id, { order: "asc" });
|
||||
files[taskPath] = buildMarkdown(
|
||||
{
|
||||
name: issue.title,
|
||||
@@ -3472,6 +3563,20 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
projectWorkspaceKey: projectWorkspaceKey ?? undefined,
|
||||
executionWorkspaceSettings: issue.executionWorkspaceSettings ?? undefined,
|
||||
assigneeAdapterOverrides: issue.assigneeAdapterOverrides ?? undefined,
|
||||
comments: comments.length > 0
|
||||
? comments.map((comment) => ({
|
||||
body: comment.body,
|
||||
authorType: comment.authorType,
|
||||
authorAgentSlug: comment.authorAgentId ? (idToSlug.get(comment.authorAgentId) ?? null) : null,
|
||||
// Portable bundles preserve author kind, but not raw board user ids.
|
||||
authorUserId: null,
|
||||
presentation: comment.presentation,
|
||||
metadata: comment.metadata,
|
||||
createdAt: comment.createdAt instanceof Date
|
||||
? comment.createdAt.toISOString()
|
||||
: new Date(comment.createdAt).toISOString(),
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
paperclipTasksOut[taskSlug] = isPlainRecord(extension) ? extension : {};
|
||||
}
|
||||
@@ -4741,7 +4846,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
warnings.push(`Task ${manifestIssue.slug} was downgraded to todo because its assignee could not be imported as assignable work.`);
|
||||
issueStatus = "todo";
|
||||
}
|
||||
await issues.create(targetCompany.id, {
|
||||
const createdIssue = await issues.create(targetCompany.id, {
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
title: manifestIssue.title,
|
||||
@@ -4756,6 +4861,33 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
executionWorkspaceSettings: manifestIssue.executionWorkspaceSettings,
|
||||
labelIds: manifestIssue.labelIds ?? [],
|
||||
});
|
||||
for (const comment of manifestIssue.comments ?? []) {
|
||||
const authorAgentId = comment.authorType === "agent" && comment.authorAgentSlug
|
||||
? importedSlugToAgentId.get(comment.authorAgentSlug)
|
||||
?? existingSlugToAgentId.get(comment.authorAgentSlug)
|
||||
?? null
|
||||
: null;
|
||||
if (comment.authorType === "agent" && comment.authorAgentSlug && !authorAgentId) {
|
||||
warnings.push(`Comment on task ${manifestIssue.slug} was imported as a system comment because author agent ${comment.authorAgentSlug} was not imported.`);
|
||||
}
|
||||
if (comment.authorType === "user" && !actorUserId) {
|
||||
warnings.push(`Comment on task ${manifestIssue.slug} was imported as a system comment because no importing user was available.`);
|
||||
}
|
||||
const authorType = authorAgentId
|
||||
? "agent"
|
||||
: comment.authorType === "user" && actorUserId
|
||||
? "user"
|
||||
: "system";
|
||||
await issues.addComment(createdIssue.id, comment.body, {
|
||||
agentId: authorAgentId ?? undefined,
|
||||
userId: authorType === "user" ? actorUserId ?? undefined : undefined,
|
||||
}, {
|
||||
authorType,
|
||||
presentation: comment.presentation,
|
||||
metadata: comment.metadata,
|
||||
createdAt: comment.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
export const COMPANY_SEARCH_RATE_LIMIT_WINDOW_MS = 60_000;
|
||||
export const COMPANY_SEARCH_RATE_LIMIT_MAX_REQUESTS = 60;
|
||||
|
||||
export type CompanySearchRateLimitActor = {
|
||||
companyId: string;
|
||||
actorType: "agent" | "board";
|
||||
actorId: string;
|
||||
};
|
||||
|
||||
export type CompanySearchRateLimitResult = {
|
||||
allowed: boolean;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
retryAfterSeconds: number;
|
||||
};
|
||||
|
||||
export type CompanySearchRateLimiter = {
|
||||
consume(actor: CompanySearchRateLimitActor): CompanySearchRateLimitResult;
|
||||
};
|
||||
|
||||
export function createCompanySearchRateLimiter(options: {
|
||||
windowMs?: number;
|
||||
maxRequests?: number;
|
||||
now?: () => number;
|
||||
} = {}): CompanySearchRateLimiter {
|
||||
const windowMs = options.windowMs ?? COMPANY_SEARCH_RATE_LIMIT_WINDOW_MS;
|
||||
const maxRequests = options.maxRequests ?? COMPANY_SEARCH_RATE_LIMIT_MAX_REQUESTS;
|
||||
const now = options.now ?? Date.now;
|
||||
const hitsByKey = new Map<string, number[]>();
|
||||
|
||||
function key(actor: CompanySearchRateLimitActor) {
|
||||
return `${actor.companyId}:${actor.actorType}:${actor.actorId}`;
|
||||
}
|
||||
|
||||
return {
|
||||
consume(actor) {
|
||||
const currentTime = now();
|
||||
const cutoff = currentTime - windowMs;
|
||||
const actorKey = key(actor);
|
||||
const recentHits = (hitsByKey.get(actorKey) ?? []).filter((hit) => hit > cutoff);
|
||||
|
||||
if (recentHits.length >= maxRequests) {
|
||||
const oldestHit = recentHits[0] ?? currentTime;
|
||||
hitsByKey.set(actorKey, recentHits);
|
||||
return {
|
||||
allowed: false,
|
||||
limit: maxRequests,
|
||||
remaining: 0,
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((oldestHit + windowMs - currentTime) / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
recentHits.push(currentTime);
|
||||
hitsByKey.set(actorKey, recentHits);
|
||||
return {
|
||||
allowed: true,
|
||||
limit: maxRequests,
|
||||
remaining: Math.max(0, maxRequests - recentHits.length),
|
||||
retryAfterSeconds: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, companies, issues, projects } from "@paperclipai/db";
|
||||
import {
|
||||
COMPANY_SEARCH_MAX_LIMIT,
|
||||
COMPANY_SEARCH_MAX_OFFSET,
|
||||
COMPANY_SEARCH_MAX_TOKENS,
|
||||
type CompanySearchIssueSummary,
|
||||
type CompanySearchQuery,
|
||||
type CompanySearchResponse,
|
||||
type CompanySearchResult,
|
||||
type CompanySearchResultType,
|
||||
type CompanySearchScope,
|
||||
type CompanySearchSnippet,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
const MIN_TOKEN_LENGTH = 2;
|
||||
const MIN_FUZZY_QUERY_LENGTH = 4;
|
||||
const MIN_FUZZY_TOKEN_LENGTH = 4;
|
||||
// Cap fuzzy edits using the shorter of (query token, title word) so common
|
||||
// 4–5 letter English words don't sweep in noise (e.g. "serach" vs "each").
|
||||
const FUZZY_PAIR_LONG_LENGTH = 6;
|
||||
const FUZZY_PAIR_LONG_MAX_EDITS = 2;
|
||||
const FUZZY_PAIR_MEDIUM_LENGTH = 5;
|
||||
const FUZZY_PAIR_MEDIUM_MAX_EDITS = 1;
|
||||
const FUZZY_PAIR_SHORT_MAX_EDITS = 0;
|
||||
const FUZZY_IDENTIFIER_SIMILARITY_THRESHOLD = 0.45;
|
||||
const SNIPPET_MAX_CHARS = 240;
|
||||
export const COMPANY_SEARCH_BRANCH_FETCH_LIMIT = COMPANY_SEARCH_MAX_OFFSET + COMPANY_SEARCH_MAX_LIMIT + 1;
|
||||
|
||||
type IssueSearchRow = {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
projectId: string | null;
|
||||
updatedAt: Date;
|
||||
score: number | string;
|
||||
matchedFields: string[] | null;
|
||||
commentSnippet: string | null;
|
||||
commentId: string | null;
|
||||
documentSnippet: string | null;
|
||||
documentTitle: string | null;
|
||||
documentKey: string | null;
|
||||
};
|
||||
|
||||
type SimpleSearchRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
role?: string | null;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
function normalizeQuery(query: string) {
|
||||
return query.trim().replace(/\s+/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
function escapeLikePattern(value: string): string {
|
||||
return value.replace(/[\\%_]/g, "\\$&");
|
||||
}
|
||||
|
||||
function tokenizeQuery(normalizedQuery: string) {
|
||||
const matches = normalizedQuery.match(/"[^"]+"|[^\s]+/g) ?? [];
|
||||
const tokens: string[] = [];
|
||||
for (const match of matches) {
|
||||
const token = match.replace(/^"|"$/g, "").replace(/^[^\p{L}\p{N}%_\\-]+|[^\p{L}\p{N}%_\\-]+$/gu, "");
|
||||
if (token.length < MIN_TOKEN_LENGTH) continue;
|
||||
if (!tokens.includes(token)) tokens.push(token);
|
||||
if (tokens.length >= COMPANY_SEARCH_MAX_TOKENS) break;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function fuzzyEligibleTokens(tokens: string[]): string[] {
|
||||
return tokens.filter((token) => token.length >= MIN_FUZZY_TOKEN_LENGTH);
|
||||
}
|
||||
|
||||
function sqlTextArray(values: string[]) {
|
||||
if (values.length === 0) return sql`ARRAY[]::text[]`;
|
||||
return sql`ARRAY[${sql.join(values.map((value) => sql`${value}`), sql`, `)}]::text[]`;
|
||||
}
|
||||
|
||||
function tokenMatchExpression(textExpression: SQL, tokenArray: SQL) {
|
||||
return sql<boolean>`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM unnest(${tokenArray}) AS search_token(value)
|
||||
WHERE lower(coalesce(${textExpression}, '')) LIKE '%' || search_token.value || '%' ESCAPE '\\'
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function noMatchSql() {
|
||||
return sql<boolean>`false`;
|
||||
}
|
||||
|
||||
function plainText(value: string | null | undefined) {
|
||||
return (value ?? "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/[#>*_~|]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/;
|
||||
|
||||
function extractFirstImageUrl(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const match = MARKDOWN_IMAGE_PATTERN.exec(value);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function findFirstMatchIndex(value: string, terms: string[]) {
|
||||
const lower = value.toLowerCase();
|
||||
let best = -1;
|
||||
for (const term of terms) {
|
||||
if (term.length === 0) continue;
|
||||
const index = lower.indexOf(term.toLowerCase());
|
||||
if (index >= 0 && (best < 0 || index < best)) best = index;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function highlightRanges(value: string, terms: string[]) {
|
||||
const lower = value.toLowerCase();
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
for (const term of terms) {
|
||||
const normalized = term.toLowerCase();
|
||||
if (normalized.length === 0) continue;
|
||||
let index = lower.indexOf(normalized);
|
||||
while (index >= 0) {
|
||||
const next = { start: index, end: index + normalized.length };
|
||||
const overlaps = ranges.some((range) => next.start < range.end && next.end > range.start);
|
||||
if (!overlaps) ranges.push(next);
|
||||
index = lower.indexOf(normalized, index + normalized.length);
|
||||
}
|
||||
}
|
||||
return ranges.sort((left, right) => left.start - right.start);
|
||||
}
|
||||
|
||||
function createSnippet(field: string, label: string, source: string | null | undefined, terms: string[]): CompanySearchSnippet | null {
|
||||
const text = plainText(source);
|
||||
if (!text) return null;
|
||||
const firstMatch = findFirstMatchIndex(text, terms);
|
||||
const windowStart = firstMatch < 0 ? 0 : Math.max(0, firstMatch - 80);
|
||||
const windowEnd = Math.min(text.length, windowStart + SNIPPET_MAX_CHARS);
|
||||
const prefix = windowStart > 0 ? "..." : "";
|
||||
const suffix = windowEnd < text.length ? "..." : "";
|
||||
const slice = text.slice(windowStart, windowEnd).trim();
|
||||
const snippetText = `${prefix}${slice}${suffix}`;
|
||||
const offset = prefix.length - windowStart;
|
||||
return {
|
||||
field,
|
||||
label,
|
||||
text: snippetText,
|
||||
highlights: highlightRanges(text, terms)
|
||||
.filter((range) => range.end > windowStart && range.start < windowEnd)
|
||||
.map((range) => ({
|
||||
start: Math.max(0, range.start + offset),
|
||||
end: Math.min(snippetText.length, range.end + offset),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function iso(value: Date | string | null | undefined) {
|
||||
if (!value) return null;
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function routePrefix(issuePrefix: string | null | undefined) {
|
||||
return issuePrefix?.trim() || "company";
|
||||
}
|
||||
|
||||
function issueHref(prefix: string, issue: { id: string; identifier: string | null }, suffix = "") {
|
||||
return `/${prefix}/issues/${encodeURIComponent(issue.identifier ?? issue.id)}${suffix}`;
|
||||
}
|
||||
|
||||
function matchTerms(normalizedQuery: string, tokens: string[]) {
|
||||
return [normalizedQuery, ...tokens].filter((term, index, terms) => term.length > 0 && terms.indexOf(term) === index);
|
||||
}
|
||||
|
||||
function makeCounts(results: CompanySearchResult[]) {
|
||||
const counts: Record<CompanySearchResultType, number> = { issue: 0, agent: 0, project: 0 };
|
||||
for (const result of results) counts[result.type] += 1;
|
||||
return counts;
|
||||
}
|
||||
|
||||
function scopeIncludesIssues(scope: CompanySearchScope) {
|
||||
return scope === "all" || scope === "issues" || scope === "comments" || scope === "documents";
|
||||
}
|
||||
|
||||
function scopeIncludesAgents(scope: CompanySearchScope) {
|
||||
return scope === "all" || scope === "agents";
|
||||
}
|
||||
|
||||
function scopeIncludesProjects(scope: CompanySearchScope) {
|
||||
return scope === "all" || scope === "projects";
|
||||
}
|
||||
|
||||
function issueSearchCondition(scope: CompanySearchScope, input: {
|
||||
issueTextMatch: SQL<boolean>;
|
||||
commentMatch: SQL<boolean>;
|
||||
documentMatch: SQL<boolean>;
|
||||
fuzzyMatch: SQL<boolean>;
|
||||
}) {
|
||||
if (scope === "comments") return input.commentMatch;
|
||||
if (scope === "documents") return input.documentMatch;
|
||||
if (scope === "issues") return sql<boolean>`(${input.issueTextMatch} OR ${input.fuzzyMatch})`;
|
||||
return sql<boolean>`(${input.issueTextMatch} OR ${input.commentMatch} OR ${input.documentMatch} OR ${input.fuzzyMatch})`;
|
||||
}
|
||||
|
||||
function selectPrimarySnippets(row: IssueSearchRow, normalizedQuery: string, tokens: string[]) {
|
||||
const terms = matchTerms(normalizedQuery, tokens);
|
||||
const matchedFields = new Set(row.matchedFields ?? []);
|
||||
const candidates: Array<CompanySearchSnippet | null> = [];
|
||||
if (matchedFields.has("identifier")) {
|
||||
candidates.push(createSnippet("identifier", "Identifier", row.identifier, terms));
|
||||
}
|
||||
if (matchedFields.has("title")) {
|
||||
candidates.push(createSnippet("title", "Title", row.title, terms));
|
||||
}
|
||||
if (matchedFields.has("comment")) {
|
||||
candidates.push(createSnippet("comment", "Comment", row.commentSnippet, terms));
|
||||
}
|
||||
if (matchedFields.has("document")) {
|
||||
candidates.push(createSnippet("document", row.documentTitle || "Document", row.documentSnippet, terms));
|
||||
}
|
||||
if (matchedFields.has("description")) {
|
||||
candidates.push(createSnippet("description", "Description", row.description, terms));
|
||||
}
|
||||
return candidates.filter((snippet): snippet is CompanySearchSnippet => Boolean(snippet)).slice(0, 2);
|
||||
}
|
||||
|
||||
function issueResult(row: IssueSearchRow, prefix: string, normalizedQuery: string, tokens: string[]): CompanySearchResult {
|
||||
const snippets = selectPrimarySnippets(row, normalizedQuery, tokens);
|
||||
const sourceLabel = snippets[0]?.label ?? null;
|
||||
const documentSuffix = row.documentKey ? `#document-${encodeURIComponent(row.documentKey)}` : "";
|
||||
const commentSuffix = row.commentId ? `#comment-${encodeURIComponent(row.commentId)}` : "";
|
||||
const suffix = row.commentId ? commentSuffix : documentSuffix;
|
||||
const issue: CompanySearchIssueSummary = {
|
||||
id: row.id,
|
||||
identifier: row.identifier,
|
||||
title: row.title,
|
||||
status: row.status as CompanySearchIssueSummary["status"],
|
||||
priority: row.priority as CompanySearchIssueSummary["priority"],
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
projectId: row.projectId,
|
||||
updatedAt: iso(row.updatedAt)!,
|
||||
};
|
||||
const previewImageUrl =
|
||||
extractFirstImageUrl(row.description) ??
|
||||
extractFirstImageUrl(row.commentSnippet) ??
|
||||
extractFirstImageUrl(row.documentSnippet);
|
||||
return {
|
||||
id: row.id,
|
||||
type: "issue",
|
||||
score: Number(row.score),
|
||||
title: row.identifier ? `${row.identifier} ${row.title}` : row.title,
|
||||
href: issueHref(prefix, row, suffix),
|
||||
matchedFields: row.matchedFields ?? [],
|
||||
sourceLabel,
|
||||
snippet: snippets[0]?.text ?? null,
|
||||
snippets,
|
||||
issue,
|
||||
updatedAt: issue.updatedAt,
|
||||
previewImageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function scoreSimpleRow(row: SimpleSearchRow, normalizedQuery: string, tokens: string[]) {
|
||||
const haystack = [row.title, row.description, row.role].filter(Boolean).join(" ").toLowerCase();
|
||||
let score = haystack.includes(normalizedQuery) ? 90 : 0;
|
||||
for (const token of tokens) {
|
||||
if (haystack.includes(token)) score += 20;
|
||||
}
|
||||
if (row.title.toLowerCase().startsWith(normalizedQuery)) score += 80;
|
||||
return score;
|
||||
}
|
||||
|
||||
function simpleTextCondition(fields: SQL[], containsPattern: string, tokenArray: SQL) {
|
||||
const phraseConditions = fields.map((field) => sql<boolean>`lower(coalesce(${field}, '')) LIKE ${containsPattern} ESCAPE '\\'`);
|
||||
const tokenConditions = fields.map((field) => tokenMatchExpression(field, tokenArray));
|
||||
return sql<boolean>`(${sql.join([...phraseConditions, ...tokenConditions], sql` OR `)})`;
|
||||
}
|
||||
|
||||
export function companySearchBranchFetchLimit(limit: number, offset = 0) {
|
||||
const normalizedLimit = Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : COMPANY_SEARCH_MAX_LIMIT;
|
||||
const normalizedOffset = Number.isFinite(offset) ? Math.max(0, Math.floor(offset)) : 0;
|
||||
return Math.min(COMPANY_SEARCH_BRANCH_FETCH_LIMIT, normalizedOffset + normalizedLimit + 1);
|
||||
}
|
||||
|
||||
export function companySearchService(db: Db) {
|
||||
return {
|
||||
search: async (companyId: string, query: CompanySearchQuery): Promise<CompanySearchResponse> => {
|
||||
const normalizedQuery = normalizeQuery(query.q);
|
||||
const tokens = tokenizeQuery(normalizedQuery);
|
||||
const scope = query.scope;
|
||||
const limit = query.limit;
|
||||
const offset = query.offset;
|
||||
const emptyCounts: Record<CompanySearchResultType, number> = { issue: 0, agent: 0, project: 0 };
|
||||
if (normalizedQuery.length === 0) {
|
||||
return {
|
||||
query: query.q,
|
||||
normalizedQuery,
|
||||
scope,
|
||||
limit,
|
||||
offset,
|
||||
results: [],
|
||||
countsByType: emptyCounts,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
const company = await db
|
||||
.select({ issuePrefix: companies.issuePrefix })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const prefix = routePrefix(company?.issuePrefix);
|
||||
const fetchLimit = companySearchBranchFetchLimit(limit, offset);
|
||||
const escapedTokens = tokens.map(escapeLikePattern);
|
||||
const tokenArray = sqlTextArray(escapedTokens);
|
||||
const fuzzyTokens = fuzzyEligibleTokens(tokens);
|
||||
const fuzzyTokenArray = sqlTextArray(fuzzyTokens);
|
||||
const escapedQuery = escapeLikePattern(normalizedQuery);
|
||||
const containsPattern = `%${escapedQuery}%`;
|
||||
const startsWithPattern = `${escapedQuery}%`;
|
||||
const fuzzyEnabled = normalizedQuery.length >= MIN_FUZZY_QUERY_LENGTH && !/[\\%_]/.test(normalizedQuery);
|
||||
const fuzzyTokensEnabled = fuzzyEnabled && fuzzyTokens.length > 0;
|
||||
|
||||
const titlePhraseMatch = sql<boolean>`lower(${issues.title}) LIKE ${containsPattern} ESCAPE '\\'`;
|
||||
const titleStartsWith = sql<boolean>`lower(${issues.title}) LIKE ${startsWithPattern} ESCAPE '\\'`;
|
||||
const identifierPhraseMatch = sql<boolean>`lower(coalesce(${issues.identifier}, '')) LIKE ${containsPattern} ESCAPE '\\'`;
|
||||
const identifierStartsWith = sql<boolean>`lower(coalesce(${issues.identifier}, '')) LIKE ${startsWithPattern} ESCAPE '\\'`;
|
||||
const descriptionPhraseMatch = sql<boolean>`lower(coalesce(${issues.description}, '')) LIKE ${containsPattern} ESCAPE '\\'`;
|
||||
const titleTokenMatch = tokenMatchExpression(sql`${issues.title}`, tokenArray);
|
||||
const identifierTokenMatch = tokenMatchExpression(sql`${issues.identifier}`, tokenArray);
|
||||
const descriptionTokenMatch = tokenMatchExpression(sql`${issues.description}`, tokenArray);
|
||||
const issueTextMatch = sql<boolean>`
|
||||
${titlePhraseMatch}
|
||||
OR ${identifierPhraseMatch}
|
||||
OR ${descriptionPhraseMatch}
|
||||
OR ${titleTokenMatch}
|
||||
OR ${identifierTokenMatch}
|
||||
OR ${descriptionTokenMatch}
|
||||
`;
|
||||
const commentMatch = sql<boolean>`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM issue_comments search_comments
|
||||
WHERE search_comments.company_id = ${companyId}
|
||||
AND search_comments.issue_id = issues.id
|
||||
AND (
|
||||
lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\'
|
||||
OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)}
|
||||
)
|
||||
)
|
||||
`;
|
||||
const documentMatch = sql<boolean>`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM issue_documents search_issue_documents
|
||||
INNER JOIN documents search_documents
|
||||
ON search_documents.id = search_issue_documents.document_id
|
||||
WHERE search_issue_documents.company_id = ${companyId}
|
||||
AND search_documents.company_id = ${companyId}
|
||||
AND search_issue_documents.issue_id = issues.id
|
||||
AND (
|
||||
lower(coalesce(search_documents.title, '')) LIKE ${containsPattern} ESCAPE '\\'
|
||||
OR lower(search_documents.latest_body) LIKE ${containsPattern} ESCAPE '\\'
|
||||
OR ${tokenMatchExpression(sql`search_documents.title`, tokenArray)}
|
||||
OR ${tokenMatchExpression(sql`search_documents.latest_body`, tokenArray)}
|
||||
)
|
||||
)
|
||||
`;
|
||||
// Each query token (length >= MIN_FUZZY_TOKEN_LENGTH) must have at least
|
||||
// one title word within Levenshtein edit distance. This handles typos
|
||||
// like "serach" -> "search" (transposition) and "mibile" -> "mobile"
|
||||
// (substitution) without the trigram noise that drop-character variants
|
||||
// produced (e.g. "serac" matching "service"). Edit budget is gated on
|
||||
// the SHORTER of the two strings so 4–5 letter English words don't get
|
||||
// swept in by lev=2 collisions.
|
||||
const fuzzyMaxEditsExpr = sql.raw(
|
||||
`CASE
|
||||
WHEN least(length(qt.value), length(title_word.value)) >= ${FUZZY_PAIR_LONG_LENGTH} THEN ${FUZZY_PAIR_LONG_MAX_EDITS}
|
||||
WHEN least(length(qt.value), length(title_word.value)) >= ${FUZZY_PAIR_MEDIUM_LENGTH} THEN ${FUZZY_PAIR_MEDIUM_MAX_EDITS}
|
||||
ELSE ${FUZZY_PAIR_SHORT_MAX_EDITS}
|
||||
END`,
|
||||
);
|
||||
const fuzzyMinTitleWordLengthExpr = sql.raw(`${MIN_FUZZY_TOKEN_LENGTH}`);
|
||||
const fuzzyTokenTitleMatch = fuzzyTokensEnabled
|
||||
? sql<boolean>`
|
||||
coalesce((
|
||||
SELECT bool_and(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM regexp_split_to_table(lower(${issues.title}), '[^a-z0-9]+') AS title_word(value)
|
||||
WHERE length(title_word.value) >= ${fuzzyMinTitleWordLengthExpr}
|
||||
AND levenshtein_less_equal(qt.value, title_word.value, ${fuzzyMaxEditsExpr}) <= ${fuzzyMaxEditsExpr}
|
||||
)
|
||||
)
|
||||
FROM unnest(${fuzzyTokenArray}) AS qt(value)
|
||||
), false)
|
||||
`
|
||||
: noMatchSql();
|
||||
const fuzzyIdentifierMatch = fuzzyEnabled
|
||||
? sql<boolean>`similarity(lower(coalesce(${issues.identifier}, '')), ${normalizedQuery}) >= ${FUZZY_IDENTIFIER_SIMILARITY_THRESHOLD}`
|
||||
: noMatchSql();
|
||||
const fuzzyMatch = sql<boolean>`(${fuzzyTokenTitleMatch} OR ${fuzzyIdentifierMatch})`;
|
||||
const tokenCoverage = sql<number>`
|
||||
(
|
||||
SELECT count(*)::int
|
||||
FROM unnest(${tokenArray}) AS search_token(value)
|
||||
WHERE lower(${issues.title}) LIKE '%' || search_token.value || '%' ESCAPE '\\'
|
||||
OR lower(coalesce(${issues.identifier}, '')) LIKE '%' || search_token.value || '%' ESCAPE '\\'
|
||||
OR lower(coalesce(${issues.description}, '')) LIKE '%' || search_token.value || '%' ESCAPE '\\'
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM issue_comments coverage_comments
|
||||
WHERE coverage_comments.company_id = ${companyId}
|
||||
AND coverage_comments.issue_id = issues.id
|
||||
AND lower(coverage_comments.body) LIKE '%' || search_token.value || '%' ESCAPE '\\'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM issue_documents coverage_issue_documents
|
||||
INNER JOIN documents coverage_documents
|
||||
ON coverage_documents.id = coverage_issue_documents.document_id
|
||||
WHERE coverage_issue_documents.company_id = ${companyId}
|
||||
AND coverage_documents.company_id = ${companyId}
|
||||
AND coverage_issue_documents.issue_id = issues.id
|
||||
AND (
|
||||
lower(coalesce(coverage_documents.title, '')) LIKE '%' || search_token.value || '%' ESCAPE '\\'
|
||||
OR lower(coverage_documents.latest_body) LIKE '%' || search_token.value || '%' ESCAPE '\\'
|
||||
)
|
||||
)
|
||||
)
|
||||
`;
|
||||
const tokenCount = tokens.length;
|
||||
const allTokensMatch = tokenCount > 0
|
||||
? sql<boolean>`${tokenCoverage} = ${tokenCount}`
|
||||
: noMatchSql();
|
||||
const score = sql<number>`
|
||||
(
|
||||
CASE WHEN lower(coalesce(${issues.identifier}, '')) = ${normalizedQuery} THEN 1200 ELSE 0 END
|
||||
+ CASE WHEN ${identifierStartsWith} THEN 700 ELSE 0 END
|
||||
+ CASE WHEN lower(${issues.title}) = ${normalizedQuery} THEN 900 ELSE 0 END
|
||||
+ CASE WHEN ${titleStartsWith} THEN 550 ELSE 0 END
|
||||
+ CASE WHEN ${titlePhraseMatch} THEN 350 ELSE 0 END
|
||||
+ CASE WHEN ${identifierPhraseMatch} THEN 320 ELSE 0 END
|
||||
+ CASE WHEN ${commentMatch} THEN 180 ELSE 0 END
|
||||
+ CASE WHEN ${documentMatch} THEN 170 ELSE 0 END
|
||||
+ CASE WHEN ${descriptionPhraseMatch} THEN 120 ELSE 0 END
|
||||
+ CASE WHEN ${allTokensMatch} THEN 260 ELSE 0 END
|
||||
+ (${tokenCoverage} * 70)
|
||||
+ CASE WHEN ${fuzzyMatch} THEN 110 ELSE 0 END
|
||||
+ CASE ${issues.status} WHEN 'done' THEN 0 WHEN 'cancelled' THEN -30 ELSE 20 END
|
||||
)::double precision
|
||||
`;
|
||||
const matchedFields = sql<string[]>`
|
||||
array_remove(ARRAY[
|
||||
CASE WHEN ${identifierPhraseMatch} OR ${identifierTokenMatch} OR ${fuzzyIdentifierMatch} THEN 'identifier' END,
|
||||
CASE WHEN ${titlePhraseMatch} OR ${titleTokenMatch} OR ${fuzzyTokenTitleMatch} THEN 'title' END,
|
||||
CASE WHEN ${descriptionPhraseMatch} OR ${descriptionTokenMatch} THEN 'description' END,
|
||||
CASE WHEN ${commentMatch} THEN 'comment' END,
|
||||
CASE WHEN ${documentMatch} THEN 'document' END
|
||||
], NULL)::text[]
|
||||
`;
|
||||
|
||||
const issueRows = scopeIncludesIssues(scope)
|
||||
? await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
description: issues.description,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
projectId: issues.projectId,
|
||||
updatedAt: issues.updatedAt,
|
||||
score,
|
||||
matchedFields,
|
||||
commentSnippet: sql<string | null>`
|
||||
(
|
||||
SELECT search_comments.body
|
||||
FROM issue_comments search_comments
|
||||
WHERE search_comments.company_id = ${companyId}
|
||||
AND search_comments.issue_id = issues.id
|
||||
AND (
|
||||
lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\'
|
||||
OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)}
|
||||
)
|
||||
ORDER BY
|
||||
CASE WHEN lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\' THEN 0 ELSE 1 END,
|
||||
search_comments.updated_at DESC,
|
||||
search_comments.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
`,
|
||||
commentId: sql<string | null>`
|
||||
(
|
||||
SELECT search_comments.id
|
||||
FROM issue_comments search_comments
|
||||
WHERE search_comments.company_id = ${companyId}
|
||||
AND search_comments.issue_id = issues.id
|
||||
AND (
|
||||
lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\'
|
||||
OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)}
|
||||
)
|
||||
ORDER BY
|
||||
CASE WHEN lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\' THEN 0 ELSE 1 END,
|
||||
search_comments.updated_at DESC,
|
||||
search_comments.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
`,
|
||||
documentSnippet: sql<string | null>`
|
||||
(
|
||||
SELECT search_documents.latest_body
|
||||
FROM issue_documents search_issue_documents
|
||||
INNER JOIN documents search_documents
|
||||
ON search_documents.id = search_issue_documents.document_id
|
||||
WHERE search_issue_documents.company_id = ${companyId}
|
||||
AND search_documents.company_id = ${companyId}
|
||||
AND search_issue_documents.issue_id = issues.id
|
||||
AND (
|
||||
lower(coalesce(search_documents.title, '')) LIKE ${containsPattern} ESCAPE '\\'
|
||||
OR lower(search_documents.latest_body) LIKE ${containsPattern} ESCAPE '\\'
|
||||
OR ${tokenMatchExpression(sql`search_documents.title`, tokenArray)}
|
||||
OR ${tokenMatchExpression(sql`search_documents.latest_body`, tokenArray)}
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN lower(coalesce(search_documents.title, '')) LIKE ${containsPattern} ESCAPE '\\' THEN 0
|
||||
WHEN lower(search_documents.latest_body) LIKE ${containsPattern} ESCAPE '\\' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
search_documents.updated_at DESC,
|
||||
search_documents.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
`,
|
||||
documentTitle: sql<string | null>`
|
||||
(
|
||||
SELECT search_documents.title
|
||||
FROM issue_documents search_issue_documents
|
||||
INNER JOIN documents search_documents
|
||||
ON search_documents.id = search_issue_documents.document_id
|
||||
WHERE search_issue_documents.company_id = ${companyId}
|
||||
AND search_documents.company_id = ${companyId}
|
||||
AND search_issue_documents.issue_id = issues.id
|
||||
AND (
|
||||
lower(coalesce(search_documents.title, '')) LIKE ${containsPattern} ESCAPE '\\'
|
||||
OR lower(search_documents.latest_body) LIKE ${containsPattern} ESCAPE '\\'
|
||||
OR ${tokenMatchExpression(sql`search_documents.title`, tokenArray)}
|
||||
OR ${tokenMatchExpression(sql`search_documents.latest_body`, tokenArray)}
|
||||
)
|
||||
ORDER BY search_documents.updated_at DESC, search_documents.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
`,
|
||||
documentKey: sql<string | null>`
|
||||
(
|
||||
SELECT search_issue_documents.key
|
||||
FROM issue_documents search_issue_documents
|
||||
INNER JOIN documents search_documents
|
||||
ON search_documents.id = search_issue_documents.document_id
|
||||
WHERE search_issue_documents.company_id = ${companyId}
|
||||
AND search_documents.company_id = ${companyId}
|
||||
AND search_issue_documents.issue_id = issues.id
|
||||
AND (
|
||||
lower(coalesce(search_documents.title, '')) LIKE ${containsPattern} ESCAPE '\\'
|
||||
OR lower(search_documents.latest_body) LIKE ${containsPattern} ESCAPE '\\'
|
||||
OR ${tokenMatchExpression(sql`search_documents.title`, tokenArray)}
|
||||
OR ${tokenMatchExpression(sql`search_documents.latest_body`, tokenArray)}
|
||||
)
|
||||
ORDER BY search_documents.updated_at DESC, search_documents.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
issueSearchCondition(scope, { issueTextMatch, commentMatch, documentMatch, fuzzyMatch }),
|
||||
))
|
||||
.orderBy(desc(score), desc(issues.updatedAt), desc(issues.id))
|
||||
.limit(fetchLimit)
|
||||
: [];
|
||||
|
||||
const simpleCondition = simpleTextCondition([
|
||||
sql`${agents.name}`,
|
||||
sql`${agents.role}`,
|
||||
sql`${agents.title}`,
|
||||
sql`${agents.capabilities}`,
|
||||
], containsPattern, tokenArray);
|
||||
const agentRows = scopeIncludesAgents(scope)
|
||||
? await db
|
||||
.select({
|
||||
id: agents.id,
|
||||
title: agents.name,
|
||||
description: agents.capabilities,
|
||||
role: agents.role,
|
||||
updatedAt: agents.updatedAt,
|
||||
})
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), simpleCondition))
|
||||
.orderBy(desc(agents.updatedAt), desc(agents.id))
|
||||
.limit(fetchLimit)
|
||||
: [];
|
||||
|
||||
const projectCondition = simpleTextCondition([
|
||||
sql`${projects.name}`,
|
||||
sql`${projects.description}`,
|
||||
], containsPattern, tokenArray);
|
||||
const projectRows = scopeIncludesProjects(scope)
|
||||
? await db
|
||||
.select({
|
||||
id: projects.id,
|
||||
title: projects.name,
|
||||
description: projects.description,
|
||||
updatedAt: projects.updatedAt,
|
||||
})
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition))
|
||||
.orderBy(desc(projects.updatedAt), desc(projects.id))
|
||||
.limit(fetchLimit)
|
||||
: [];
|
||||
|
||||
const results: CompanySearchResult[] = [
|
||||
...(issueRows as IssueSearchRow[]).map((row) => issueResult(row, prefix, normalizedQuery, tokens)),
|
||||
...(agentRows as SimpleSearchRow[]).map((row) => {
|
||||
const terms = matchTerms(normalizedQuery, tokens);
|
||||
const snippet = createSnippet("capabilities", "Agent", row.description ?? row.role ?? row.title, terms);
|
||||
return {
|
||||
id: row.id,
|
||||
type: "agent" as const,
|
||||
score: scoreSimpleRow(row, normalizedQuery, tokens),
|
||||
title: row.title,
|
||||
href: `/${prefix}/agents/${encodeURIComponent(row.id)}`,
|
||||
matchedFields: ["agent"],
|
||||
sourceLabel: snippet?.label ?? null,
|
||||
snippet: snippet?.text ?? null,
|
||||
snippets: snippet ? [snippet] : [],
|
||||
updatedAt: iso(row.updatedAt),
|
||||
previewImageUrl: null,
|
||||
};
|
||||
}),
|
||||
...(projectRows as SimpleSearchRow[]).map((row) => {
|
||||
const terms = matchTerms(normalizedQuery, tokens);
|
||||
const snippet = createSnippet("description", "Project", row.description ?? row.title, terms);
|
||||
return {
|
||||
id: row.id,
|
||||
type: "project" as const,
|
||||
score: scoreSimpleRow(row, normalizedQuery, tokens),
|
||||
title: row.title,
|
||||
href: `/${prefix}/projects/${encodeURIComponent(row.id)}`,
|
||||
matchedFields: ["project"],
|
||||
sourceLabel: snippet?.label ?? null,
|
||||
snippet: snippet?.text ?? null,
|
||||
snippets: snippet ? [snippet] : [],
|
||||
updatedAt: iso(row.updatedAt),
|
||||
previewImageUrl: null,
|
||||
};
|
||||
}),
|
||||
].sort((left, right) => {
|
||||
if (right.score !== left.score) return right.score - left.score;
|
||||
return (right.updatedAt ?? "").localeCompare(left.updatedAt ?? "");
|
||||
});
|
||||
|
||||
const paged = results.slice(offset, offset + limit);
|
||||
return {
|
||||
query: query.q,
|
||||
normalizedQuery,
|
||||
scope,
|
||||
limit,
|
||||
offset,
|
||||
results: paged,
|
||||
countsByType: makeCounts(results),
|
||||
hasMore: results.length > offset + limit,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
+108
-31
@@ -1,7 +1,7 @@
|
||||
import { and, desc, eq, gte, isNotNull, isNull, lt, lte, sql } from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/pg-core";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { activityLog, agents, companies, costEvents, issues, projects } from "@paperclipai/db";
|
||||
import { activityLog, agents, companies, costEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { budgetService, type BudgetServiceHooks } from "./budgets.js";
|
||||
|
||||
@@ -135,18 +135,53 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
};
|
||||
},
|
||||
|
||||
issueTreeSummary: async (companyId: string, issueId: string) => {
|
||||
issueTreeSummary: async (
|
||||
companyId: string,
|
||||
issueId: string,
|
||||
options: { excludeRoot?: boolean } = {},
|
||||
) => {
|
||||
// Callers must resolve and authorize a visible root issue before invoking this.
|
||||
// The route does that so zero counts are not mistaken for a missing root.
|
||||
const childIssues = alias(issues, "child");
|
||||
const issueTreeCondition = sql<boolean>`
|
||||
${issues.id} IN (
|
||||
WITH RECURSIVE issue_tree(id) AS (
|
||||
|
||||
// The seed of the recursive CTE: when excludeRoot is true, start from
|
||||
// the direct children so the root issue itself is not counted.
|
||||
const cteSeed = options.excludeRoot
|
||||
? sql`
|
||||
SELECT ${issues.id}
|
||||
FROM ${issues}
|
||||
WHERE ${issues.companyId} = ${companyId}
|
||||
AND ${issues.parentId} = ${issueId}
|
||||
AND ${issues.hiddenAt} IS NULL
|
||||
`
|
||||
: sql`
|
||||
SELECT ${issues.id}
|
||||
FROM ${issues}
|
||||
WHERE ${issues.companyId} = ${companyId}
|
||||
AND ${issues.id} = ${issueId}
|
||||
AND ${issues.hiddenAt} IS NULL
|
||||
`;
|
||||
|
||||
const cteSeedText = options.excludeRoot
|
||||
? sql`
|
||||
SELECT (${issues.id})::text AS id
|
||||
FROM ${issues}
|
||||
WHERE ${issues.companyId} = ${companyId}
|
||||
AND ${issues.parentId} = ${issueId}
|
||||
AND ${issues.hiddenAt} IS NULL
|
||||
`
|
||||
: sql`
|
||||
SELECT (${issues.id})::text AS id
|
||||
FROM ${issues}
|
||||
WHERE ${issues.companyId} = ${companyId}
|
||||
AND ${issues.id} = ${issueId}
|
||||
AND ${issues.hiddenAt} IS NULL
|
||||
`;
|
||||
|
||||
const issueTreeCondition = sql<boolean>`
|
||||
${issues.id} IN (
|
||||
WITH RECURSIVE issue_tree(id) AS (
|
||||
${cteSeed}
|
||||
UNION ALL
|
||||
SELECT ${childIssues.id}
|
||||
FROM ${issues} ${childIssues}
|
||||
@@ -158,38 +193,80 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
)
|
||||
`;
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
issueCount: sql<number>`count(distinct ${issues.id})::int`,
|
||||
costCents: sumAsNumber(costEvents.costCents),
|
||||
inputTokens: sumAsNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumAsNumber(costEvents.outputTokens),
|
||||
})
|
||||
.from(issues)
|
||||
.leftJoin(
|
||||
costEvents,
|
||||
and(
|
||||
eq(costEvents.companyId, companyId),
|
||||
eq(costEvents.issueId, issues.id),
|
||||
),
|
||||
const runSummarySql = sql`
|
||||
WITH RECURSIVE issue_tree(id) AS (
|
||||
${cteSeedText}
|
||||
UNION ALL
|
||||
SELECT (${childIssues.id})::text
|
||||
FROM ${issues} ${childIssues}
|
||||
JOIN issue_tree ON (${childIssues.parentId})::text = issue_tree.id
|
||||
WHERE ${childIssues.companyId} = ${companyId}
|
||||
AND ${childIssues.hiddenAt} IS NULL
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
issueTreeCondition,
|
||||
SELECT
|
||||
count(distinct ${heartbeatRuns.id})::int AS "runCount",
|
||||
coalesce(sum(extract(epoch from (coalesce(${heartbeatRuns.finishedAt}, now()) - ${heartbeatRuns.startedAt})) * 1000), 0)::double precision AS "runtimeMs"
|
||||
FROM ${heartbeatRuns}
|
||||
WHERE ${heartbeatRuns.companyId} = ${companyId}
|
||||
AND ${heartbeatRuns.startedAt} IS NOT NULL
|
||||
AND (
|
||||
${heartbeatRuns.contextSnapshot} ->> 'issueId' IN (SELECT id FROM issue_tree)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM ${activityLog}
|
||||
JOIN issue_tree ON ${activityLog.entityId} = issue_tree.id
|
||||
WHERE ${activityLog.companyId} = ${companyId}
|
||||
AND ${activityLog.entityType} = 'issue'
|
||||
AND ${activityLog.runId} = ${heartbeatRuns.id}
|
||||
)
|
||||
)
|
||||
`;
|
||||
|
||||
// Run cost-event aggregation and run-duration aggregation in parallel.
|
||||
// They're separate queries because cost_events fan out per-event and
|
||||
// joining heartbeat_runs through them would double-count run durations.
|
||||
const [costRowResult, runRowResult] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
issueCount: sql<number>`count(distinct ${issues.id})::int`,
|
||||
costCents: sumAsNumber(costEvents.costCents),
|
||||
inputTokens: sumAsNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumAsNumber(costEvents.outputTokens),
|
||||
})
|
||||
.from(issues)
|
||||
.leftJoin(
|
||||
costEvents,
|
||||
and(
|
||||
eq(costEvents.companyId, companyId),
|
||||
eq(costEvents.issueId, issues.id),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
issueTreeCondition,
|
||||
),
|
||||
),
|
||||
);
|
||||
db.execute(runSummarySql),
|
||||
]);
|
||||
|
||||
const costRow = costRowResult[0];
|
||||
const runRow = Array.isArray(runRowResult)
|
||||
? (runRowResult[0] as { runCount?: number | string | null; runtimeMs?: number | string | null } | undefined)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
issueId,
|
||||
issueCount: Number(row?.issueCount ?? 0),
|
||||
issueCount: Number(costRow?.issueCount ?? 0),
|
||||
includeDescendants: true,
|
||||
costCents: Number(row?.costCents ?? 0),
|
||||
inputTokens: Number(row?.inputTokens ?? 0),
|
||||
cachedInputTokens: Number(row?.cachedInputTokens ?? 0),
|
||||
outputTokens: Number(row?.outputTokens ?? 0),
|
||||
costCents: Number(costRow?.costCents ?? 0),
|
||||
inputTokens: Number(costRow?.inputTokens ?? 0),
|
||||
cachedInputTokens: Number(costRow?.cachedInputTokens ?? 0),
|
||||
outputTokens: Number(costRow?.outputTokens ?? 0),
|
||||
runCount: Number(runRow?.runCount ?? 0),
|
||||
runtimeMs: Number(runRow?.runtimeMs ?? 0),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
PluginEnvironmentConfig,
|
||||
PluginSandboxEnvironmentConfig,
|
||||
SandboxEnvironmentConfig,
|
||||
SecretProvider,
|
||||
SecretVersionSelector,
|
||||
SshEnvironmentConfig,
|
||||
} from "@paperclipai/shared";
|
||||
import { unprocessable } from "../errors.js";
|
||||
@@ -165,6 +167,7 @@ async function createEnvironmentSecret(input: {
|
||||
environmentName: string;
|
||||
driver: EnvironmentDriver;
|
||||
field: string;
|
||||
provider: SecretProvider;
|
||||
value: string;
|
||||
actor?: { userId?: string | null; agentId?: string | null };
|
||||
}) {
|
||||
@@ -172,7 +175,7 @@ async function createEnvironmentSecret(input: {
|
||||
input.companyId,
|
||||
{
|
||||
name: secretName(input),
|
||||
provider: "local_encrypted",
|
||||
provider: input.provider,
|
||||
value: input.value,
|
||||
description: `Secret for ${input.environmentName} ${input.field}.`,
|
||||
},
|
||||
@@ -190,6 +193,7 @@ async function persistConfigSecretRefs(input: {
|
||||
companyId: string;
|
||||
environmentName: string;
|
||||
driver: EnvironmentDriver;
|
||||
secretProvider: SecretProvider;
|
||||
config: Record<string, unknown>;
|
||||
schema: Record<string, unknown> | null;
|
||||
actor?: { userId?: string | null; agentId?: string | null };
|
||||
@@ -213,6 +217,7 @@ async function persistConfigSecretRefs(input: {
|
||||
environmentName: input.environmentName,
|
||||
driver: input.driver,
|
||||
field: path.replace(/[^a-z0-9]+/gi, "-").toLowerCase(),
|
||||
provider: input.secretProvider,
|
||||
value: trimmed,
|
||||
actor: input.actor,
|
||||
});
|
||||
@@ -226,6 +231,11 @@ async function resolveConfigSecretRefsForRuntime(input: {
|
||||
companyId: string;
|
||||
config: Record<string, unknown>;
|
||||
schema: Record<string, unknown> | null;
|
||||
context: {
|
||||
consumerId: string;
|
||||
issueId?: string | null;
|
||||
heartbeatRunId?: string | null;
|
||||
};
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const secrets = secretService(input.db);
|
||||
let nextConfig = { ...input.config };
|
||||
@@ -234,15 +244,52 @@ async function resolveConfigSecretRefsForRuntime(input: {
|
||||
if (typeof current !== "string") continue;
|
||||
const trimmed = current.trim();
|
||||
if (!isUuidSecretRef(trimmed)) continue;
|
||||
if (!input.context.consumerId) {
|
||||
throw unprocessable("Runtime secret resolution requires an environment id");
|
||||
}
|
||||
nextConfig = writeConfigValueAtPath(
|
||||
nextConfig,
|
||||
path,
|
||||
await secrets.resolveSecretValue(input.companyId, trimmed, "latest"),
|
||||
await secrets.resolveSecretValue(input.companyId, trimmed, "latest", {
|
||||
consumerType: "environment",
|
||||
consumerId: input.context.consumerId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
issueId: input.context.issueId ?? null,
|
||||
heartbeatRunId: input.context.heartbeatRunId ?? null,
|
||||
configPath: path,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
export async function collectEnvironmentSecretRefs(input: {
|
||||
db: Db;
|
||||
environment: Pick<Environment, "id" | "driver" | "config">;
|
||||
}): Promise<Array<{ secretId: string; configPath: string; versionSelector?: SecretVersionSelector }>> {
|
||||
const parsed = parseEnvironmentDriverConfig(input.environment);
|
||||
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) {
|
||||
return [{
|
||||
secretId: parsed.config.privateKeySecretRef.secretId,
|
||||
configPath: "privateKeySecretRef",
|
||||
versionSelector: parsed.config.privateKeySecretRef.version ?? "latest",
|
||||
}];
|
||||
}
|
||||
if (parsed.driver === "sandbox" && parsed.config.provider !== "fake") {
|
||||
const schema = await getSandboxProviderConfigSchema(input.db, parsed.config.provider);
|
||||
const refs: Array<{ secretId: string; configPath: string; versionSelector?: SecretVersionSelector }> = [];
|
||||
for (const path of collectSecretRefPaths(schema)) {
|
||||
const current = readConfigValueAtPath(parsed.config as Record<string, unknown>, path);
|
||||
if (typeof current === "string" && isUuidSecretRef(current.trim())) {
|
||||
refs.push({ secretId: current.trim(), configPath: path, versionSelector: "latest" });
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function stripSandboxProviderEnvelope(config: SandboxEnvironmentConfig): Record<string, unknown> {
|
||||
const { provider: _provider, ...driverConfig } = config as Record<string, unknown>;
|
||||
return driverConfig;
|
||||
@@ -340,6 +387,7 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
|
||||
companyId: string;
|
||||
environmentName: string;
|
||||
driver: EnvironmentDriver;
|
||||
secretProvider: SecretProvider;
|
||||
config: Record<string, unknown> | null | undefined;
|
||||
actor?: { userId?: string | null; agentId?: string | null };
|
||||
pluginWorkerManager?: PluginWorkerManager;
|
||||
@@ -361,6 +409,7 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
|
||||
environmentName: input.environmentName,
|
||||
driver: input.driver,
|
||||
field: "private-key",
|
||||
provider: input.secretProvider,
|
||||
value: privateKey,
|
||||
actor: input.actor,
|
||||
});
|
||||
@@ -404,6 +453,7 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
|
||||
companyId: input.companyId,
|
||||
environmentName: input.environmentName,
|
||||
driver: input.driver,
|
||||
secretProvider: input.secretProvider,
|
||||
config: {
|
||||
provider: parsed.data.provider,
|
||||
...validated.normalizedConfig,
|
||||
@@ -442,10 +492,15 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
|
||||
export async function resolveEnvironmentDriverConfigForRuntime(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
environment: Pick<Environment, "driver" | "config">,
|
||||
environment: Pick<Environment, "driver" | "config"> & Partial<Pick<Environment, "id">>,
|
||||
context?: { issueId?: string | null; heartbeatRunId?: string | null },
|
||||
): Promise<ParsedEnvironmentConfig> {
|
||||
const parsed = parseEnvironmentDriverConfig(environment);
|
||||
const secrets = secretService(db);
|
||||
const environmentId = environment.id;
|
||||
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef && !environmentId) {
|
||||
throw unprocessable("Runtime secret resolution requires an environment id");
|
||||
}
|
||||
|
||||
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) {
|
||||
return {
|
||||
@@ -456,6 +511,15 @@ export async function resolveEnvironmentDriverConfigForRuntime(
|
||||
companyId,
|
||||
parsed.config.privateKeySecretRef.secretId,
|
||||
parsed.config.privateKeySecretRef.version ?? "latest",
|
||||
{
|
||||
consumerType: "environment",
|
||||
consumerId: environmentId!,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
issueId: context?.issueId ?? null,
|
||||
heartbeatRunId: context?.heartbeatRunId ?? null,
|
||||
configPath: "privateKeySecretRef",
|
||||
},
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -469,6 +533,11 @@ export async function resolveEnvironmentDriverConfigForRuntime(
|
||||
companyId,
|
||||
config: parsed.config as Record<string, unknown>,
|
||||
schema: await getSandboxProviderConfigSchema(db, parsed.config.provider),
|
||||
context: {
|
||||
consumerId: environmentId!,
|
||||
issueId: context?.issueId ?? null,
|
||||
heartbeatRunId: context?.heartbeatRunId ?? null,
|
||||
},
|
||||
}) as SandboxEnvironmentConfig,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,20 +58,19 @@ export async function resolveEnvironmentExecutionTarget(input: {
|
||||
? input.leaseMetadata.remoteCwd.trim()
|
||||
: DEFAULT_SANDBOX_REMOTE_CWD;
|
||||
const timeoutMs = "timeoutMs" in parsed.config ? parsed.config.timeoutMs : null;
|
||||
const paperclipApiUrl =
|
||||
typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0
|
||||
? input.leaseMetadata.paperclipApiUrl.trim()
|
||||
const shellCommand =
|
||||
input.leaseMetadata?.shellCommand === "bash" || input.leaseMetadata?.shellCommand === "sh"
|
||||
? input.leaseMetadata.shellCommand
|
||||
: null;
|
||||
|
||||
return {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: parsed.config.provider,
|
||||
shellCommand,
|
||||
remoteCwd,
|
||||
environmentId: input.environment.id ?? null,
|
||||
leaseId: input.leaseId ?? null,
|
||||
paperclipApiUrl,
|
||||
paperclipTransport: paperclipApiUrl ? "direct" : "bridge",
|
||||
timeoutMs,
|
||||
runner: input.environmentRuntime && input.lease
|
||||
? {
|
||||
@@ -138,10 +137,6 @@ export async function resolveEnvironmentExecutionTarget(input: {
|
||||
environmentId: input.environment.id ?? null,
|
||||
leaseId: input.leaseId ?? null,
|
||||
remoteCwd,
|
||||
paperclipApiUrl:
|
||||
typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0
|
||||
? input.leaseMetadata.paperclipApiUrl.trim()
|
||||
: null,
|
||||
spec: {
|
||||
host: parsed.config.host,
|
||||
port: parsed.config.port,
|
||||
@@ -151,10 +146,6 @@ export async function resolveEnvironmentExecutionTarget(input: {
|
||||
knownHosts: parsed.config.knownHosts,
|
||||
strictHostKeyChecking: parsed.config.strictHostKeyChecking,
|
||||
remoteCwd,
|
||||
paperclipApiUrl:
|
||||
typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0
|
||||
? input.leaseMetadata.paperclipApiUrl.trim()
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { environmentLeases } from "@paperclipai/db";
|
||||
@@ -14,7 +15,7 @@ import type {
|
||||
PluginEnvironmentLease,
|
||||
PluginEnvironmentRealizeWorkspaceResult,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import { ensureSshWorkspaceReady, findReachablePaperclipApiUrlOverSsh } from "@paperclipai/adapter-utils/ssh";
|
||||
import { ensureSshWorkspaceReady } from "@paperclipai/adapter-utils/ssh";
|
||||
import { environmentService } from "./environments.js";
|
||||
import {
|
||||
parseEnvironmentDriverConfig,
|
||||
@@ -102,7 +103,13 @@ export interface EnvironmentDriverAcquireInput {
|
||||
companyId: string;
|
||||
environment: Environment;
|
||||
issueId: string | null;
|
||||
heartbeatRunId: string;
|
||||
/**
|
||||
* UUID of the owning heartbeat run, or null for ad-hoc invocations
|
||||
* (e.g. operator-initiated `Test` probes) that are not tied to a run.
|
||||
* Null leases must be released by id via `getDriver(...).releaseRunLease`
|
||||
* since `releaseRunLeases(heartbeatRunId)` cannot find them.
|
||||
*/
|
||||
heartbeatRunId: string | null;
|
||||
executionWorkspaceId: string | null;
|
||||
executionWorkspaceMode: ExecutionWorkspace["mode"] | null;
|
||||
}
|
||||
@@ -113,6 +120,24 @@ export interface EnvironmentDriverReleaseInput {
|
||||
status: Extract<EnvironmentLeaseStatus, "released" | "expired" | "failed">;
|
||||
}
|
||||
|
||||
function resolvePluginSandboxRpcTimeoutMs(config: Record<string, unknown>): number | undefined {
|
||||
const timeoutCandidates = [
|
||||
typeof config.timeoutMs === "number" ? config.timeoutMs : undefined,
|
||||
typeof config.bridgeRequestTimeoutMs === "number" ? config.bridgeRequestTimeoutMs : undefined,
|
||||
]
|
||||
.filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.trunc(value));
|
||||
|
||||
if (timeoutCandidates.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return resolvePluginExecuteRpcTimeoutMs({
|
||||
requestedTimeoutMs: Math.max(...timeoutCandidates),
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
export interface EnvironmentDriverLeaseInput {
|
||||
environment: Environment;
|
||||
lease: EnvironmentLease;
|
||||
@@ -221,33 +246,15 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver {
|
||||
driver: "ssh",
|
||||
|
||||
async acquireRunLease(input) {
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment);
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment, {
|
||||
issueId: input.issueId,
|
||||
heartbeatRunId: input.heartbeatRunId,
|
||||
});
|
||||
if (parsed.driver !== "ssh") {
|
||||
throw new Error(`Expected SSH environment config for driver "${input.environment.driver}".`);
|
||||
}
|
||||
|
||||
const { remoteCwd } = await ensureSshWorkspaceReady(parsed.config);
|
||||
const candidateUrls = (() => {
|
||||
const raw = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
const paperclipApiUrl = await findReachablePaperclipApiUrlOverSsh({
|
||||
config: parsed.config,
|
||||
candidates: candidateUrls,
|
||||
});
|
||||
if (!paperclipApiUrl) {
|
||||
throw new Error(
|
||||
`SSH environment ${parsed.config.username}@${parsed.config.host} could not reach any Paperclip API candidates.`,
|
||||
);
|
||||
}
|
||||
return await environmentsSvc.acquireLease({
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environment.id,
|
||||
@@ -265,7 +272,6 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver {
|
||||
username: parsed.config.username,
|
||||
remoteWorkspacePath: parsed.config.remoteWorkspacePath,
|
||||
remoteCwd,
|
||||
paperclipApiUrl,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -361,6 +367,7 @@ function createSandboxEnvironmentDriver(
|
||||
const metadataConfig = sandboxConfigFromLeaseMetadataLoose(input.lease);
|
||||
if (metadataConfig && metadataConfig.provider === input.provider) {
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.lease.companyId, {
|
||||
id: input.environment.id,
|
||||
driver: "sandbox",
|
||||
config: sandboxConfigForLeaseMetadata(metadataConfig),
|
||||
});
|
||||
@@ -396,7 +403,10 @@ function createSandboxEnvironmentDriver(
|
||||
|
||||
async acquireRunLease(input) {
|
||||
const storedParsed = parseEnvironmentDriverConfig(input.environment);
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment);
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, input.companyId, input.environment, {
|
||||
issueId: input.issueId,
|
||||
heartbeatRunId: input.heartbeatRunId,
|
||||
});
|
||||
if (parsed.driver !== "sandbox" || storedParsed.driver !== "sandbox") {
|
||||
throw new Error(`Expected sandbox environment config for driver "${input.environment.driver}".`);
|
||||
}
|
||||
@@ -429,14 +439,21 @@ function createSandboxEnvironmentDriver(
|
||||
|
||||
const workerConfig = stripSandboxProviderEnvelope(parsed.config);
|
||||
const storedConfig = storedParsed.config;
|
||||
const existingLeases = parsed.config.reuseLease
|
||||
? await environmentsSvc.listLeases(input.environment.id)
|
||||
// Ad-hoc tests (heartbeatRunId === null) must never resume an existing
|
||||
// provider lease. If they did, releasing the test lease at the end of
|
||||
// the probe would tear down the live heartbeat run that owns it.
|
||||
// We also filter out leases whose policy is not reuse_by_environment
|
||||
// so any non-reusable lease (including ad-hoc test leases that
|
||||
// landed in the table from older code paths) cannot be matched.
|
||||
const reusableExistingLeases = parsed.config.reuseLease && input.heartbeatRunId !== null
|
||||
? (await environmentsSvc.listLeases(input.environment.id))
|
||||
.filter((lease) => lease.leasePolicy === "reuse_by_environment")
|
||||
: [];
|
||||
const reusableProviderLeaseId = parsed.config.reuseLease
|
||||
? findReusableSandboxLeaseId({ config: storedConfig, leases: existingLeases })
|
||||
const reusableProviderLeaseId = parsed.config.reuseLease && input.heartbeatRunId !== null
|
||||
? findReusableSandboxLeaseId({ config: storedConfig, leases: reusableExistingLeases })
|
||||
: null;
|
||||
const reusableLease = reusableProviderLeaseId
|
||||
? existingLeases.find((lease) => lease.providerLeaseId === reusableProviderLeaseId)
|
||||
? reusableExistingLeases.find((lease) => lease.providerLeaseId === reusableProviderLeaseId)
|
||||
: null;
|
||||
|
||||
const providerLease = reusableLease?.providerLeaseId
|
||||
@@ -447,10 +464,12 @@ function createSandboxEnvironmentDriver(
|
||||
driverKey: parsed.config.provider,
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environment.id,
|
||||
issueId: input.issueId,
|
||||
config: workerConfig,
|
||||
providerLeaseId: reusableLease.providerLeaseId,
|
||||
leaseMetadata: reusableLease.metadata ?? undefined,
|
||||
},
|
||||
resolvePluginSandboxRpcTimeoutMs(workerConfig),
|
||||
).then((resumed) =>
|
||||
typeof resumed.providerLeaseId === "string" && resumed.providerLeaseId.length > 0
|
||||
? resumed
|
||||
@@ -464,13 +483,21 @@ function createSandboxEnvironmentDriver(
|
||||
driverKey: parsed.config.provider,
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environment.id,
|
||||
issueId: input.issueId,
|
||||
config: workerConfig,
|
||||
runId: input.heartbeatRunId,
|
||||
// Plugin SDK requires a string; ad-hoc test leases use a fresh
|
||||
// UUID so providers that validate or persist the runId still see
|
||||
// a well-formed identifier.
|
||||
runId: input.heartbeatRunId ?? randomUUID(),
|
||||
workspaceMode: input.executionWorkspaceMode ?? undefined,
|
||||
},
|
||||
resolvePluginSandboxRpcTimeoutMs(workerConfig),
|
||||
);
|
||||
|
||||
const resolvedLeasePolicy = parsed.config.reuseLease
|
||||
// Ad-hoc test leases are never publishable for reuse: storing them
|
||||
// as `reuse_by_environment` would let a concurrent heartbeat resume
|
||||
// the test's provider lease and lose its sandbox when the test ends.
|
||||
const resolvedLeasePolicy = parsed.config.reuseLease && input.heartbeatRunId !== null
|
||||
? "reuse_by_environment"
|
||||
: "ephemeral";
|
||||
|
||||
@@ -499,22 +526,33 @@ function createSandboxEnvironmentDriver(
|
||||
});
|
||||
}
|
||||
|
||||
// Built-in sandbox provider path.
|
||||
const reusableProviderLeaseId = parsed.config.reuseLease
|
||||
// Built-in sandbox provider path. Same guard as the plugin-backed path:
|
||||
// ad-hoc tests (heartbeatRunId === null) must never resume an existing
|
||||
// provider lease, or releasing the test lease will terminate the live
|
||||
// heartbeat run that shares it. Filter to leases whose policy is
|
||||
// reuse_by_environment so non-reusable rows can never be matched.
|
||||
const reusableProviderLeaseId = parsed.config.reuseLease && input.heartbeatRunId !== null
|
||||
? (await environmentsSvc
|
||||
.listLeases(input.environment.id)
|
||||
.then((leases) => findReusableSandboxLeaseId({ config: parsed.config, leases })))
|
||||
.then((leases) =>
|
||||
findReusableSandboxLeaseId({
|
||||
config: parsed.config,
|
||||
leases: leases.filter((lease) => lease.leasePolicy === "reuse_by_environment"),
|
||||
}),
|
||||
))
|
||||
: null;
|
||||
|
||||
const providerLease = await acquireSandboxProviderLease({
|
||||
config: parsed.config,
|
||||
environmentId: input.environment.id,
|
||||
heartbeatRunId: input.heartbeatRunId,
|
||||
heartbeatRunId: input.heartbeatRunId ?? randomUUID(),
|
||||
issueId: input.issueId,
|
||||
reusableProviderLeaseId,
|
||||
});
|
||||
|
||||
const resolvedLeasePolicy = parsed.config.reuseLease
|
||||
// Same ephemeral-policy-for-tests guard as the plugin-backed path:
|
||||
// ad-hoc test leases must not be publishable for reuse.
|
||||
const resolvedLeasePolicy = parsed.config.reuseLease && input.heartbeatRunId !== null
|
||||
? "reuse_by_environment"
|
||||
: "ephemeral";
|
||||
|
||||
@@ -553,6 +591,7 @@ function createSandboxEnvironmentDriver(
|
||||
|
||||
const parsed = metadataConfig
|
||||
? await resolveEnvironmentDriverConfigForRuntime(db, input.lease.companyId, {
|
||||
id: input.environment.id,
|
||||
driver: "sandbox",
|
||||
config: metadataConfig as unknown as Record<string, unknown>,
|
||||
})
|
||||
@@ -599,6 +638,7 @@ function createSandboxEnvironmentDriver(
|
||||
driverKey: providerKey,
|
||||
companyId: input.lease.companyId,
|
||||
environmentId: input.environment.id,
|
||||
issueId: input.lease.issueId,
|
||||
config: stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig),
|
||||
lease: {
|
||||
providerLeaseId: input.lease.providerLeaseId,
|
||||
@@ -606,7 +646,7 @@ function createSandboxEnvironmentDriver(
|
||||
expiresAt: input.lease.expiresAt?.toISOString() ?? null,
|
||||
},
|
||||
workspace: input.workspace,
|
||||
});
|
||||
}, resolvePluginSandboxRpcTimeoutMs(stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,6 +683,7 @@ function createSandboxEnvironmentDriver(
|
||||
driverKey: providerKey,
|
||||
companyId: input.lease.companyId,
|
||||
environmentId: input.environment.id,
|
||||
issueId: input.lease.issueId,
|
||||
config: sanitizedConfig,
|
||||
lease: {
|
||||
providerLeaseId: input.lease.providerLeaseId,
|
||||
@@ -684,10 +725,11 @@ function createSandboxEnvironmentDriver(
|
||||
driverKey: providerKey,
|
||||
companyId: input.lease.companyId,
|
||||
environmentId: input.environment.id,
|
||||
issueId: input.lease.issueId,
|
||||
config: stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig),
|
||||
providerLeaseId: input.lease.providerLeaseId,
|
||||
leaseMetadata: metadata,
|
||||
});
|
||||
}, resolvePluginSandboxRpcTimeoutMs(stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig)));
|
||||
} catch {
|
||||
cleanupStatus = "failed";
|
||||
}
|
||||
@@ -726,6 +768,7 @@ const INTERNAL_PLUGIN_SANDBOX_CONFIG_KEYS = new Set([
|
||||
"pluginId",
|
||||
"pluginKey",
|
||||
"providerMetadata",
|
||||
"shellCommand",
|
||||
"sandboxProviderPlugin",
|
||||
]);
|
||||
|
||||
@@ -851,8 +894,9 @@ function createPluginEnvironmentDriver(
|
||||
driverKey: parsed.config.driverKey,
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environment.id,
|
||||
issueId: input.issueId,
|
||||
config: parsed.config.driverConfig,
|
||||
runId: input.heartbeatRunId,
|
||||
runId: input.heartbeatRunId ?? randomUUID(),
|
||||
workspaceMode: input.executionWorkspaceMode ?? undefined,
|
||||
});
|
||||
|
||||
@@ -883,6 +927,7 @@ function createPluginEnvironmentDriver(
|
||||
driverKey,
|
||||
companyId: input.lease.companyId,
|
||||
environmentId: input.environment.id,
|
||||
issueId: input.lease.issueId,
|
||||
config: driverConfig,
|
||||
providerLeaseId: input.lease.providerLeaseId,
|
||||
leaseMetadata: input.lease.metadata ?? undefined,
|
||||
@@ -903,6 +948,7 @@ function createPluginEnvironmentDriver(
|
||||
workerManager,
|
||||
companyId: input.lease.companyId,
|
||||
environmentId: input.environment.id,
|
||||
issueId: input.lease.issueId,
|
||||
config: {
|
||||
pluginKey,
|
||||
driverKey,
|
||||
@@ -923,6 +969,7 @@ function createPluginEnvironmentDriver(
|
||||
workerManager,
|
||||
companyId: input.lease.companyId,
|
||||
environmentId: input.environment.id,
|
||||
issueId: input.lease.issueId,
|
||||
config: {
|
||||
pluginKey,
|
||||
driverKey,
|
||||
@@ -953,6 +1000,7 @@ function createPluginEnvironmentDriver(
|
||||
driverKey,
|
||||
companyId: input.lease.companyId,
|
||||
environmentId: input.environment.id,
|
||||
issueId: input.lease.issueId,
|
||||
config: driverConfig,
|
||||
lease: {
|
||||
providerLeaseId: input.lease.providerLeaseId,
|
||||
@@ -983,6 +1031,7 @@ function createPluginEnvironmentDriver(
|
||||
driverKey,
|
||||
companyId: input.lease.companyId,
|
||||
environmentId: input.environment.id,
|
||||
issueId: input.lease.issueId,
|
||||
config: driverConfig,
|
||||
lease: {
|
||||
providerLeaseId: input.lease.providerLeaseId,
|
||||
@@ -1061,7 +1110,8 @@ export function environmentRuntimeService(
|
||||
companyId: string;
|
||||
environment: Environment;
|
||||
issueId: string | null;
|
||||
heartbeatRunId: string;
|
||||
/** Null for ad-hoc invocations (e.g. operator-initiated `Test` probes). */
|
||||
heartbeatRunId: string | null;
|
||||
persistedExecutionWorkspace: Pick<ExecutionWorkspace, "id" | "mode"> | null;
|
||||
}): Promise<EnvironmentRuntimeLeaseRecord> {
|
||||
if (input.environment.status !== "active") {
|
||||
|
||||
@@ -89,6 +89,9 @@ type FeedbackTargetRecord = {
|
||||
createdAt: Date;
|
||||
authorAgentId: string | null;
|
||||
authorUserId: string | null;
|
||||
authorType?: string | null;
|
||||
presentation?: unknown;
|
||||
metadata?: unknown;
|
||||
createdByRunId: string | null;
|
||||
documentId: string | null;
|
||||
documentKey: string | null;
|
||||
@@ -797,6 +800,9 @@ async function resolveFeedbackTarget(
|
||||
companyId: issueComments.companyId,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
authorType: issueComments.authorType,
|
||||
presentation: issueComments.presentation,
|
||||
metadata: issueComments.metadata,
|
||||
createdByRunId: issueComments.createdByRunId,
|
||||
body: issueComments.body,
|
||||
createdAt: issueComments.createdAt,
|
||||
@@ -820,6 +826,9 @@ async function resolveFeedbackTarget(
|
||||
createdAt: targetComment.createdAt,
|
||||
authorAgentId: targetComment.authorAgentId,
|
||||
authorUserId: targetComment.authorUserId,
|
||||
authorType: targetComment.authorType ?? (targetComment.authorAgentId ? "agent" : targetComment.authorUserId ? "user" : "system"),
|
||||
presentation: targetComment.presentation ?? null,
|
||||
metadata: targetComment.metadata ?? null,
|
||||
createdByRunId: targetComment.createdByRunId ?? null,
|
||||
documentId: null,
|
||||
documentKey: null,
|
||||
@@ -833,6 +842,9 @@ async function resolveFeedbackTarget(
|
||||
createdAt: targetComment.createdAt.toISOString(),
|
||||
authorAgentId: targetComment.authorAgentId,
|
||||
authorUserId: targetComment.authorUserId,
|
||||
authorType: targetComment.authorType ?? (targetComment.authorAgentId ? "agent" : targetComment.authorUserId ? "user" : "system"),
|
||||
presentation: targetComment.presentation ?? null,
|
||||
metadata: targetComment.metadata ?? null,
|
||||
createdByRunId: targetComment.createdByRunId ?? null,
|
||||
issuePath,
|
||||
targetPath: issuePath ? `${issuePath}#comment-${targetComment.id}` : null,
|
||||
@@ -918,6 +930,9 @@ async function listIssueContextItems(
|
||||
createdAt: issueComments.createdAt,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
authorType: issueComments.authorType,
|
||||
presentation: issueComments.presentation,
|
||||
metadata: issueComments.metadata,
|
||||
createdByRunId: issueComments.createdByRunId,
|
||||
})
|
||||
.from(issueComments)
|
||||
@@ -952,6 +967,9 @@ async function listIssueContextItems(
|
||||
createdAt: row.createdAt,
|
||||
authorAgentId: row.authorAgentId,
|
||||
authorUserId: row.authorUserId,
|
||||
authorType: row.authorType ?? (row.authorAgentId ? "agent" : row.authorUserId ? "user" : "system"),
|
||||
presentation: row.presentation ?? null,
|
||||
metadata: row.metadata ?? null,
|
||||
createdByRunId: row.createdByRunId ?? null,
|
||||
documentId: null,
|
||||
documentKey: null,
|
||||
@@ -1023,6 +1041,9 @@ async function buildIssueContext(
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
authorAgentId: item.authorAgentId,
|
||||
authorUserId: item.authorUserId,
|
||||
authorType: item.authorType ?? null,
|
||||
presentation: item.presentation ?? null,
|
||||
metadata: item.metadata ?? null,
|
||||
createdByRunId: item.createdByRunId,
|
||||
documentKey: item.documentKey,
|
||||
documentTitle: item.documentTitle,
|
||||
|
||||
@@ -64,6 +64,40 @@ describe("heartbeat stop metadata", () => {
|
||||
).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("normalizes max-turn exhaustion stop reasons", () => {
|
||||
expect(
|
||||
buildHeartbeatRunStopMetadata({
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
outcome: "failed",
|
||||
errorCode: "turn_limit_exhausted",
|
||||
errorMessage: "turn limit reached",
|
||||
}).stopReason,
|
||||
).toBe("max_turns_exhausted");
|
||||
|
||||
const merged = mergeHeartbeatRunStopMetadata(
|
||||
{ stopReason: "turn_limit_exhausted" },
|
||||
buildHeartbeatRunStopMetadata({
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
outcome: "failed",
|
||||
errorCode: "adapter_failed",
|
||||
}),
|
||||
);
|
||||
expect(merged.stopReason).toBe("max_turns_exhausted");
|
||||
});
|
||||
|
||||
it("prioritizes succeeded outcome over inconsistent max-turn error metadata", () => {
|
||||
expect(
|
||||
buildHeartbeatRunStopMetadata({
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
outcome: "succeeded",
|
||||
errorCode: "max_turns_exhausted",
|
||||
}).stopReason,
|
||||
).toBe("completed");
|
||||
});
|
||||
|
||||
it("preserves existing result fields when merging stop metadata", () => {
|
||||
const result = mergeHeartbeatRunStopMetadata(
|
||||
{ summary: "done" },
|
||||
|
||||
@@ -6,6 +6,7 @@ export type HeartbeatRunStopReason =
|
||||
| "cancelled"
|
||||
| "budget_paused"
|
||||
| "paused"
|
||||
| "max_turns_exhausted"
|
||||
| "process_lost"
|
||||
| "adapter_failed";
|
||||
|
||||
@@ -40,6 +41,12 @@ function defaultTimeoutSecForAdapter(adapterType: string) {
|
||||
return adapterType === "openclaw_gateway" ? 120 : 0;
|
||||
}
|
||||
|
||||
export function normalizeMaxTurnStopReason(value: unknown): Extract<HeartbeatRunStopReason, "max_turns_exhausted"> | null {
|
||||
return value === "max_turns_exhausted" || value === "turn_limit_exhausted"
|
||||
? "max_turns_exhausted"
|
||||
: null;
|
||||
}
|
||||
|
||||
export function resolveHeartbeatRunTimeoutPolicy(
|
||||
adapterType: string,
|
||||
adapterConfig: Record<string, unknown> | null | undefined,
|
||||
@@ -76,6 +83,8 @@ export function inferHeartbeatRunStopReason(input: {
|
||||
errorMessage?: string | null;
|
||||
}): HeartbeatRunStopReason {
|
||||
if (input.outcome === "succeeded") return "completed";
|
||||
const maxTurnStopReason = normalizeMaxTurnStopReason(input.errorCode);
|
||||
if (maxTurnStopReason) return maxTurnStopReason;
|
||||
if (input.outcome === "timed_out") return "timeout";
|
||||
if (input.outcome === "failed" && input.errorCode === "process_lost") return "process_lost";
|
||||
if (input.outcome === "cancelled") {
|
||||
@@ -107,9 +116,10 @@ export function mergeHeartbeatRunStopMetadata(
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
metadata: HeartbeatRunStopMetadata,
|
||||
): Record<string, unknown> {
|
||||
const existingMaxTurnStopReason = normalizeMaxTurnStopReason(resultJson?.stopReason);
|
||||
return {
|
||||
...(resultJson ?? {}),
|
||||
stopReason: metadata.stopReason,
|
||||
stopReason: existingMaxTurnStopReason ?? metadata.stopReason,
|
||||
effectiveTimeoutSec: metadata.effectiveTimeoutSec,
|
||||
timeoutConfigured: metadata.timeoutConfigured,
|
||||
timeoutSource: metadata.timeoutSource,
|
||||
|
||||
+2117
-164
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
export { companyService } from "./companies.js";
|
||||
export { companySearchService } from "./company-search.js";
|
||||
export { feedbackService } from "./feedback.js";
|
||||
export { companySkillService } from "./company-skills.js";
|
||||
export { agentService, deduplicateAgentName } from "./agents.js";
|
||||
|
||||
@@ -9,6 +9,8 @@ export const ISSUE_CONTINUATION_SUMMARY_TITLE = "Continuation Summary";
|
||||
export const ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS = 8_000;
|
||||
const SUMMARY_SECTION_MAX_CHARS = 1_200;
|
||||
const PATH_CANDIDATE_RE = /(?:^|[\s`"'(])((?:server|ui|packages|doc|scripts|\.github)\/[A-Za-z0-9._/-]+)/g;
|
||||
const WAITING_FOR_REVIEW_OR_APPROVAL_RE =
|
||||
/\bwait(?:ing)? for\b.{0,160}\b(?:review(?:er)?(?: feedback)?|approval|board|human|user|operator)\b/i;
|
||||
|
||||
type IssueSummaryInput = {
|
||||
id: string;
|
||||
@@ -120,6 +122,16 @@ function extractPreviousNextAction(previousBody: string | null | undefined) {
|
||||
.find(Boolean) ?? null;
|
||||
}
|
||||
|
||||
export function extractContinuationSummaryNextAction(body: string | null | undefined) {
|
||||
return extractPreviousNextAction(body);
|
||||
}
|
||||
|
||||
export function continuationSummaryParksExecutor(body: string | null | undefined) {
|
||||
const nextAction = extractContinuationSummaryNextAction(body);
|
||||
if (!nextAction) return false;
|
||||
return WAITING_FOR_REVIEW_OR_APPROVAL_RE.test(nextAction);
|
||||
}
|
||||
|
||||
export function buildContinuationSummaryMarkdown(input: {
|
||||
issue: IssueSummaryInput;
|
||||
run: RunSummaryInput;
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { IssueExecutionDecision, IssueExecutionPolicy, IssueExecutionStage, IssueExecutionStagePrincipal, IssueExecutionState } from "@paperclipai/shared";
|
||||
import type {
|
||||
IssueExecutionDecision,
|
||||
IssueExecutionMonitorClearReason,
|
||||
IssueExecutionMonitorPolicy,
|
||||
IssueExecutionMonitorState,
|
||||
IssueExecutionPolicy,
|
||||
IssueExecutionStage,
|
||||
IssueExecutionStagePrincipal,
|
||||
IssueExecutionState,
|
||||
IssueMonitorScheduledBy,
|
||||
} from "@paperclipai/shared";
|
||||
import { issueExecutionPolicySchema, issueExecutionStateSchema } from "@paperclipai/shared";
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
@@ -12,6 +22,12 @@ type IssueLike = AssigneeLike & {
|
||||
status: string;
|
||||
executionPolicy?: IssueExecutionPolicy | Record<string, unknown> | null;
|
||||
executionState?: IssueExecutionState | Record<string, unknown> | null;
|
||||
monitorNextCheckAt?: Date | null;
|
||||
monitorWakeRequestedAt?: Date | null;
|
||||
monitorLastTriggeredAt?: Date | null;
|
||||
monitorAttemptCount?: number | null;
|
||||
monitorNotes?: string | null;
|
||||
monitorScheduledBy?: string | null;
|
||||
};
|
||||
|
||||
type ActorLike = {
|
||||
@@ -27,11 +43,13 @@ type RequestedAssigneePatch = {
|
||||
type TransitionInput = {
|
||||
issue: IssueLike;
|
||||
policy: IssueExecutionPolicy | null;
|
||||
previousPolicy?: IssueExecutionPolicy | null;
|
||||
requestedStatus?: string;
|
||||
requestedAssigneePatch: RequestedAssigneePatch;
|
||||
actor: ActorLike;
|
||||
commentBody?: string | null;
|
||||
reviewRequest?: IssueExecutionState["reviewRequest"] | null;
|
||||
monitorExplicitlyUpdated?: boolean;
|
||||
};
|
||||
|
||||
type TransitionResult = {
|
||||
@@ -43,6 +61,280 @@ type TransitionResult = {
|
||||
const COMPLETED_STATUS: IssueExecutionState["status"] = "completed";
|
||||
const PENDING_STATUS: IssueExecutionState["status"] = "pending";
|
||||
const CHANGES_REQUESTED_STATUS: IssueExecutionState["status"] = "changes_requested";
|
||||
const MONITOR_INVALID_MESSAGE = "Monitor can only be scheduled on issues assigned to an agent in in_progress or in_review";
|
||||
const MONITOR_BOUNDS_EXHAUSTED_MESSAGE = "Monitor bounds are already exhausted";
|
||||
export const REDACTED_ISSUE_MONITOR_EXTERNAL_REF = "[redacted]";
|
||||
|
||||
function normalizeMonitorNotes(notes: string | null | undefined) {
|
||||
if (typeof notes !== "string") return null;
|
||||
const trimmed = notes.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeMonitorText(value: string | null | undefined) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function redactIssueMonitorExternalRef(value: string | null | undefined) {
|
||||
return normalizeMonitorText(value) ? REDACTED_ISSUE_MONITOR_EXTERNAL_REF : null;
|
||||
}
|
||||
|
||||
function monitorMetadataFromPolicy(monitor: IssueExecutionMonitorPolicy) {
|
||||
return {
|
||||
kind: monitor.kind ?? null,
|
||||
serviceName: normalizeMonitorText(monitor.serviceName),
|
||||
externalRef: redactIssueMonitorExternalRef(monitor.externalRef),
|
||||
timeoutAt: monitor.timeoutAt ?? null,
|
||||
maxAttempts: monitor.maxAttempts ?? null,
|
||||
recoveryPolicy: monitor.recoveryPolicy ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function monitorMetadataFromState(state: IssueExecutionMonitorState | null | undefined) {
|
||||
return {
|
||||
kind: state?.kind ?? null,
|
||||
serviceName: normalizeMonitorText(state?.serviceName),
|
||||
externalRef: redactIssueMonitorExternalRef(state?.externalRef),
|
||||
timeoutAt: state?.timeoutAt ?? null,
|
||||
maxAttempts: state?.maxAttempts ?? null,
|
||||
recoveryPolicy: state?.recoveryPolicy ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function blankExecutionState(): IssueExecutionState {
|
||||
return {
|
||||
status: "idle",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: null,
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
monitor: null,
|
||||
};
|
||||
}
|
||||
|
||||
function isoString(value: Date | string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
return value;
|
||||
}
|
||||
|
||||
function monitorStatesEqual(left: IssueExecutionMonitorState | null, right: IssueExecutionMonitorState | null): boolean {
|
||||
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
|
||||
}
|
||||
|
||||
function executionStateWithMonitor(
|
||||
stageState: IssueExecutionState | null,
|
||||
monitorState: IssueExecutionMonitorState | null,
|
||||
): IssueExecutionState | null {
|
||||
if (!stageState && !monitorState) return null;
|
||||
const base = stageState ? { ...stageState } : blankExecutionState();
|
||||
return {
|
||||
...base,
|
||||
monitor: monitorState,
|
||||
};
|
||||
}
|
||||
|
||||
function derivePersistedMonitorState(input: {
|
||||
issue: IssueLike;
|
||||
state: IssueExecutionState | null;
|
||||
policy: IssueExecutionPolicy | null;
|
||||
}): IssueExecutionMonitorState | null {
|
||||
const fromState = input.state?.monitor ?? null;
|
||||
const scheduledMonitor = input.policy?.monitor ?? null;
|
||||
const nextCheckAt = isoString(input.issue.monitorNextCheckAt) ?? scheduledMonitor?.nextCheckAt ?? fromState?.nextCheckAt ?? null;
|
||||
const lastTriggeredAt = isoString(input.issue.monitorLastTriggeredAt) ?? fromState?.lastTriggeredAt ?? null;
|
||||
const attemptCount = input.issue.monitorAttemptCount ?? fromState?.attemptCount ?? 0;
|
||||
const notes = scheduledMonitor?.notes ?? normalizeMonitorNotes(input.issue.monitorNotes) ?? fromState?.notes ?? null;
|
||||
const scheduledByRaw = input.issue.monitorScheduledBy ?? scheduledMonitor?.scheduledBy ?? fromState?.scheduledBy ?? null;
|
||||
const scheduledBy =
|
||||
scheduledByRaw === "assignee" || scheduledByRaw === "board" ? scheduledByRaw : null;
|
||||
const metadata = scheduledMonitor ? monitorMetadataFromPolicy(scheduledMonitor) : monitorMetadataFromState(fromState);
|
||||
|
||||
if (nextCheckAt) {
|
||||
return {
|
||||
status: "scheduled",
|
||||
nextCheckAt,
|
||||
lastTriggeredAt,
|
||||
attemptCount,
|
||||
notes,
|
||||
scheduledBy,
|
||||
...metadata,
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (fromState?.status === "cleared") {
|
||||
return {
|
||||
...fromState,
|
||||
notes,
|
||||
scheduledBy,
|
||||
attemptCount,
|
||||
lastTriggeredAt,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
if (fromState?.status === "triggered" || lastTriggeredAt || attemptCount > 0) {
|
||||
return {
|
||||
status: "triggered",
|
||||
nextCheckAt: null,
|
||||
lastTriggeredAt,
|
||||
attemptCount,
|
||||
notes,
|
||||
scheduledBy,
|
||||
...metadata,
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildScheduledMonitorState(
|
||||
previous: IssueExecutionMonitorState | null,
|
||||
monitor: IssueExecutionMonitorPolicy,
|
||||
): IssueExecutionMonitorState {
|
||||
return {
|
||||
status: "scheduled",
|
||||
nextCheckAt: monitor.nextCheckAt,
|
||||
lastTriggeredAt: previous?.lastTriggeredAt ?? null,
|
||||
attemptCount: previous?.attemptCount ?? 0,
|
||||
notes: monitor.notes ?? null,
|
||||
scheduledBy: monitor.scheduledBy,
|
||||
...monitorMetadataFromPolicy(monitor),
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTriggeredMonitorState(input: {
|
||||
previous: IssueExecutionMonitorState | null;
|
||||
triggeredAt: Date;
|
||||
}): IssueExecutionMonitorState {
|
||||
return {
|
||||
status: "triggered",
|
||||
nextCheckAt: null,
|
||||
lastTriggeredAt: input.triggeredAt.toISOString(),
|
||||
attemptCount: (input.previous?.attemptCount ?? 0) + 1,
|
||||
notes: input.previous?.notes ?? null,
|
||||
scheduledBy: input.previous?.scheduledBy ?? null,
|
||||
...monitorMetadataFromState(input.previous),
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClearedMonitorState(input: {
|
||||
previous: IssueExecutionMonitorState | null;
|
||||
clearReason: IssueExecutionMonitorClearReason;
|
||||
clearedAt: Date;
|
||||
}): IssueExecutionMonitorState {
|
||||
return {
|
||||
status: "cleared",
|
||||
nextCheckAt: null,
|
||||
lastTriggeredAt: input.previous?.lastTriggeredAt ?? null,
|
||||
attemptCount: input.previous?.attemptCount ?? 0,
|
||||
notes: input.previous?.notes ?? null,
|
||||
scheduledBy: input.previous?.scheduledBy ?? null,
|
||||
...monitorMetadataFromState(input.previous),
|
||||
clearedAt: input.clearedAt.toISOString(),
|
||||
clearReason: input.clearReason,
|
||||
};
|
||||
}
|
||||
|
||||
function issueAllowsMonitor(status: string, assigneeAgentId: string | null, assigneeUserId: string | null) {
|
||||
return Boolean(assigneeAgentId) && !assigneeUserId && (status === "in_progress" || status === "in_review");
|
||||
}
|
||||
|
||||
function monitorClearReasonForIssue(
|
||||
status: string,
|
||||
assigneeAgentId: string | null,
|
||||
assigneeUserId: string | null,
|
||||
): IssueExecutionMonitorClearReason | null {
|
||||
if (status === "done") return "done";
|
||||
if (status === "cancelled") return "cancelled";
|
||||
if (!issueAllowsMonitor(status, assigneeAgentId, assigneeUserId)) {
|
||||
if (assigneeUserId || !assigneeAgentId) return "invalid_assignee";
|
||||
return "invalid_status";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseMonitorDate(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function exhaustedMonitorClearReason(input: {
|
||||
monitor: IssueExecutionMonitorPolicy;
|
||||
attemptCount: number;
|
||||
now: Date;
|
||||
}): IssueExecutionMonitorClearReason | null {
|
||||
const timeoutAt = parseMonitorDate(input.monitor.timeoutAt ?? null);
|
||||
if (timeoutAt && input.now.getTime() >= timeoutAt.getTime()) {
|
||||
return "timeout_exceeded";
|
||||
}
|
||||
const maxAttempts = input.monitor.maxAttempts ?? null;
|
||||
if (maxAttempts !== null && input.attemptCount >= maxAttempts) {
|
||||
return "max_attempts_exhausted";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nextAssigneeIds(input: {
|
||||
issue: IssueLike;
|
||||
requestedAssigneePatch: RequestedAssigneePatch;
|
||||
stagePatch: Record<string, unknown>;
|
||||
}) {
|
||||
const assigneeAgentId =
|
||||
input.stagePatch.assigneeAgentId !== undefined
|
||||
? (input.stagePatch.assigneeAgentId as string | null)
|
||||
: input.requestedAssigneePatch.assigneeAgentId !== undefined
|
||||
? input.requestedAssigneePatch.assigneeAgentId ?? null
|
||||
: input.issue.assigneeAgentId ?? null;
|
||||
const assigneeUserId =
|
||||
input.stagePatch.assigneeUserId !== undefined
|
||||
? (input.stagePatch.assigneeUserId as string | null)
|
||||
: input.requestedAssigneePatch.assigneeUserId !== undefined
|
||||
? input.requestedAssigneePatch.assigneeUserId ?? null
|
||||
: input.issue.assigneeUserId ?? null;
|
||||
return { assigneeAgentId, assigneeUserId };
|
||||
}
|
||||
|
||||
export function stripMonitorFromExecutionPolicy(policy: IssueExecutionPolicy | null): IssueExecutionPolicy | null {
|
||||
if (!policy) return null;
|
||||
if (!policy.monitor) return policy;
|
||||
if (policy.stages.length === 0) return null;
|
||||
return {
|
||||
mode: policy.mode,
|
||||
commentRequired: policy.commentRequired,
|
||||
stages: policy.stages,
|
||||
};
|
||||
}
|
||||
|
||||
export function setIssueExecutionPolicyMonitorScheduledBy(
|
||||
policy: IssueExecutionPolicy | null,
|
||||
scheduledBy: IssueMonitorScheduledBy,
|
||||
): IssueExecutionPolicy | null {
|
||||
if (!policy?.monitor) return policy;
|
||||
return {
|
||||
...policy,
|
||||
monitor: {
|
||||
...policy.monitor,
|
||||
scheduledBy,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeIssueExecutionPolicy(input: unknown): IssueExecutionPolicy | null {
|
||||
if (input == null) return null;
|
||||
@@ -81,12 +373,27 @@ export function normalizeIssueExecutionPolicy(input: unknown): IssueExecutionPol
|
||||
})
|
||||
.filter((stage): stage is NonNullable<typeof stage> => stage !== null);
|
||||
|
||||
if (stages.length === 0) return null;
|
||||
const monitor = parsed.data.monitor
|
||||
? {
|
||||
nextCheckAt: parsed.data.monitor.nextCheckAt,
|
||||
notes: normalizeMonitorNotes(parsed.data.monitor.notes),
|
||||
scheduledBy: parsed.data.monitor.scheduledBy,
|
||||
kind: parsed.data.monitor.kind ?? null,
|
||||
serviceName: normalizeMonitorText(parsed.data.monitor.serviceName),
|
||||
externalRef: redactIssueMonitorExternalRef(parsed.data.monitor.externalRef),
|
||||
timeoutAt: parsed.data.monitor.timeoutAt ?? null,
|
||||
maxAttempts: parsed.data.monitor.maxAttempts ?? null,
|
||||
recoveryPolicy: parsed.data.monitor.recoveryPolicy ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (stages.length === 0 && !monitor) return null;
|
||||
|
||||
return {
|
||||
mode: parsed.data.mode ?? "normal",
|
||||
commentRequired: true,
|
||||
stages,
|
||||
...(monitor ? { monitor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,6 +480,7 @@ function buildCompletedState(previous: IssueExecutionState | null, currentStage:
|
||||
completedStageIds,
|
||||
lastDecisionId: previous?.lastDecisionId ?? null,
|
||||
lastDecisionOutcome: "approved",
|
||||
monitor: previous?.monitor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,6 +500,7 @@ function buildStateWithCompletedStages(input: {
|
||||
completedStageIds: input.completedStageIds,
|
||||
lastDecisionId: input.previous?.lastDecisionId ?? null,
|
||||
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
|
||||
monitor: input.previous?.monitor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,6 +520,7 @@ function buildSkippedStageCompletedState(input: {
|
||||
completedStageIds: input.completedStageIds,
|
||||
lastDecisionId: input.previous?.lastDecisionId ?? null,
|
||||
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
|
||||
monitor: input.previous?.monitor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,6 +543,7 @@ function buildPendingState(input: {
|
||||
completedStageIds: input.previous?.completedStageIds ?? [],
|
||||
lastDecisionId: input.previous?.lastDecisionId ?? null,
|
||||
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
|
||||
monitor: input.previous?.monitor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -293,7 +604,7 @@ function canAutoSkipPendingStage(input: {
|
||||
input.stage.participants.every((participant) => principalsEqual(participant, input.returnAssignee));
|
||||
}
|
||||
|
||||
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
|
||||
function applyIssueExecutionStageTransition(input: TransitionInput): TransitionResult {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const existingState = parseIssueExecutionState(input.issue.executionState);
|
||||
const currentAssignee = assigneePrincipal(input.issue);
|
||||
@@ -560,3 +871,180 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
|
||||
workflowControlledAssignment: true,
|
||||
};
|
||||
}
|
||||
|
||||
function applyMonitorTransition(input: TransitionInput, stagePatch: Record<string, unknown>) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const previousPolicy = input.previousPolicy ?? normalizeIssueExecutionPolicy(input.issue.executionPolicy ?? null);
|
||||
const existingState = parseIssueExecutionState(input.issue.executionState);
|
||||
const currentMonitorState = derivePersistedMonitorState({
|
||||
issue: input.issue,
|
||||
state: existingState,
|
||||
policy: previousPolicy,
|
||||
});
|
||||
const nextStatus =
|
||||
typeof stagePatch.status === "string"
|
||||
? (stagePatch.status as string)
|
||||
: input.requestedStatus ?? input.issue.status;
|
||||
const { assigneeAgentId, assigneeUserId } = nextAssigneeIds({
|
||||
issue: input.issue,
|
||||
requestedAssigneePatch: input.requestedAssigneePatch,
|
||||
stagePatch,
|
||||
});
|
||||
const stageState =
|
||||
stagePatch.executionState !== undefined
|
||||
? parseIssueExecutionState(stagePatch.executionState)
|
||||
: existingState;
|
||||
const invalidReason = input.policy?.monitor
|
||||
? monitorClearReasonForIssue(nextStatus, assigneeAgentId, assigneeUserId)
|
||||
: null;
|
||||
|
||||
let targetMonitorState = currentMonitorState;
|
||||
|
||||
if (input.policy?.monitor) {
|
||||
if (invalidReason) {
|
||||
if (input.monitorExplicitlyUpdated) {
|
||||
throw unprocessable(MONITOR_INVALID_MESSAGE);
|
||||
}
|
||||
patch.executionPolicy = stripMonitorFromExecutionPolicy(input.policy);
|
||||
patch.monitorNextCheckAt = null;
|
||||
patch.monitorWakeRequestedAt = null;
|
||||
targetMonitorState = buildClearedMonitorState({
|
||||
previous: currentMonitorState,
|
||||
clearReason: invalidReason,
|
||||
clearedAt: new Date(),
|
||||
});
|
||||
} else {
|
||||
const exhaustedReason = exhaustedMonitorClearReason({
|
||||
monitor: input.policy.monitor,
|
||||
attemptCount: currentMonitorState?.attemptCount ?? 0,
|
||||
now: new Date(),
|
||||
});
|
||||
if (exhaustedReason) {
|
||||
if (input.monitorExplicitlyUpdated) {
|
||||
throw unprocessable(MONITOR_BOUNDS_EXHAUSTED_MESSAGE, { clearReason: exhaustedReason });
|
||||
}
|
||||
patch.executionPolicy = stripMonitorFromExecutionPolicy(input.policy);
|
||||
patch.monitorNextCheckAt = null;
|
||||
patch.monitorWakeRequestedAt = null;
|
||||
targetMonitorState = buildClearedMonitorState({
|
||||
previous: currentMonitorState,
|
||||
clearReason: exhaustedReason,
|
||||
clearedAt: new Date(),
|
||||
});
|
||||
} else {
|
||||
patch.monitorNextCheckAt = new Date(input.policy.monitor.nextCheckAt);
|
||||
patch.monitorWakeRequestedAt = null;
|
||||
patch.monitorNotes = input.policy.monitor.notes ?? null;
|
||||
patch.monitorScheduledBy = input.policy.monitor.scheduledBy;
|
||||
targetMonitorState = buildScheduledMonitorState(currentMonitorState, input.policy.monitor);
|
||||
}
|
||||
}
|
||||
} else if (previousPolicy?.monitor) {
|
||||
patch.monitorNextCheckAt = null;
|
||||
patch.monitorWakeRequestedAt = null;
|
||||
targetMonitorState = buildClearedMonitorState({
|
||||
previous: currentMonitorState,
|
||||
clearReason:
|
||||
input.monitorExplicitlyUpdated
|
||||
? "manual"
|
||||
: monitorClearReasonForIssue(nextStatus, assigneeAgentId, assigneeUserId) ?? "manual",
|
||||
clearedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
if (stagePatch.executionState !== undefined || !monitorStatesEqual(currentMonitorState, targetMonitorState)) {
|
||||
patch.executionState = executionStateWithMonitor(stageState, targetMonitorState);
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
export function buildInitialIssueMonitorFields(input: {
|
||||
policy: IssueExecutionPolicy | null;
|
||||
status: string;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
}) {
|
||||
if (!input.policy?.monitor) return {};
|
||||
if (!issueAllowsMonitor(input.status, input.assigneeAgentId ?? null, input.assigneeUserId ?? null)) {
|
||||
throw unprocessable(MONITOR_INVALID_MESSAGE);
|
||||
}
|
||||
const exhaustedReason = exhaustedMonitorClearReason({
|
||||
monitor: input.policy.monitor,
|
||||
attemptCount: 0,
|
||||
now: new Date(),
|
||||
});
|
||||
if (exhaustedReason) {
|
||||
throw unprocessable(MONITOR_BOUNDS_EXHAUSTED_MESSAGE, { clearReason: exhaustedReason });
|
||||
}
|
||||
|
||||
const monitorState = buildScheduledMonitorState(null, input.policy.monitor);
|
||||
return {
|
||||
monitorNextCheckAt: new Date(input.policy.monitor.nextCheckAt),
|
||||
monitorWakeRequestedAt: null,
|
||||
monitorNotes: input.policy.monitor.notes ?? null,
|
||||
monitorScheduledBy: input.policy.monitor.scheduledBy,
|
||||
executionState: executionStateWithMonitor(null, monitorState) as Record<string, unknown> | null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildIssueMonitorTriggeredPatch(input: {
|
||||
issue: IssueLike;
|
||||
policy: IssueExecutionPolicy | null;
|
||||
triggeredAt: Date;
|
||||
}) {
|
||||
const existingState = parseIssueExecutionState(input.issue.executionState);
|
||||
const currentMonitorState = derivePersistedMonitorState({
|
||||
issue: input.issue,
|
||||
state: existingState,
|
||||
policy: input.policy,
|
||||
});
|
||||
const nextMonitorState = buildTriggeredMonitorState({
|
||||
previous: currentMonitorState,
|
||||
triggeredAt: input.triggeredAt,
|
||||
});
|
||||
|
||||
return {
|
||||
executionPolicy: stripMonitorFromExecutionPolicy(input.policy) as Record<string, unknown> | null,
|
||||
executionState: executionStateWithMonitor(existingState, nextMonitorState) as Record<string, unknown> | null,
|
||||
monitorNextCheckAt: null,
|
||||
monitorWakeRequestedAt: null,
|
||||
monitorLastTriggeredAt: input.triggeredAt,
|
||||
monitorAttemptCount: nextMonitorState.attemptCount,
|
||||
monitorNotes: nextMonitorState.notes,
|
||||
monitorScheduledBy: nextMonitorState.scheduledBy,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildIssueMonitorClearedPatch(input: {
|
||||
issue: IssueLike;
|
||||
policy: IssueExecutionPolicy | null;
|
||||
clearReason: IssueExecutionMonitorClearReason;
|
||||
clearedAt?: Date;
|
||||
}) {
|
||||
const existingState = parseIssueExecutionState(input.issue.executionState);
|
||||
const currentMonitorState = derivePersistedMonitorState({
|
||||
issue: input.issue,
|
||||
state: existingState,
|
||||
policy: input.policy,
|
||||
});
|
||||
const nextMonitorState = buildClearedMonitorState({
|
||||
previous: currentMonitorState,
|
||||
clearReason: input.clearReason,
|
||||
clearedAt: input.clearedAt ?? new Date(),
|
||||
});
|
||||
|
||||
return {
|
||||
executionPolicy: stripMonitorFromExecutionPolicy(input.policy) as Record<string, unknown> | null,
|
||||
executionState: executionStateWithMonitor(existingState, nextMonitorState) as Record<string, unknown> | null,
|
||||
monitorNextCheckAt: null,
|
||||
monitorWakeRequestedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
|
||||
const stageResult = applyIssueExecutionStageTransition(input);
|
||||
const monitorPatch = applyMonitorTransition(input, stageResult.patch);
|
||||
Object.assign(stageResult.patch, monitorPatch);
|
||||
return stageResult;
|
||||
}
|
||||
|
||||
@@ -839,6 +839,7 @@ export function issueThreadInteractionService(db: Db) {
|
||||
title: task.title,
|
||||
description: task.description ?? null,
|
||||
status: "todo",
|
||||
workMode: task.workMode ?? "standard",
|
||||
priority: task.priority ?? "medium",
|
||||
assigneeAgentId: task.assigneeAgentId ?? null,
|
||||
assigneeUserId: task.assigneeUserId ?? null,
|
||||
|
||||
+307
-11
@@ -1,5 +1,5 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { and, asc, desc, eq, gt, inArray, isNull, lt, ne, notInArray, or, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, gt, inArray, isNull, like, lt, ne, notInArray, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
@@ -28,13 +28,26 @@ import {
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
IssueCommentAuthorType,
|
||||
IssueCommentMetadata,
|
||||
IssueCommentPresentation,
|
||||
IssueBlockerAttention,
|
||||
IssueProductivityReview,
|
||||
IssueProductivityReviewTrigger,
|
||||
IssueRelationIssueSummary,
|
||||
} from "@paperclipai/shared";
|
||||
import { clampIssueRequestDepth, extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared";
|
||||
import {
|
||||
clampIssueRequestDepth,
|
||||
extractAgentMentionIds,
|
||||
extractProjectMentionIds,
|
||||
issueCommentAuthorTypeSchema,
|
||||
issueCommentMetadataSchema,
|
||||
issueCommentPresentationSchema,
|
||||
isUuidLike,
|
||||
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { parseObject } from "../adapters/utils.js";
|
||||
import {
|
||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
@@ -43,6 +56,7 @@ import {
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
} from "./execution-workspace-policy.js";
|
||||
import { mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
|
||||
import { buildInitialIssueMonitorFields, normalizeIssueExecutionPolicy } from "./issue-execution-policy.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { redactCurrentUserText } from "../log-redaction.js";
|
||||
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
|
||||
@@ -119,9 +133,11 @@ export interface IssueFilters {
|
||||
descendantOf?: string;
|
||||
labelId?: string;
|
||||
originKind?: string;
|
||||
originKindPrefix?: string;
|
||||
originId?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
excludeRoutineExecutions?: boolean;
|
||||
includePluginOperations?: boolean;
|
||||
includeBlockedBy?: boolean;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
@@ -140,6 +156,19 @@ type IssueActiveRunRow = {
|
||||
finishedAt: Date | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
type IssueScheduledRetryRow = {
|
||||
runId: string;
|
||||
status: "scheduled_retry" | "queued" | "running" | "cancelled";
|
||||
agentId: string;
|
||||
agentName: string | null;
|
||||
retryOfRunId: string | null;
|
||||
scheduledRetryAt: Date | null;
|
||||
scheduledRetryAttempt: number;
|
||||
scheduledRetryReason: string | null;
|
||||
retryExhaustedReason?: string | null;
|
||||
error?: string | null;
|
||||
errorCode?: string | null;
|
||||
};
|
||||
type IssueWithLabels = IssueRow & { labels: IssueLabelRow[]; labelIds: string[] };
|
||||
type IssueWithLabelsAndRun = IssueWithLabels & { activeRun: IssueActiveRunRow | null };
|
||||
type IssueUserCommentStats = {
|
||||
@@ -555,6 +584,19 @@ function inboxVisibleForUserCondition(companyId: string, userId: string) {
|
||||
`;
|
||||
}
|
||||
|
||||
function nonPluginOperationIssueCondition() {
|
||||
return sql<boolean>`NOT (${issues.originKind} LIKE 'plugin:%:operation' OR ${issues.originKind} LIKE 'plugin:%:operation:%')`;
|
||||
}
|
||||
|
||||
function shouldIncludePluginOperationIssues(filters: IssueFilters | undefined) {
|
||||
return Boolean(
|
||||
filters?.includePluginOperations ||
|
||||
filters?.originKind ||
|
||||
filters?.originId ||
|
||||
filters?.projectId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Named entities commonly emitted in saved issue bodies; unknown `&name;` sequences are left unchanged. */
|
||||
const WELL_KNOWN_NAMED_HTML_ENTITIES: Readonly<Record<string, string>> = {
|
||||
amp: "&",
|
||||
@@ -1267,6 +1309,9 @@ async function listIssueBlockerAttentionMap(
|
||||
if (explicitWaitingIssueIds.has(node.id)) {
|
||||
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
if (node.assigneeUserId && node.status !== "cancelled") {
|
||||
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
if (node.status === "in_review") {
|
||||
const hasWaitingPath = activeIssueIds.has(node.id) || Boolean(node.assigneeUserId);
|
||||
if (hasWaitingPath) {
|
||||
@@ -1280,6 +1325,9 @@ async function listIssueBlockerAttentionMap(
|
||||
if (node.status === "cancelled") {
|
||||
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
if (node.status === "backlog" && node.assigneeAgentId) {
|
||||
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
|
||||
const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done");
|
||||
if (downstream.length > 0) {
|
||||
@@ -1401,6 +1449,7 @@ const issueListSelect = {
|
||||
END
|
||||
`,
|
||||
status: issues.status,
|
||||
workMode: issues.workMode,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
@@ -1421,6 +1470,12 @@ const issueListSelect = {
|
||||
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
|
||||
executionPolicy: sql<null>`null`,
|
||||
executionState: sql<null>`null`,
|
||||
monitorNextCheckAt: issues.monitorNextCheckAt,
|
||||
monitorWakeRequestedAt: issues.monitorWakeRequestedAt,
|
||||
monitorLastTriggeredAt: issues.monitorLastTriggeredAt,
|
||||
monitorAttemptCount: issues.monitorAttemptCount,
|
||||
monitorNotes: issues.monitorNotes,
|
||||
monitorScheduledBy: issues.monitorScheduledBy,
|
||||
executionWorkspaceId: issues.executionWorkspaceId,
|
||||
executionWorkspacePreference: issues.executionWorkspacePreference,
|
||||
executionWorkspaceSettings: sql<null>`null`,
|
||||
@@ -1650,10 +1705,77 @@ export function issueService(db: Db) {
|
||||
return enriched;
|
||||
}
|
||||
|
||||
function redactIssueComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
|
||||
async function getCurrentScheduledRetryForIssue(issueId: string, companyId: string): Promise<IssueScheduledRetryRow | null> {
|
||||
const row = await db
|
||||
.select({
|
||||
runId: heartbeatRuns.id,
|
||||
status: heartbeatRuns.status,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
agentName: agents.name,
|
||||
retryOfRunId: heartbeatRuns.retryOfRunId,
|
||||
scheduledRetryAt: heartbeatRuns.scheduledRetryAt,
|
||||
scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt,
|
||||
scheduledRetryReason: heartbeatRuns.scheduledRetryReason,
|
||||
error: heartbeatRuns.error,
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
eq(heartbeatRuns.status, "scheduled_retry"),
|
||||
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(heartbeatRuns.scheduledRetryAt), asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
return row ? { ...row, status: "scheduled_retry" } : null;
|
||||
}
|
||||
|
||||
function deriveIssueCommentAuthorType(comment: {
|
||||
authorType?: string | null;
|
||||
authorAgentId?: string | null;
|
||||
authorUserId?: string | null;
|
||||
}): IssueCommentAuthorType {
|
||||
const explicit = issueCommentAuthorTypeSchema.safeParse(comment.authorType);
|
||||
if (explicit.success) return explicit.data;
|
||||
if (comment.authorAgentId) return "agent";
|
||||
if (comment.authorUserId) return "user";
|
||||
return "system";
|
||||
}
|
||||
|
||||
function assertIssueCommentAuthorTypeAllowed(
|
||||
actor: { agentId?: string | null; userId?: string | null },
|
||||
authorType: IssueCommentAuthorType,
|
||||
) {
|
||||
if (actor.agentId && authorType !== "agent") {
|
||||
throw unprocessable("Comment authorType must match authenticated actor");
|
||||
}
|
||||
if (actor.userId && authorType !== "user") {
|
||||
throw unprocessable("Comment authorType must match authenticated actor");
|
||||
}
|
||||
if (!actor.agentId && !actor.userId && authorType !== "system") {
|
||||
throw unprocessable("System comments cannot use user or agent authorType without an author id");
|
||||
}
|
||||
}
|
||||
|
||||
function redactIssueComment<T extends { body: string; authorType?: string | null; authorAgentId?: string | null; authorUserId?: string | null; presentation?: unknown; metadata?: unknown }>(
|
||||
comment: T,
|
||||
censorUsernameInLogs: boolean,
|
||||
): T & {
|
||||
authorType: IssueCommentAuthorType;
|
||||
presentation: IssueCommentPresentation | null;
|
||||
metadata: IssueCommentMetadata | null;
|
||||
} {
|
||||
return {
|
||||
...comment,
|
||||
authorType: deriveIssueCommentAuthorType(comment),
|
||||
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
|
||||
presentation: issueCommentPresentationSchema.nullable().catch(null).parse(comment.presentation ?? null),
|
||||
metadata: issueCommentMetadataSchema.nullable().catch(null).parse(comment.metadata ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2187,7 +2309,11 @@ export function issueService(db: Db) {
|
||||
}
|
||||
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
|
||||
if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
|
||||
if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`));
|
||||
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
|
||||
if (!shouldIncludePluginOperationIssues(filters)) {
|
||||
conditions.push(nonPluginOperationIssueCondition());
|
||||
}
|
||||
if (filters?.labelId) {
|
||||
const labeledIssueIds = await db
|
||||
.select({ issueId: issueLabels.issueId })
|
||||
@@ -2319,6 +2445,7 @@ export function issueService(db: Db) {
|
||||
const conditions = [
|
||||
eq(issues.companyId, companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
nonPluginOperationIssueCondition(),
|
||||
unreadForUserCondition(companyId, userId),
|
||||
];
|
||||
if (status) {
|
||||
@@ -2410,8 +2537,9 @@ export function issueService(db: Db) {
|
||||
|
||||
getById: async (raw: string) => {
|
||||
const id = raw.trim();
|
||||
if (/^[A-Z]+-\d+$/i.test(id)) {
|
||||
return getIssueByIdentifier(id);
|
||||
const identifier = normalizeIssueReferenceIdentifier(id);
|
||||
if (identifier) {
|
||||
return getIssueByIdentifier(identifier);
|
||||
}
|
||||
if (!isUuidLike(id)) {
|
||||
return null;
|
||||
@@ -2423,6 +2551,16 @@ export function issueService(db: Db) {
|
||||
return getIssueByIdentifier(identifier);
|
||||
},
|
||||
|
||||
getCurrentScheduledRetry: async (issueId: string) => {
|
||||
const issue = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!issue) throw notFound("Issue not found");
|
||||
return getCurrentScheduledRetryForIssue(issue.id, issue.companyId);
|
||||
},
|
||||
|
||||
getRelationSummaries: async (issueId: string) => {
|
||||
const issue = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
@@ -2726,24 +2864,68 @@ export function issueService(db: Db) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cache the project policy lookup for this insert. Both the
|
||||
// default-settings block and the assignee-environment-promotion block
|
||||
// need the same row; without caching they'd issue two round-trips.
|
||||
let projectPolicyCached: ReturnType<typeof parseProjectExecutionWorkspacePolicy> | null = null;
|
||||
let projectPolicyLoaded = false;
|
||||
const loadProjectPolicyOnce = async () => {
|
||||
if (projectPolicyLoaded) return projectPolicyCached;
|
||||
projectPolicyLoaded = true;
|
||||
if (!issueData.projectId) return null;
|
||||
const projectRow = await tx
|
||||
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
projectPolicyCached = parseProjectExecutionWorkspacePolicy(projectRow?.executionWorkspacePolicy);
|
||||
return projectPolicyCached;
|
||||
};
|
||||
|
||||
if (
|
||||
executionWorkspaceSettings == null &&
|
||||
executionWorkspaceId == null &&
|
||||
issueData.projectId
|
||||
) {
|
||||
const project = await tx
|
||||
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
executionWorkspaceSettings =
|
||||
defaultIssueExecutionWorkspaceSettingsForProject(
|
||||
gateProjectExecutionWorkspacePolicy(
|
||||
parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy),
|
||||
await loadProjectPolicyOnce(),
|
||||
isolatedWorkspacesEnabled,
|
||||
),
|
||||
) as Record<string, unknown> | null;
|
||||
}
|
||||
if (data.assigneeAgentId && isolatedWorkspacesEnabled) {
|
||||
const currentWorkspaceSettings = executionWorkspaceSettings == null
|
||||
? {}
|
||||
: parseObject(executionWorkspaceSettings);
|
||||
const issueHasEnvironmentSelection =
|
||||
Object.prototype.hasOwnProperty.call(currentWorkspaceSettings, "environmentId");
|
||||
// Don't promote the assignee agent's defaultEnvironmentId if either
|
||||
// the issue or the project policy already specifies an environment.
|
||||
// resolveExecutionWorkspaceEnvironmentId treats issue settings as
|
||||
// higher priority than project policy, so promoting the agent's
|
||||
// default to issue settings would invert the documented priority
|
||||
// (project policy must win over agent default when explicitly set).
|
||||
let projectHasEnvironmentSelection = false;
|
||||
if (!issueHasEnvironmentSelection && issueData.projectId) {
|
||||
const projectPolicy = await loadProjectPolicyOnce();
|
||||
projectHasEnvironmentSelection = projectPolicy?.environmentId !== undefined;
|
||||
}
|
||||
if (!issueHasEnvironmentSelection && !projectHasEnvironmentSelection) {
|
||||
const assigneeAgent = await tx
|
||||
.select({ defaultEnvironmentId: agents.defaultEnvironmentId })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.id, data.assigneeAgentId), eq(agents.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (typeof assigneeAgent?.defaultEnvironmentId === "string" && assigneeAgent.defaultEnvironmentId.length > 0) {
|
||||
executionWorkspaceSettings = {
|
||||
...currentWorkspaceSettings,
|
||||
environmentId: assigneeAgent.defaultEnvironmentId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!projectWorkspaceId && issueData.projectId) {
|
||||
const project = await tx
|
||||
.select({
|
||||
@@ -2815,6 +2997,15 @@ export function issueService(db: Db) {
|
||||
if (values.status === "cancelled") {
|
||||
values.cancelledAt = new Date();
|
||||
}
|
||||
Object.assign(
|
||||
values,
|
||||
buildInitialIssueMonitorFields({
|
||||
policy: normalizeIssueExecutionPolicy(issueData.executionPolicy ?? null),
|
||||
status: values.status ?? "backlog",
|
||||
assigneeAgentId: values.assigneeAgentId ?? null,
|
||||
assigneeUserId: values.assigneeUserId ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
const [issue] = await tx.insert(issues).values(values).returning();
|
||||
if (inputLabelIds) {
|
||||
@@ -2962,6 +3153,94 @@ export function issueService(db: Db) {
|
||||
issueData.projectId !== undefined ? issueData.projectId : existing.projectId,
|
||||
),
|
||||
]);
|
||||
|
||||
// Mirror the create() path: when the assignee changes to a non-null
|
||||
// agent, default the issue's executionWorkspaceSettings.environmentId
|
||||
// to the new agent's defaultEnvironmentId. Skip when:
|
||||
// - this update explicitly sets executionWorkspaceSettings.environmentId
|
||||
// (caller is making a deliberate override; respect it), OR
|
||||
// - the project policy already specifies an environmentId (project
|
||||
// policy must win over agent default per the documented priority
|
||||
// order in resolveExecutionWorkspaceEnvironmentId), OR
|
||||
// - the issue already has an environmentId that was *not* the prior
|
||||
// assignee's default (i.e., the operator set it explicitly in an
|
||||
// earlier update; preserve their choice). When the existing
|
||||
// environmentId matches the prior assignee's default, treat it as
|
||||
// auto-promoted and refresh it to the new assignee's default.
|
||||
const assigneeChanged =
|
||||
issueData.assigneeAgentId !== undefined &&
|
||||
issueData.assigneeAgentId !== null &&
|
||||
issueData.assigneeAgentId !== existing.assigneeAgentId;
|
||||
const explicitEnvInThisUpdate =
|
||||
issueData.executionWorkspaceSettings !== undefined &&
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
parseObject(issueData.executionWorkspaceSettings),
|
||||
"environmentId",
|
||||
);
|
||||
if (assigneeChanged && isolatedWorkspacesEnabled && !explicitEnvInThisUpdate) {
|
||||
let projectHasEnvironmentSelection = false;
|
||||
if (nextProjectId) {
|
||||
const projectRow = await tx
|
||||
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, nextProjectId), eq(projects.companyId, existing.companyId)))
|
||||
.then((rows: Array<{ executionWorkspacePolicy: unknown }>) => rows[0] ?? null);
|
||||
const projectPolicy = parseProjectExecutionWorkspacePolicy(projectRow?.executionWorkspacePolicy);
|
||||
projectHasEnvironmentSelection = projectPolicy?.environmentId !== undefined;
|
||||
}
|
||||
if (!projectHasEnvironmentSelection) {
|
||||
const baseSettings = nextExecutionWorkspaceSettings == null
|
||||
? {}
|
||||
: parseObject(nextExecutionWorkspaceSettings);
|
||||
const existingEnvId = typeof baseSettings.environmentId === "string"
|
||||
? baseSettings.environmentId
|
||||
: null;
|
||||
|
||||
// Look up both the prior assignee (to detect auto-promoted env)
|
||||
// and the new assignee in a single query.
|
||||
type AgentRow = { id: string; defaultEnvironmentId: string | null };
|
||||
const agentRows: AgentRow[] = await tx
|
||||
.select({ id: agents.id, defaultEnvironmentId: agents.defaultEnvironmentId })
|
||||
.from(agents)
|
||||
.where(
|
||||
and(
|
||||
eq(agents.companyId, existing.companyId),
|
||||
inArray(
|
||||
agents.id,
|
||||
[issueData.assigneeAgentId!, existing.assigneeAgentId].filter(
|
||||
(value): value is string => typeof value === "string",
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const newAssignee = agentRows.find((row: AgentRow) => row.id === issueData.assigneeAgentId);
|
||||
const previousAssignee = existing.assigneeAgentId
|
||||
? agentRows.find((row: AgentRow) => row.id === existing.assigneeAgentId)
|
||||
: null;
|
||||
|
||||
const newDefaultEnvId =
|
||||
typeof newAssignee?.defaultEnvironmentId === "string" && newAssignee.defaultEnvironmentId.length > 0
|
||||
? newAssignee.defaultEnvironmentId
|
||||
: null;
|
||||
const previousDefaultEnvId =
|
||||
typeof previousAssignee?.defaultEnvironmentId === "string" && previousAssignee.defaultEnvironmentId.length > 0
|
||||
? previousAssignee.defaultEnvironmentId
|
||||
: null;
|
||||
|
||||
const existingEnvWasAutoPromoted =
|
||||
existingEnvId === null ||
|
||||
(previousDefaultEnvId !== null && existingEnvId === previousDefaultEnvId);
|
||||
|
||||
if (newDefaultEnvId && existingEnvWasAutoPromoted) {
|
||||
patch.executionWorkspaceSettings = {
|
||||
...baseSettings,
|
||||
environmentId: newDefaultEnvId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
patch.goalId = resolveNextIssueGoalId({
|
||||
currentProjectId: existing.projectId,
|
||||
currentGoalId: existing.goalId,
|
||||
@@ -3567,6 +3846,12 @@ export function issueService(db: Db) {
|
||||
issueId: string,
|
||||
body: string,
|
||||
actor: { agentId?: string; userId?: string; runId?: string | null },
|
||||
options?: {
|
||||
authorType?: IssueCommentAuthorType | null;
|
||||
presentation?: IssueCommentPresentation | null;
|
||||
metadata?: IssueCommentMetadata | null;
|
||||
createdAt?: Date | string | null;
|
||||
},
|
||||
) => {
|
||||
const issue = await db
|
||||
.select({ companyId: issues.companyId })
|
||||
@@ -3580,6 +3865,13 @@ export function issueService(db: Db) {
|
||||
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
|
||||
};
|
||||
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
|
||||
const authorType = issueCommentAuthorTypeSchema.parse(
|
||||
options?.authorType ?? (actor.agentId ? "agent" : actor.userId ? "user" : "system"),
|
||||
);
|
||||
assertIssueCommentAuthorTypeAllowed(actor, authorType);
|
||||
const presentation = issueCommentPresentationSchema.nullable().parse(options?.presentation ?? null);
|
||||
const metadata = issueCommentMetadataSchema.nullable().parse(options?.metadata ?? null);
|
||||
const createdAt = options?.createdAt ? new Date(options.createdAt) : null;
|
||||
const [comment] = await db
|
||||
.insert(issueComments)
|
||||
.values({
|
||||
@@ -3587,8 +3879,12 @@ export function issueService(db: Db) {
|
||||
issueId,
|
||||
authorAgentId: actor.agentId ?? null,
|
||||
authorUserId: actor.userId ?? null,
|
||||
authorType,
|
||||
createdByRunId: actor.runId ?? null,
|
||||
body: redactedBody,
|
||||
presentation,
|
||||
metadata,
|
||||
...(createdAt && !Number.isNaN(createdAt.getTime()) ? { createdAt } : {}),
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
||||
"companies.get": ["companies.read"],
|
||||
"projects.list": ["projects.read"],
|
||||
"projects.get": ["projects.read"],
|
||||
"projects.managed.get": ["projects.managed"],
|
||||
"projects.managed.reconcile": ["projects.managed"],
|
||||
"projects.managed.reset": ["projects.managed"],
|
||||
"routines.managed.get": ["routines.managed"],
|
||||
"routines.managed.reconcile": ["routines.managed"],
|
||||
"routines.managed.reset": ["routines.managed"],
|
||||
"project.workspaces.list": ["project.workspaces.read"],
|
||||
"project.workspaces.get": ["project.workspaces.read"],
|
||||
"issues.list": ["issues.read"],
|
||||
@@ -56,6 +62,9 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
||||
"issue.comments.get": ["issue.comments.read"],
|
||||
"agents.list": ["agents.read"],
|
||||
"agents.get": ["agents.read"],
|
||||
"agents.managed.get": ["agents.managed"],
|
||||
"agents.managed.reconcile": ["agents.managed"],
|
||||
"agents.managed.reset": ["agents.managed"],
|
||||
"goals.list": ["goals.read"],
|
||||
"goals.get": ["goals.read"],
|
||||
"activity.list": ["activity.read"],
|
||||
@@ -65,6 +74,12 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
||||
"issues.summaries.getOrchestration": ["issues.orchestration.read"],
|
||||
"db.namespace": ["database.namespace.read"],
|
||||
"db.query": ["database.namespace.read"],
|
||||
"localFolders.declarations": [],
|
||||
"localFolders.configure": ["local.folders"],
|
||||
"localFolders.status": ["local.folders"],
|
||||
"localFolders.list": ["local.folders"],
|
||||
"localFolders.readText": ["local.folders"],
|
||||
"localFolders.writeTextAtomic": ["local.folders"],
|
||||
|
||||
// Data write operations
|
||||
"issues.create": ["issues.create"],
|
||||
@@ -133,6 +148,7 @@ const UI_SLOT_CAPABILITIES: Record<PluginUiSlotType, PluginCapability> = {
|
||||
commentAnnotation: "ui.commentAnnotation.register",
|
||||
commentContextMenuItem: "ui.action.register",
|
||||
settingsPage: "instance.settings.register",
|
||||
routeSidebar: "ui.sidebar.register",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -167,6 +183,9 @@ const FEATURE_CAPABILITIES: Record<string, PluginCapability> = {
|
||||
webhooks: "webhooks.receive",
|
||||
database: "database.namespace.migrate",
|
||||
environmentDrivers: "environment.drivers.register",
|
||||
agents: "agents.managed",
|
||||
projects: "projects.managed",
|
||||
routines: "routines.managed",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -303,7 +303,19 @@ function resolveMigrationsDir(packageRoot: string, migrationsDir: string): strin
|
||||
return resolvedDir;
|
||||
}
|
||||
|
||||
export function pluginDatabaseService(db: Db) {
|
||||
type PluginDatabaseClient = Pick<Db, "select" | "insert" | "update" | "execute">;
|
||||
type PluginDatabaseRootClient = PluginDatabaseClient & Partial<Pick<Db, "transaction">>;
|
||||
|
||||
export interface ApplyPluginMigrationsOptions {
|
||||
/**
|
||||
* Persist failed migration ledger rows. Fresh install uses false because the
|
||||
* caller owns a larger transaction and must roll back the plugin row and
|
||||
* namespace together.
|
||||
*/
|
||||
persistFailure?: boolean;
|
||||
}
|
||||
|
||||
export function pluginDatabaseService(db: PluginDatabaseRootClient) {
|
||||
async function getPluginRecord(pluginId: string) {
|
||||
const rows = await db.select().from(plugins).where(eq(plugins.id, pluginId)).limit(1);
|
||||
const plugin = rows[0];
|
||||
@@ -311,14 +323,18 @@ export function pluginDatabaseService(db: Db) {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
async function ensureNamespace(pluginId: string, manifest: PaperclipPluginManifestV1) {
|
||||
async function ensureNamespaceWithClient(
|
||||
client: PluginDatabaseClient,
|
||||
pluginId: string,
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
) {
|
||||
if (!manifest.database) return null;
|
||||
const namespaceName = derivePluginDatabaseNamespace(
|
||||
manifest.id,
|
||||
manifest.database.namespaceSlug,
|
||||
);
|
||||
await db.execute(sql.raw(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(namespaceName)}`));
|
||||
const rows = await db
|
||||
await client.execute(sql.raw(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(namespaceName)}`));
|
||||
const rows = await client
|
||||
.insert(pluginDatabaseNamespaces)
|
||||
.values({
|
||||
pluginId,
|
||||
@@ -341,6 +357,10 @@ export function pluginDatabaseService(db: Db) {
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function ensureNamespace(pluginId: string, manifest: PaperclipPluginManifestV1) {
|
||||
return ensureNamespaceWithClient(db, pluginId, manifest);
|
||||
}
|
||||
|
||||
async function getNamespace(pluginId: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
@@ -358,7 +378,7 @@ export function pluginDatabaseService(db: Db) {
|
||||
return namespace.namespaceName;
|
||||
}
|
||||
|
||||
async function recordMigrationFailure(input: {
|
||||
async function recordMigrationFailure(client: PluginDatabaseClient, input: {
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
namespaceName: string;
|
||||
@@ -368,7 +388,7 @@ export function pluginDatabaseService(db: Db) {
|
||||
error: unknown;
|
||||
}): Promise<void> {
|
||||
const message = input.error instanceof Error ? input.error.message : String(input.error);
|
||||
await db
|
||||
await client
|
||||
.insert(pluginMigrations)
|
||||
.values({
|
||||
pluginId: input.pluginId,
|
||||
@@ -391,7 +411,7 @@ export function pluginDatabaseService(db: Db) {
|
||||
appliedAt: null,
|
||||
},
|
||||
});
|
||||
await db
|
||||
await client
|
||||
.update(pluginDatabaseNamespaces)
|
||||
.set({ status: "migration_failed", updatedAt: new Date() })
|
||||
.where(eq(pluginDatabaseNamespaces.pluginId, input.pluginId));
|
||||
@@ -400,7 +420,12 @@ export function pluginDatabaseService(db: Db) {
|
||||
return {
|
||||
ensureNamespace,
|
||||
|
||||
async applyMigrations(pluginId: string, manifest: PaperclipPluginManifestV1, packageRoot: string) {
|
||||
async applyMigrations(
|
||||
pluginId: string,
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
packageRoot: string,
|
||||
options: ApplyPluginMigrationsOptions = {},
|
||||
) {
|
||||
if (!manifest.database) return null;
|
||||
const namespace = await ensureNamespace(pluginId, manifest);
|
||||
if (!namespace) return null;
|
||||
@@ -409,13 +434,14 @@ export function pluginDatabaseService(db: Db) {
|
||||
const migrationFiles = await listSqlMigrationFiles(migrationDir);
|
||||
const coreReadTables = manifest.database.coreReadTables ?? [];
|
||||
const lockKey = Number.parseInt(createHash("sha256").update(pluginId).digest("hex").slice(0, 12), 16);
|
||||
const persistFailure = options.persistFailure ?? true;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(${lockKey})`);
|
||||
const applyWithClient = async (client: PluginDatabaseClient) => {
|
||||
await client.execute(sql`SELECT pg_advisory_xact_lock(${lockKey})`);
|
||||
for (const migrationKey of migrationFiles) {
|
||||
const content = await readFile(path.join(migrationDir, migrationKey), "utf8");
|
||||
const checksum = createHash("sha256").update(content).digest("hex");
|
||||
const existingRows = await tx
|
||||
const existingRows = await client
|
||||
.select()
|
||||
.from(pluginMigrations)
|
||||
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.migrationKey, migrationKey)))
|
||||
@@ -435,9 +461,9 @@ export function pluginDatabaseService(db: Db) {
|
||||
}
|
||||
for (const statement of statements) {
|
||||
validatePluginMigrationStatement(statement, namespace.namespaceName, coreReadTables);
|
||||
await tx.execute(sql.raw(statement));
|
||||
await client.execute(sql.raw(statement));
|
||||
}
|
||||
await tx
|
||||
await client
|
||||
.insert(pluginMigrations)
|
||||
.values({
|
||||
pluginId,
|
||||
@@ -461,19 +487,27 @@ export function pluginDatabaseService(db: Db) {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await recordMigrationFailure({
|
||||
pluginId,
|
||||
pluginKey: manifest.id,
|
||||
namespaceName: namespace.namespaceName,
|
||||
migrationKey,
|
||||
checksum,
|
||||
pluginVersion: manifest.version,
|
||||
error,
|
||||
});
|
||||
if (persistFailure) {
|
||||
await recordMigrationFailure(db, {
|
||||
pluginId,
|
||||
pluginKey: manifest.id,
|
||||
namespaceName: namespace.namespaceName,
|
||||
migrationKey,
|
||||
checksum,
|
||||
pluginVersion: manifest.version,
|
||||
error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof db.transaction === "function") {
|
||||
await db.transaction(async (tx) => applyWithClient(tx as PluginDatabaseClient));
|
||||
} else {
|
||||
await applyWithClient(db);
|
||||
}
|
||||
|
||||
return namespace;
|
||||
},
|
||||
|
||||
@@ -247,6 +247,7 @@ export async function resumePluginEnvironmentLease(input: {
|
||||
workerManager: PluginWorkerManager;
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
issueId?: string | null;
|
||||
config: PluginEnvironmentConfig;
|
||||
providerLeaseId: string;
|
||||
leaseMetadata?: Record<string, unknown>;
|
||||
@@ -256,6 +257,7 @@ export async function resumePluginEnvironmentLease(input: {
|
||||
driverKey: input.config.driverKey,
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environmentId,
|
||||
issueId: input.issueId ?? null,
|
||||
config: input.config.driverConfig,
|
||||
providerLeaseId: input.providerLeaseId,
|
||||
leaseMetadata: input.leaseMetadata,
|
||||
@@ -267,6 +269,7 @@ export async function destroyPluginEnvironmentLease(input: {
|
||||
workerManager: PluginWorkerManager;
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
issueId?: string | null;
|
||||
config: PluginEnvironmentConfig;
|
||||
providerLeaseId: string | null;
|
||||
leaseMetadata?: Record<string, unknown>;
|
||||
@@ -276,6 +279,7 @@ export async function destroyPluginEnvironmentLease(input: {
|
||||
driverKey: input.config.driverKey,
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environmentId,
|
||||
issueId: input.issueId ?? null,
|
||||
config: input.config.driverConfig,
|
||||
providerLeaseId: input.providerLeaseId,
|
||||
leaseMetadata: input.leaseMetadata,
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
PluginIssueOrchestrationSummary,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared";
|
||||
import { pluginOperationIssueOriginKind } from "@paperclipai/shared";
|
||||
import { companyService } from "./companies.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { projectService } from "./projects.js";
|
||||
@@ -34,12 +35,29 @@ import { budgetService } from "./budgets.js";
|
||||
import { issueApprovalService } from "./issue-approvals.js";
|
||||
import { subscribeCompanyLiveEvents } from "./live-events.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { activityService } from "./activity.js";
|
||||
import { costService } from "./costs.js";
|
||||
import { assetService } from "./assets.js";
|
||||
import { pluginRegistryService } from "./plugin-registry.js";
|
||||
import { pluginStateStore } from "./plugin-state-store.js";
|
||||
import { pluginDatabaseService } from "./plugin-database.js";
|
||||
import { pluginManagedAgentService } from "./plugin-managed-agents.js";
|
||||
import { pluginManagedRoutineService } from "./plugin-managed-routines.js";
|
||||
import { pluginManagedSkillService } from "./plugin-managed-skills.js";
|
||||
import {
|
||||
assertConfiguredLocalFolder,
|
||||
assertWritableConfiguredLocalFolder,
|
||||
getStoredLocalFolders,
|
||||
deletePluginLocalFolderFile,
|
||||
inspectPluginLocalFolder,
|
||||
listPluginLocalFolderEntries,
|
||||
preparePluginLocalFolder,
|
||||
readPluginLocalFolderText,
|
||||
requireLocalFolderDeclaration,
|
||||
setStoredLocalFolder,
|
||||
writePluginLocalFolderTextAtomic,
|
||||
} from "./plugin-local-folders.js";
|
||||
import { createPluginSecretsHandler } from "./plugin-secrets-handler.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import type { PluginEventBus } from "./plugin-event-bus.js";
|
||||
@@ -460,7 +478,7 @@ export function buildHostServices(
|
||||
pluginKey: string,
|
||||
eventBus: PluginEventBus,
|
||||
notifyWorker?: (method: string, params: unknown) => void,
|
||||
options: { pluginWorkerManager?: PluginWorkerManager } = {},
|
||||
options: { pluginWorkerManager?: PluginWorkerManager; manifest?: import("@paperclipai/shared").PaperclipPluginManifestV1 } = {},
|
||||
): HostServices & { dispose(): void } {
|
||||
const registry = pluginRegistryService(db);
|
||||
const stateStore = pluginStateStore(db);
|
||||
@@ -468,6 +486,36 @@ export function buildHostServices(
|
||||
const secretsHandler = createPluginSecretsHandler({ db, pluginId });
|
||||
const companies = companyService(db);
|
||||
const agents = agentService(db);
|
||||
const managedAgents = pluginManagedAgentService(db, {
|
||||
pluginId,
|
||||
pluginKey,
|
||||
manifest: options.manifest,
|
||||
instructionTemplateVariables: async (companyId) => {
|
||||
const variables: Record<string, string | null | undefined> = {};
|
||||
for (const declaration of options.manifest?.localFolders ?? []) {
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: declaration.folderKey,
|
||||
declaration,
|
||||
storedConfig: await getStoredLocalFolderConfig(companyId, declaration.folderKey),
|
||||
});
|
||||
const prefix = `localFolders.${declaration.folderKey}`;
|
||||
variables[`${prefix}.path`] = status.realPath ?? status.path ?? null;
|
||||
variables[`${prefix}.agentsPath`] = status.realPath ? path.join(status.realPath, "AGENTS.md") : null;
|
||||
}
|
||||
return variables;
|
||||
},
|
||||
});
|
||||
const managedRoutines = pluginManagedRoutineService(db, {
|
||||
pluginId,
|
||||
pluginKey,
|
||||
manifest: options.manifest,
|
||||
pluginWorkerManager: options.pluginWorkerManager,
|
||||
});
|
||||
const managedSkills = pluginManagedSkillService(db, {
|
||||
pluginId,
|
||||
pluginKey,
|
||||
manifest: options.manifest,
|
||||
});
|
||||
const heartbeat = heartbeatService(db, {
|
||||
pluginWorkerManager: options.pluginWorkerManager,
|
||||
});
|
||||
@@ -518,6 +566,23 @@ export function buildHostServices(
|
||||
*/
|
||||
const ensurePluginAvailableForCompany = async (_companyId: string) => {};
|
||||
|
||||
const getLocalFolderDeclaration = (folderKey: string) =>
|
||||
requireLocalFolderDeclaration(options.manifest?.localFolders, folderKey);
|
||||
|
||||
const getStoredLocalFolderConfig = async (companyId: string, folderKey: string) => {
|
||||
ensureCompanyId(companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const settings = await registry.getCompanySettings(pluginId, companyId);
|
||||
return getStoredLocalFolders(settings?.settingsJson)[folderKey] ?? null;
|
||||
};
|
||||
|
||||
const inspectStoredLocalFolder = async (companyId: string, folderKey: string) =>
|
||||
inspectPluginLocalFolder({
|
||||
folderKey,
|
||||
declaration: getLocalFolderDeclaration(folderKey),
|
||||
storedConfig: await getStoredLocalFolderConfig(companyId, folderKey),
|
||||
});
|
||||
|
||||
const inCompany = <T extends { companyId: string | null | undefined }>(
|
||||
record: T | null | undefined,
|
||||
companyId: string,
|
||||
@@ -752,6 +817,91 @@ export function buildHostServices(
|
||||
},
|
||||
},
|
||||
|
||||
localFolders: {
|
||||
async declarations() {
|
||||
return options.manifest?.localFolders ?? [];
|
||||
},
|
||||
|
||||
async configure(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const declaration = getLocalFolderDeclaration(params.folderKey);
|
||||
const existing = await registry.getCompanySettings(pluginId, companyId);
|
||||
const existingConfig = getStoredLocalFolders(existing?.settingsJson)[params.folderKey] ?? null;
|
||||
await preparePluginLocalFolder({
|
||||
folderKey: params.folderKey,
|
||||
declaration,
|
||||
storedConfig: existingConfig,
|
||||
overrideConfig: {
|
||||
path: params.path,
|
||||
},
|
||||
});
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: params.folderKey,
|
||||
declaration,
|
||||
storedConfig: existingConfig,
|
||||
overrideConfig: {
|
||||
path: params.path,
|
||||
},
|
||||
});
|
||||
|
||||
const nextSettings = setStoredLocalFolder(existing?.settingsJson, params.folderKey, {
|
||||
path: params.path,
|
||||
access: status.access,
|
||||
requiredDirectories: status.requiredDirectories,
|
||||
requiredFiles: status.requiredFiles,
|
||||
});
|
||||
await registry.upsertCompanySettings(pluginId, companyId, {
|
||||
enabled: existing?.enabled ?? true,
|
||||
settingsJson: nextSettings,
|
||||
lastError: status.healthy ? null : status.problems.map((item: { message: string }) => item.message).join("; "),
|
||||
});
|
||||
return status;
|
||||
},
|
||||
|
||||
async status(params) {
|
||||
return inspectStoredLocalFolder(params.companyId, params.folderKey);
|
||||
},
|
||||
|
||||
async list(params) {
|
||||
const status = await inspectStoredLocalFolder(params.companyId, params.folderKey);
|
||||
assertConfiguredLocalFolder(status);
|
||||
const listing = await listPluginLocalFolderEntries(status.realPath!, {
|
||||
relativePath: params.relativePath,
|
||||
recursive: params.recursive,
|
||||
maxEntries: params.maxEntries,
|
||||
});
|
||||
return { ...listing, folderKey: params.folderKey };
|
||||
},
|
||||
|
||||
async readText(params) {
|
||||
const status = await inspectStoredLocalFolder(params.companyId, params.folderKey);
|
||||
assertConfiguredLocalFolder(status);
|
||||
return readPluginLocalFolderText(status.realPath!, params.relativePath);
|
||||
},
|
||||
|
||||
async writeTextAtomic(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await preparePluginLocalFolder({
|
||||
folderKey: params.folderKey,
|
||||
declaration: getLocalFolderDeclaration(params.folderKey),
|
||||
storedConfig: await getStoredLocalFolderConfig(companyId, params.folderKey),
|
||||
});
|
||||
const status = await inspectStoredLocalFolder(companyId, params.folderKey);
|
||||
assertWritableConfiguredLocalFolder(status);
|
||||
await writePluginLocalFolderTextAtomic(status.realPath!, params.relativePath, params.contents);
|
||||
return inspectStoredLocalFolder(companyId, params.folderKey);
|
||||
},
|
||||
|
||||
async deleteFile(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
const status = await inspectStoredLocalFolder(companyId, params.folderKey);
|
||||
assertWritableConfiguredLocalFolder(status);
|
||||
await deletePluginLocalFolderFile(status.realPath!, params.relativePath, params.folderKey);
|
||||
return inspectStoredLocalFolder(companyId, params.folderKey);
|
||||
},
|
||||
},
|
||||
|
||||
state: {
|
||||
async get(params) {
|
||||
return stateStore.get(pluginId, params.scopeKind as any, params.stateKey, {
|
||||
@@ -1013,6 +1163,95 @@ export function buildHostServices(
|
||||
updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(),
|
||||
};
|
||||
},
|
||||
async getManaged(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return projects.resolveManagedProject({
|
||||
companyId,
|
||||
pluginId,
|
||||
pluginKey,
|
||||
projectKey: params.projectKey,
|
||||
createIfMissing: false,
|
||||
});
|
||||
},
|
||||
async reconcileManaged(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return projects.resolveManagedProject({
|
||||
companyId,
|
||||
pluginId,
|
||||
pluginKey,
|
||||
projectKey: params.projectKey,
|
||||
});
|
||||
},
|
||||
async resetManaged(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return projects.resolveManagedProject({
|
||||
companyId,
|
||||
pluginId,
|
||||
pluginKey,
|
||||
projectKey: params.projectKey,
|
||||
reset: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
routines: {
|
||||
async managedGet(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return managedRoutines.get(params.routineKey, companyId);
|
||||
},
|
||||
async managedReconcile(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return managedRoutines.reconcile(params.routineKey, companyId, {
|
||||
assigneeAgentId: params.assigneeAgentId,
|
||||
projectId: params.projectId,
|
||||
});
|
||||
},
|
||||
async managedReset(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return managedRoutines.reset(params.routineKey, companyId, {
|
||||
assigneeAgentId: params.assigneeAgentId,
|
||||
projectId: params.projectId,
|
||||
});
|
||||
},
|
||||
async managedUpdate(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return managedRoutines.update(params.routineKey, companyId, {
|
||||
status: params.status,
|
||||
});
|
||||
},
|
||||
async managedRun(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return managedRoutines.run(params.routineKey, companyId, {
|
||||
assigneeAgentId: params.assigneeAgentId,
|
||||
projectId: params.projectId,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
skills: {
|
||||
async managedGet(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return managedSkills.get(params.skillKey, companyId);
|
||||
},
|
||||
async managedReconcile(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return managedSkills.reconcile(params.skillKey, companyId);
|
||||
},
|
||||
async managedReset(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return managedSkills.reset(params.skillKey, companyId);
|
||||
},
|
||||
},
|
||||
|
||||
issues: {
|
||||
@@ -1031,8 +1270,12 @@ export function buildHostServices(
|
||||
async create(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const { actorAgentId, actorUserId, actorRunId, originKind, ...issueInput } = params;
|
||||
const normalizedOriginKind = normalizePluginOriginKind(originKind);
|
||||
const { actorAgentId, actorUserId, actorRunId, originKind, surfaceVisibility, ...issueInput } = params;
|
||||
const normalizedOriginKind = normalizePluginOriginKind(
|
||||
surfaceVisibility === "plugin_operation" && !originKind
|
||||
? pluginOperationIssueOriginKind(pluginKey)
|
||||
: originKind,
|
||||
);
|
||||
const issue = (await issues.create(companyId, {
|
||||
...(issueInput as any),
|
||||
originKind: normalizedOriginKind,
|
||||
@@ -1641,6 +1884,21 @@ export function buildHostServices(
|
||||
if (!run) throw new Error("Agent wakeup was skipped by heartbeat policy");
|
||||
return { runId: run.id };
|
||||
},
|
||||
async managedGet(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return managedAgents.get(params.agentKey, companyId);
|
||||
},
|
||||
async managedReconcile(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return managedAgents.reconcile(params.agentKey, companyId);
|
||||
},
|
||||
async managedReset(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return managedAgents.reset(params.agentKey, companyId);
|
||||
},
|
||||
},
|
||||
|
||||
goals: {
|
||||
|
||||
@@ -29,7 +29,7 @@ import { readdir, readFile, rm, stat } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type {
|
||||
@@ -248,6 +248,8 @@ export interface PluginRuntimeServices {
|
||||
instanceInfo: {
|
||||
instanceId: string;
|
||||
hostVersion: string;
|
||||
deploymentMode?: "local_trusted" | "authenticated";
|
||||
deploymentExposure?: "private" | "public";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -932,7 +934,10 @@ export function pluginLoader(
|
||||
|
||||
try {
|
||||
// Dynamic import works for both .js (ESM) and .cjs (CJS) manifests
|
||||
const mod = await import(manifestPath) as Record<string, unknown>;
|
||||
const manifestUrl = pathToFileURL(manifestPath);
|
||||
const manifestStat = await stat(manifestPath);
|
||||
manifestUrl.searchParams.set("mtime", String(Math.trunc(manifestStat.mtimeMs)));
|
||||
const mod = await import(manifestUrl.href) as Record<string, unknown>;
|
||||
// The manifest may be the default export or the module itself
|
||||
raw = mod["default"] ?? mod;
|
||||
} catch (err) {
|
||||
@@ -944,6 +949,51 @@ export function pluginLoader(
|
||||
return manifestValidator.parseOrThrow(raw);
|
||||
}
|
||||
|
||||
async function loadManifestFromPackageRoot(
|
||||
packageRoot: string,
|
||||
): Promise<PaperclipPluginManifestV1 | null> {
|
||||
const pkgJson = await readPackageJson(packageRoot);
|
||||
if (!pkgJson) return null;
|
||||
|
||||
const manifestPath = resolveManifestPath(packageRoot, pkgJson);
|
||||
if (!manifestPath || !existsSync(manifestPath)) return null;
|
||||
|
||||
return loadManifestFromPath(manifestPath);
|
||||
}
|
||||
|
||||
async function refreshPluginManifestFromPackage(
|
||||
plugin: PluginRecord,
|
||||
packageRoot: string,
|
||||
): Promise<PluginRecord> {
|
||||
const manifest = await loadManifestFromPackageRoot(packageRoot);
|
||||
if (!manifest) {
|
||||
throw new Error(`Plugin package ${plugin.packageName} no longer exposes a Paperclip manifest`);
|
||||
}
|
||||
if (manifest.id !== plugin.pluginKey) {
|
||||
throw new Error(
|
||||
`Plugin manifest ID '${manifest.id}' does not match installed plugin '${plugin.pluginKey}'`,
|
||||
);
|
||||
}
|
||||
|
||||
if (JSON.stringify(manifest) === JSON.stringify(plugin.manifestJson)) {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
await registry.update(plugin.id, {
|
||||
packageName: plugin.packageName,
|
||||
version: manifest.version,
|
||||
manifest,
|
||||
});
|
||||
|
||||
return {
|
||||
...plugin,
|
||||
version: manifest.version,
|
||||
apiVersion: manifest.apiVersion,
|
||||
categories: manifest.categories,
|
||||
manifestJson: manifest,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DiscoveredPlugin from a resolved package directory, or null
|
||||
* if the package is not a Paperclip plugin.
|
||||
@@ -1256,22 +1306,43 @@ export function pluginLoader(
|
||||
|
||||
async installPlugin(installOptions: PluginInstallOptions): Promise<DiscoveredPlugin> {
|
||||
const discovered = await fetchAndValidate(installOptions);
|
||||
const manifest = discovered.manifest!;
|
||||
|
||||
// Step 6: Persist install record in Postgres (include packagePath for local installs so the worker can be resolved)
|
||||
await registry.install(
|
||||
{
|
||||
packageName: discovered.packageName,
|
||||
packagePath: discovered.source === "local-filesystem" ? discovered.packagePath : undefined,
|
||||
},
|
||||
discovered.manifest!,
|
||||
);
|
||||
// Step 6: Persist install record and apply plugin-owned schema migrations
|
||||
// in one database transaction. If migration validation fails, the plugin
|
||||
// row, namespace record, migration ledger, and created schema all roll back.
|
||||
const installDb = manifest.database ? migrationDb : db;
|
||||
await installDb.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
const txRegistry = pluginRegistryService(txDb);
|
||||
const installed = await txRegistry.install(
|
||||
{
|
||||
packageName: discovered.packageName,
|
||||
packagePath: discovered.source === "local-filesystem" ? discovered.packagePath : undefined,
|
||||
},
|
||||
manifest,
|
||||
);
|
||||
|
||||
if (!installed) {
|
||||
throw new Error(`Plugin install did not return a registry row: ${manifest.id}`);
|
||||
}
|
||||
|
||||
if (manifest.database) {
|
||||
await pluginDatabaseService(txDb).applyMigrations(
|
||||
installed.id,
|
||||
manifest,
|
||||
discovered.packagePath,
|
||||
{ persistFailure: false },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
log.info(
|
||||
{
|
||||
pluginId: discovered.manifest!.id,
|
||||
pluginId: manifest.id,
|
||||
packageName: discovered.packageName,
|
||||
version: discovered.version,
|
||||
capabilities: discovered.manifest!.capabilities,
|
||||
capabilities: manifest.capabilities,
|
||||
},
|
||||
"plugin-loader: plugin installed successfully",
|
||||
);
|
||||
@@ -1663,9 +1734,10 @@ export function pluginLoader(
|
||||
* `error` in the database when activation fails.
|
||||
*/
|
||||
async function activatePlugin(plugin: PluginRecord): Promise<PluginLoadResult> {
|
||||
const manifest = plugin.manifestJson;
|
||||
const pluginId = plugin.id;
|
||||
const pluginKey = plugin.pluginKey;
|
||||
let activePlugin = plugin;
|
||||
let manifest = activePlugin.manifestJson;
|
||||
|
||||
const registered: PluginLoadResult["registered"] = {
|
||||
worker: false,
|
||||
@@ -1705,8 +1777,10 @@ export function pluginLoader(
|
||||
// ------------------------------------------------------------------
|
||||
// 1. Resolve worker entrypoint
|
||||
// ------------------------------------------------------------------
|
||||
const workerEntrypoint = resolveWorkerEntrypoint(plugin, localPluginDir);
|
||||
const packageRoot = resolvePluginPackageRoot(plugin, localPluginDir);
|
||||
const packageRoot = resolvePluginPackageRoot(activePlugin, localPluginDir);
|
||||
activePlugin = await refreshPluginManifestFromPackage(activePlugin, packageRoot);
|
||||
manifest = activePlugin.manifestJson;
|
||||
const workerEntrypoint = resolveWorkerEntrypoint(activePlugin, localPluginDir);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 2. Apply restricted database migrations before worker startup
|
||||
@@ -1746,12 +1820,16 @@ export function pluginLoader(
|
||||
databaseNamespace,
|
||||
hostHandlers,
|
||||
autoRestart: true,
|
||||
env: {
|
||||
PAPERCLIP_DEPLOYMENT_MODE: instanceInfo.deploymentMode ?? "",
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE: instanceInfo.deploymentExposure ?? "",
|
||||
},
|
||||
};
|
||||
|
||||
// Repo-local plugin installs can resolve workspace TS sources at runtime
|
||||
// (for example @paperclipai/shared exports). Run those workers through
|
||||
// the tsx loader so first-party example plugins work in development.
|
||||
if (plugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) {
|
||||
if (activePlugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) {
|
||||
workerOptions.execArgv = ["--import", DEV_TSX_LOADER_PATH];
|
||||
}
|
||||
|
||||
@@ -1842,13 +1920,13 @@ export function pluginLoader(
|
||||
{
|
||||
pluginId,
|
||||
pluginKey,
|
||||
version: plugin.version,
|
||||
version: activePlugin.version,
|
||||
registered,
|
||||
},
|
||||
"plugin-loader: plugin activated successfully",
|
||||
);
|
||||
|
||||
return { plugin, success: true, registered };
|
||||
return { plugin: activePlugin, success: true, registered };
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -1872,7 +1950,7 @@ export function pluginLoader(
|
||||
}
|
||||
|
||||
return {
|
||||
plugin,
|
||||
plugin: activePlugin,
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
registered,
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
import { constants as fsConstants, promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
PluginLocalFolderDeclaration,
|
||||
PluginLocalFolderEntry,
|
||||
PluginLocalFolderListing,
|
||||
PluginLocalFolderProblem,
|
||||
PluginLocalFolderStatus,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import { badRequest, forbidden, notFound } from "../errors.js";
|
||||
|
||||
export interface StoredPluginLocalFolderConfig {
|
||||
path: string;
|
||||
access?: "read" | "readWrite";
|
||||
requiredDirectories?: string[];
|
||||
requiredFiles?: string[];
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface PluginLocalFolderSettingsJson {
|
||||
localFolders?: Record<string, StoredPluginLocalFolderConfig>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const LOCAL_FOLDER_KEY_PATTERN = /^[a-z0-9][a-z0-9._:-]*$/;
|
||||
|
||||
function problem(
|
||||
code: PluginLocalFolderProblem["code"],
|
||||
message: string,
|
||||
problemPath?: string,
|
||||
): PluginLocalFolderProblem {
|
||||
return { code, message, path: problemPath };
|
||||
}
|
||||
|
||||
export function assertPluginLocalFolderKey(folderKey: string) {
|
||||
if (!LOCAL_FOLDER_KEY_PATTERN.test(folderKey)) {
|
||||
throw badRequest("folderKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens");
|
||||
}
|
||||
}
|
||||
|
||||
export function findLocalFolderDeclaration(
|
||||
declarations: PluginLocalFolderDeclaration[] | undefined,
|
||||
folderKey: string,
|
||||
) {
|
||||
return declarations?.find((declaration) => declaration.folderKey === folderKey) ?? null;
|
||||
}
|
||||
|
||||
export function requireLocalFolderDeclaration(
|
||||
declarations: PluginLocalFolderDeclaration[] | undefined,
|
||||
folderKey: string,
|
||||
) {
|
||||
assertPluginLocalFolderKey(folderKey);
|
||||
const declaration = findLocalFolderDeclaration(declarations, folderKey);
|
||||
if (!declaration) {
|
||||
throw badRequest("Local folder key is not declared by this plugin manifest");
|
||||
}
|
||||
return declaration;
|
||||
}
|
||||
|
||||
function normalizeRelativePath(relativePath: string): string {
|
||||
if (
|
||||
!relativePath ||
|
||||
path.isAbsolute(relativePath) ||
|
||||
relativePath.includes("\\") ||
|
||||
relativePath.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
|
||||
) {
|
||||
throw forbidden("Local folder relative paths must stay inside the configured root");
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
function validateRequiredPath(pathValue: string, label: string): string {
|
||||
try {
|
||||
return normalizeRelativePath(pathValue);
|
||||
} catch {
|
||||
throw badRequest(`${label} must contain only relative paths without traversal, empty segments, or backslashes`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeListRelativePath(relativePath: string | null | undefined): string | null {
|
||||
const trimmed = relativePath?.trim();
|
||||
if (!trimmed) return null;
|
||||
return normalizeRelativePath(trimmed);
|
||||
}
|
||||
|
||||
function normalizeMaxEntries(value: number | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return 1000;
|
||||
return Math.max(1, Math.min(5000, Math.floor(value)));
|
||||
}
|
||||
|
||||
function mergeFolderConfig(
|
||||
declaration: PluginLocalFolderDeclaration | null,
|
||||
stored: StoredPluginLocalFolderConfig | null,
|
||||
override?: Partial<StoredPluginLocalFolderConfig>,
|
||||
): StoredPluginLocalFolderConfig | null {
|
||||
const pathValue = override?.path ?? stored?.path;
|
||||
if (!pathValue) return null;
|
||||
return {
|
||||
path: pathValue,
|
||||
access: declaration?.access ?? override?.access ?? stored?.access ?? "readWrite",
|
||||
requiredDirectories:
|
||||
declaration?.requiredDirectories ?? override?.requiredDirectories ?? stored?.requiredDirectories ?? [],
|
||||
requiredFiles:
|
||||
declaration?.requiredFiles ?? override?.requiredFiles ?? stored?.requiredFiles ?? [],
|
||||
updatedAt: stored?.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function getStoredLocalFolders(settingsJson: Record<string, unknown> | null | undefined) {
|
||||
const folders = (settingsJson as PluginLocalFolderSettingsJson | undefined)?.localFolders;
|
||||
if (!folders || typeof folders !== "object") return {};
|
||||
return folders;
|
||||
}
|
||||
|
||||
export function setStoredLocalFolder(
|
||||
settingsJson: Record<string, unknown> | null | undefined,
|
||||
folderKey: string,
|
||||
config: StoredPluginLocalFolderConfig,
|
||||
): PluginLocalFolderSettingsJson {
|
||||
return {
|
||||
...(settingsJson ?? {}),
|
||||
localFolders: {
|
||||
...getStoredLocalFolders(settingsJson),
|
||||
[folderKey]: {
|
||||
...config,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function inspectPluginLocalFolder(input: {
|
||||
folderKey: string;
|
||||
declaration?: PluginLocalFolderDeclaration | null;
|
||||
storedConfig?: StoredPluginLocalFolderConfig | null;
|
||||
overrideConfig?: Partial<StoredPluginLocalFolderConfig>;
|
||||
}): Promise<PluginLocalFolderStatus> {
|
||||
assertPluginLocalFolderKey(input.folderKey);
|
||||
const config = mergeFolderConfig(
|
||||
input.declaration ?? null,
|
||||
input.storedConfig ?? null,
|
||||
input.overrideConfig,
|
||||
);
|
||||
const access = config?.access ?? input.declaration?.access ?? "readWrite";
|
||||
const requiredDirectories = (config?.requiredDirectories ?? []).map((item) =>
|
||||
validateRequiredPath(item, "requiredDirectories"),
|
||||
);
|
||||
const requiredFiles = (config?.requiredFiles ?? []).map((item) =>
|
||||
validateRequiredPath(item, "requiredFiles"),
|
||||
);
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
if (!config?.path) {
|
||||
return {
|
||||
folderKey: input.folderKey,
|
||||
configured: false,
|
||||
path: null,
|
||||
realPath: null,
|
||||
access,
|
||||
readable: false,
|
||||
writable: false,
|
||||
requiredDirectories,
|
||||
requiredFiles,
|
||||
missingDirectories: requiredDirectories,
|
||||
missingFiles: requiredFiles,
|
||||
healthy: false,
|
||||
problems: [problem("not_configured", "No local folder path is configured.")],
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const configuredPath = path.resolve(config.path);
|
||||
const problems: PluginLocalFolderProblem[] = [];
|
||||
const missingDirectories: string[] = [];
|
||||
const missingFiles: string[] = [];
|
||||
const markRequiredPathsMissing = () => {
|
||||
missingDirectories.push(...requiredDirectories);
|
||||
missingFiles.push(...requiredFiles);
|
||||
};
|
||||
let realPath: string | null = null;
|
||||
let readable = false;
|
||||
let writable = false;
|
||||
|
||||
if (!path.isAbsolute(config.path)) {
|
||||
problems.push(problem("not_absolute", "Local folder path must be absolute.", config.path));
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(configuredPath);
|
||||
if (!stat.isDirectory()) {
|
||||
problems.push(problem("not_directory", "Configured local folder path is not a directory.", configuredPath));
|
||||
markRequiredPathsMissing();
|
||||
} else {
|
||||
realPath = await fs.realpath(configuredPath);
|
||||
try {
|
||||
await fs.access(realPath, fsConstants.R_OK);
|
||||
readable = true;
|
||||
} catch {
|
||||
problems.push(problem("not_readable", "Configured local folder is not readable.", configuredPath));
|
||||
}
|
||||
|
||||
if (access === "readWrite") {
|
||||
try {
|
||||
await fs.access(realPath, fsConstants.W_OK);
|
||||
const probePath = path.join(realPath, `.paperclip-local-folder-probe-${process.pid}-${Date.now()}`);
|
||||
await fs.writeFile(probePath, "");
|
||||
await fs.rm(probePath, { force: true });
|
||||
writable = true;
|
||||
} catch {
|
||||
problems.push(problem("not_writable", "Configured local folder is not writable.", configuredPath));
|
||||
}
|
||||
}
|
||||
|
||||
for (const requiredDir of requiredDirectories) {
|
||||
const requiredStatus = await inspectChildPath(realPath, requiredDir, "directory");
|
||||
if (!requiredStatus.exists) {
|
||||
missingDirectories.push(requiredDir);
|
||||
problems.push(problem("missing_directory", "Required directory is missing.", requiredDir));
|
||||
} else if (!requiredStatus.contained) {
|
||||
problems.push(problem("symlink_escape", "Required directory escapes the configured root.", requiredDir));
|
||||
} else if (!requiredStatus.matchesKind) {
|
||||
missingDirectories.push(requiredDir);
|
||||
problems.push(problem("missing_directory", "Required path is not a directory.", requiredDir));
|
||||
}
|
||||
}
|
||||
|
||||
for (const requiredFile of requiredFiles) {
|
||||
const requiredStatus = await inspectChildPath(realPath, requiredFile, "file");
|
||||
if (!requiredStatus.exists) {
|
||||
missingFiles.push(requiredFile);
|
||||
problems.push(problem("missing_file", "Required file is missing.", requiredFile));
|
||||
} else if (!requiredStatus.contained) {
|
||||
problems.push(problem("symlink_escape", "Required file escapes the configured root.", requiredFile));
|
||||
} else if (!requiredStatus.matchesKind) {
|
||||
missingFiles.push(requiredFile);
|
||||
problems.push(problem("missing_file", "Required path is not a file.", requiredFile));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
|
||||
problems.push(problem(code === "ENOENT" ? "missing" : "not_readable", "Configured local folder cannot be inspected.", configuredPath));
|
||||
if (code === "ENOENT") {
|
||||
markRequiredPathsMissing();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
folderKey: input.folderKey,
|
||||
configured: true,
|
||||
path: configuredPath,
|
||||
realPath,
|
||||
access,
|
||||
readable,
|
||||
writable: access === "read" ? false : writable,
|
||||
requiredDirectories,
|
||||
requiredFiles,
|
||||
missingDirectories,
|
||||
missingFiles,
|
||||
healthy:
|
||||
problems.length === 0 &&
|
||||
readable &&
|
||||
(access === "read" || writable),
|
||||
problems,
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function isInsideRoot(rootRealPath: string, candidateRealPath: string) {
|
||||
const relative = path.relative(rootRealPath, candidateRealPath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
async function assertPathInsideRoot(rootRealPath: string, candidatePath: string) {
|
||||
const candidateRealPath = await fs.realpath(candidatePath);
|
||||
if (!isInsideRoot(rootRealPath, candidateRealPath)) {
|
||||
throw forbidden("Local folder symlink escape is not allowed");
|
||||
}
|
||||
return candidateRealPath;
|
||||
}
|
||||
|
||||
async function ensureDirectoryInsideRoot(rootRealPath: string, relativePath: string) {
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
const segments = normalized.split("/");
|
||||
let currentRealPath = rootRealPath;
|
||||
|
||||
for (const segment of segments) {
|
||||
const nextPath = path.join(currentRealPath, segment);
|
||||
try {
|
||||
const stat = await fs.stat(nextPath);
|
||||
if (!stat.isDirectory()) {
|
||||
throw badRequest("Required directory path exists but is not a directory");
|
||||
}
|
||||
} catch (error) {
|
||||
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
|
||||
if (code !== "ENOENT") throw error;
|
||||
await fs.mkdir(nextPath);
|
||||
}
|
||||
|
||||
const nextRealPath = await fs.realpath(nextPath);
|
||||
if (!isInsideRoot(rootRealPath, nextRealPath)) {
|
||||
throw forbidden("Local folder symlink escape is not allowed");
|
||||
}
|
||||
currentRealPath = nextRealPath;
|
||||
}
|
||||
}
|
||||
|
||||
export async function preparePluginLocalFolder(input: {
|
||||
folderKey: string;
|
||||
declaration?: PluginLocalFolderDeclaration | null;
|
||||
storedConfig?: StoredPluginLocalFolderConfig | null;
|
||||
overrideConfig?: Partial<StoredPluginLocalFolderConfig>;
|
||||
}) {
|
||||
assertPluginLocalFolderKey(input.folderKey);
|
||||
const config = mergeFolderConfig(
|
||||
input.declaration ?? null,
|
||||
input.storedConfig ?? null,
|
||||
input.overrideConfig,
|
||||
);
|
||||
const access = config?.access ?? input.declaration?.access ?? "readWrite";
|
||||
if (!config?.path || access !== "readWrite" || !path.isAbsolute(config.path)) return;
|
||||
|
||||
const configuredPath = path.resolve(config.path);
|
||||
try {
|
||||
const stat = await fs.stat(configuredPath);
|
||||
if (!stat.isDirectory()) return;
|
||||
} catch (error) {
|
||||
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
|
||||
if (code !== "ENOENT") return;
|
||||
try {
|
||||
await fs.mkdir(configuredPath, { recursive: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const rootRealPath = await fs.realpath(configuredPath);
|
||||
|
||||
for (const requiredDir of config.requiredDirectories ?? []) {
|
||||
await ensureDirectoryInsideRoot(rootRealPath, validateRequiredPath(requiredDir, "requiredDirectories"));
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectChildPath(
|
||||
rootRealPath: string,
|
||||
relativePath: string,
|
||||
kind: "directory" | "file",
|
||||
) {
|
||||
let resolvedPath: Awaited<ReturnType<typeof resolvePluginLocalFolderPath>>;
|
||||
try {
|
||||
resolvedPath = await resolvePluginLocalFolderPath(rootRealPath, relativePath, {
|
||||
mustExist: true,
|
||||
allowMissingLeaf: true,
|
||||
});
|
||||
} catch {
|
||||
return { exists: true, contained: false, matchesKind: false };
|
||||
}
|
||||
if (!resolvedPath.exists) {
|
||||
return { exists: false, contained: true, matchesKind: false };
|
||||
}
|
||||
const stat = await fs.stat(resolvedPath.realPath);
|
||||
return {
|
||||
exists: true,
|
||||
contained: true,
|
||||
matchesKind: kind === "directory" ? stat.isDirectory() : stat.isFile(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolvePluginLocalFolderPath(
|
||||
rootPath: string,
|
||||
relativePath: string,
|
||||
options?: { mustExist?: boolean; allowMissingLeaf?: boolean },
|
||||
) {
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
const rootRealPath = await fs.realpath(rootPath);
|
||||
const absolutePath = path.resolve(rootRealPath, normalized);
|
||||
const relativeFromRoot = path.relative(rootRealPath, absolutePath);
|
||||
if (relativeFromRoot.startsWith("..") || path.isAbsolute(relativeFromRoot)) {
|
||||
throw forbidden("Local folder path traversal is not allowed");
|
||||
}
|
||||
|
||||
try {
|
||||
const realPath = await fs.realpath(absolutePath);
|
||||
const realRelative = path.relative(rootRealPath, realPath);
|
||||
if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
|
||||
throw forbidden("Local folder symlink escape is not allowed");
|
||||
}
|
||||
return { absolutePath, realPath, exists: true };
|
||||
} catch (error) {
|
||||
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
|
||||
if (code !== "ENOENT" || options?.mustExist) {
|
||||
if (options?.allowMissingLeaf && code === "ENOENT") {
|
||||
return { absolutePath, realPath: absolutePath, exists: false };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const parentRealPath = await fs.realpath(path.dirname(absolutePath));
|
||||
const parentRelative = path.relative(rootRealPath, parentRealPath);
|
||||
if (parentRelative.startsWith("..") || path.isAbsolute(parentRelative)) {
|
||||
throw forbidden("Local folder symlink escape is not allowed");
|
||||
}
|
||||
return { absolutePath, realPath: absolutePath, exists: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPluginLocalFolderText(rootPath: string, relativePath: string) {
|
||||
const resolved = await resolvePluginLocalFolderPath(rootPath, relativePath, { mustExist: true });
|
||||
const stat = await fs.stat(resolved.realPath);
|
||||
if (!stat.isFile()) {
|
||||
throw badRequest("Local folder read target must be a file");
|
||||
}
|
||||
return fs.readFile(resolved.realPath, "utf8");
|
||||
}
|
||||
|
||||
export async function listPluginLocalFolderEntries(
|
||||
rootPath: string,
|
||||
options: { relativePath?: string | null; recursive?: boolean; maxEntries?: number } = {},
|
||||
): Promise<PluginLocalFolderListing> {
|
||||
const rootRealPath = await fs.realpath(rootPath);
|
||||
const relativePath = normalizeListRelativePath(options.relativePath);
|
||||
const target = relativePath
|
||||
? await resolvePluginLocalFolderPath(rootRealPath, relativePath, { mustExist: true })
|
||||
: { absolutePath: rootRealPath, realPath: rootRealPath, exists: true };
|
||||
const targetStat = await fs.stat(target.realPath);
|
||||
if (!targetStat.isDirectory()) {
|
||||
throw badRequest("Local folder list target must be a directory");
|
||||
}
|
||||
|
||||
const maxEntries = normalizeMaxEntries(options.maxEntries);
|
||||
const entries: PluginLocalFolderEntry[] = [];
|
||||
let truncated = false;
|
||||
|
||||
const visit = async (directoryRealPath: string, directoryRelativePath: string | null) => {
|
||||
if (truncated) return;
|
||||
const dirents = await fs.readdir(directoryRealPath, { withFileTypes: true });
|
||||
dirents.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const dirent of dirents) {
|
||||
if (entries.length >= maxEntries) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const childRelativePath = directoryRelativePath ? `${directoryRelativePath}/${dirent.name}` : dirent.name;
|
||||
let resolvedChild: Awaited<ReturnType<typeof resolvePluginLocalFolderPath>>;
|
||||
try {
|
||||
resolvedChild = await resolvePluginLocalFolderPath(rootRealPath, childRelativePath, { mustExist: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = await fs.stat(resolvedChild.realPath).catch(() => null);
|
||||
if (!stat) continue;
|
||||
const kind = stat.isDirectory() ? "directory" : stat.isFile() ? "file" : null;
|
||||
if (!kind) continue;
|
||||
|
||||
entries.push({
|
||||
path: childRelativePath,
|
||||
name: dirent.name,
|
||||
kind,
|
||||
size: kind === "file" ? stat.size : null,
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
});
|
||||
|
||||
if (options.recursive && kind === "directory") {
|
||||
await visit(resolvedChild.realPath, childRelativePath);
|
||||
if (truncated) return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await visit(target.realPath, relativePath);
|
||||
return {
|
||||
folderKey: "list-result",
|
||||
relativePath,
|
||||
entries,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writePluginLocalFolderTextAtomic(
|
||||
rootPath: string,
|
||||
relativePath: string,
|
||||
contents: string,
|
||||
) {
|
||||
const rootRealPath = await fs.realpath(rootPath);
|
||||
const resolved = await resolvePluginLocalFolderPath(rootPath, relativePath);
|
||||
await fs.mkdir(path.dirname(resolved.absolutePath), { recursive: true });
|
||||
await assertPathInsideRoot(rootRealPath, path.dirname(resolved.absolutePath));
|
||||
const tempPath = path.join(
|
||||
path.dirname(resolved.absolutePath),
|
||||
`.paperclip-${path.basename(resolved.absolutePath)}-${process.pid}-${randomUUID()}.tmp`,
|
||||
);
|
||||
let tempCreated = false;
|
||||
try {
|
||||
const handle = await fs.open(tempPath, "wx");
|
||||
tempCreated = true;
|
||||
try {
|
||||
await assertPathInsideRoot(rootRealPath, tempPath);
|
||||
await handle.writeFile(contents, "utf8");
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (error) {
|
||||
if (tempCreated) {
|
||||
await fs.rm(tempPath, { force: true });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await resolvePluginLocalFolderPath(rootRealPath, relativePath);
|
||||
await fs.rename(tempPath, resolved.absolutePath);
|
||||
await resolvePluginLocalFolderPath(rootRealPath, relativePath, { mustExist: true });
|
||||
} catch (error) {
|
||||
await fs.rm(tempPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const dirHandle = await fs.open(path.dirname(resolved.absolutePath), "r");
|
||||
try {
|
||||
await dirHandle.sync();
|
||||
} finally {
|
||||
await dirHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
return inspectPluginLocalFolder({
|
||||
folderKey: "write-result",
|
||||
storedConfig: {
|
||||
path: rootPath,
|
||||
access: "readWrite",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePluginLocalFolderFile(
|
||||
rootPath: string,
|
||||
relativePath: string,
|
||||
folderKey: string,
|
||||
) {
|
||||
const rootRealPath = await fs.realpath(rootPath);
|
||||
let resolved: Awaited<ReturnType<typeof resolvePluginLocalFolderPath>>;
|
||||
try {
|
||||
resolved = await resolvePluginLocalFolderPath(rootRealPath, relativePath, {
|
||||
mustExist: true,
|
||||
allowMissingLeaf: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
|
||||
if (code !== "ENOENT") throw error;
|
||||
return inspectPluginLocalFolder({
|
||||
folderKey,
|
||||
storedConfig: {
|
||||
path: rootPath,
|
||||
access: "readWrite",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (resolved.exists) {
|
||||
const stat = await fs.lstat(resolved.absolutePath);
|
||||
if (stat.isDirectory()) {
|
||||
throw badRequest("Local folder delete target must be a file");
|
||||
}
|
||||
await fs.rm(resolved.absolutePath, { force: true });
|
||||
if (process.platform !== "win32") {
|
||||
const dirHandle = await fs.open(path.dirname(resolved.absolutePath), "r");
|
||||
try {
|
||||
await dirHandle.sync();
|
||||
} finally {
|
||||
await dirHandle.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inspectPluginLocalFolder({
|
||||
folderKey,
|
||||
storedConfig: {
|
||||
path: rootPath,
|
||||
access: "readWrite",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function defaultLocalFolderBasePath(pluginKey: string, companyId: string) {
|
||||
return path.join(os.homedir(), ".paperclip", "plugin-data", companyId, pluginKey);
|
||||
}
|
||||
|
||||
export function assertConfiguredLocalFolder(status: PluginLocalFolderStatus) {
|
||||
if (!status.configured || !status.realPath || !status.readable) {
|
||||
throw notFound("Local folder is not configured or readable");
|
||||
}
|
||||
if (!status.healthy) {
|
||||
throw badRequest("Local folder is not healthy");
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWritableConfiguredLocalFolder(status: PluginLocalFolderStatus) {
|
||||
if (!status.configured || !status.realPath || !status.readable) {
|
||||
throw notFound("Local folder is not configured or readable");
|
||||
}
|
||||
const onlyMissingRequiredPaths = status.problems.every((item) =>
|
||||
item.code === "missing_directory" || item.code === "missing_file"
|
||||
);
|
||||
if (!status.healthy && !onlyMissingRequiredPaths) {
|
||||
throw badRequest("Local folder is not healthy");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
pluginEntities,
|
||||
pluginManagedResources,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
Agent,
|
||||
PaperclipPluginManifestV1,
|
||||
PluginManagedAgentDeclaration,
|
||||
PluginManagedAgentResolution,
|
||||
} from "@paperclipai/shared";
|
||||
import { notFound } from "../errors.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { approvalService } from "./approvals.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { agentInstructionsService } from "./agent-instructions.js";
|
||||
|
||||
const MANAGED_AGENT_ENTITY_TYPE = "managed_agent";
|
||||
const DEFAULT_MANAGED_AGENT_ADAPTER_TYPE = "process";
|
||||
|
||||
interface PluginManagedAgentServiceOptions {
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
manifest?: PaperclipPluginManifestV1 | null;
|
||||
instructionTemplateVariables?: (companyId: string) => Promise<Record<string, string | null | undefined>>;
|
||||
}
|
||||
|
||||
function bindingExternalId(companyId: string, agentKey: string) {
|
||||
return `managed:agent:${companyId}:${agentKey}`;
|
||||
}
|
||||
|
||||
function managedMetadata(
|
||||
pluginId: string,
|
||||
pluginKey: string,
|
||||
declaration: PluginManagedAgentDeclaration,
|
||||
existing?: Record<string, unknown> | null,
|
||||
) {
|
||||
return {
|
||||
...(existing ?? {}),
|
||||
paperclipManagedResource: {
|
||||
pluginId,
|
||||
pluginKey,
|
||||
resourceKind: "agent",
|
||||
resourceKey: declaration.agentKey,
|
||||
},
|
||||
pluginManagedAgent: {
|
||||
pluginId,
|
||||
pluginKey,
|
||||
agentKey: declaration.agentKey,
|
||||
displayName: declaration.displayName,
|
||||
instructions: declaration.instructions ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAdapterType(value: unknown) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function fallbackAdapterType(declaration: PluginManagedAgentDeclaration) {
|
||||
return normalizeAdapterType(declaration.adapterType) ?? DEFAULT_MANAGED_AGENT_ADAPTER_TYPE;
|
||||
}
|
||||
|
||||
function adapterPreference(declaration: PluginManagedAgentDeclaration) {
|
||||
const seen = new Set<string>();
|
||||
const preference: string[] = [];
|
||||
for (const value of declaration.adapterPreference ?? []) {
|
||||
const adapterType = normalizeAdapterType(value);
|
||||
if (!adapterType || seen.has(adapterType)) continue;
|
||||
seen.add(adapterType);
|
||||
preference.push(adapterType);
|
||||
}
|
||||
return preference;
|
||||
}
|
||||
|
||||
function selectPreferredAdapterType(
|
||||
declaration: PluginManagedAgentDeclaration,
|
||||
usage: Array<{ adapterType: string; count: number }>,
|
||||
) {
|
||||
const fallback = fallbackAdapterType(declaration);
|
||||
const preference = adapterPreference(declaration);
|
||||
if (preference.length === 0) return fallback;
|
||||
|
||||
const rank = new Map(preference.map((adapterType, index) => [adapterType, index]));
|
||||
let selected: { adapterType: string; count: number; rank: number } | null = null;
|
||||
for (const entry of usage) {
|
||||
const adapterRank = rank.get(entry.adapterType);
|
||||
if (adapterRank === undefined) continue;
|
||||
if (
|
||||
!selected ||
|
||||
entry.count > selected.count ||
|
||||
(entry.count === selected.count && adapterRank < selected.rank)
|
||||
) {
|
||||
selected = { ...entry, rank: adapterRank };
|
||||
}
|
||||
}
|
||||
return selected?.adapterType ?? fallback;
|
||||
}
|
||||
|
||||
function declarationPatch(declaration: PluginManagedAgentDeclaration, input: { adapterType?: string } = {}) {
|
||||
return {
|
||||
name: declaration.displayName,
|
||||
role: declaration.role ?? "general",
|
||||
title: declaration.title ?? null,
|
||||
icon: declaration.icon ?? null,
|
||||
capabilities: declaration.capabilities ?? null,
|
||||
adapterType: input.adapterType ?? fallbackAdapterType(declaration),
|
||||
adapterConfig: declaration.adapterConfig ?? {},
|
||||
runtimeConfig: declaration.runtimeConfig ?? {},
|
||||
permissions: declaration.permissions ?? {},
|
||||
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function applyInstructionTemplateVariables(
|
||||
content: string,
|
||||
variables: Record<string, string | null | undefined>,
|
||||
) {
|
||||
let next = content;
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
next = next.replaceAll(`{{${key}}}`, value?.trim() || "(not configured)");
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function declaredInstructionFiles(
|
||||
declaration: PluginManagedAgentDeclaration,
|
||||
variables: Record<string, string | null | undefined>,
|
||||
) {
|
||||
const instructionDeclaration = declaration.instructions;
|
||||
if (!instructionDeclaration?.content && !instructionDeclaration?.files) return null;
|
||||
|
||||
const entryFile = instructionDeclaration.entryFile ?? "AGENTS.md";
|
||||
const files = { ...(instructionDeclaration.files ?? {}) };
|
||||
if (instructionDeclaration.content !== undefined) {
|
||||
files[entryFile] = instructionDeclaration.content;
|
||||
}
|
||||
if (files[entryFile] === undefined) {
|
||||
files[entryFile] = "";
|
||||
}
|
||||
|
||||
return {
|
||||
entryFile,
|
||||
files: Object.fromEntries(
|
||||
Object.entries(files).map(([filePath, content]) => [
|
||||
filePath,
|
||||
applyInstructionTemplateVariables(content, variables),
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function rowIsManagedAgent(
|
||||
row: typeof agents.$inferSelect,
|
||||
pluginKey: string,
|
||||
agentKey: string,
|
||||
) {
|
||||
const metadata = row.metadata;
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return false;
|
||||
const marker = (metadata as Record<string, unknown>).paperclipManagedResource;
|
||||
if (!marker || typeof marker !== "object" || Array.isArray(marker)) return false;
|
||||
const record = marker as Record<string, unknown>;
|
||||
return (
|
||||
record.pluginKey === pluginKey
|
||||
&& record.resourceKind === "agent"
|
||||
&& record.resourceKey === agentKey
|
||||
);
|
||||
}
|
||||
|
||||
export function pluginManagedAgentService(
|
||||
db: Db,
|
||||
options: PluginManagedAgentServiceOptions,
|
||||
) {
|
||||
const agentSvc = agentService(db);
|
||||
const approvalSvc = approvalService(db);
|
||||
const instructions = agentInstructionsService();
|
||||
|
||||
function declarationFor(agentKey: string) {
|
||||
const declaration = options.manifest?.agents?.find((agent) => agent.agentKey === agentKey);
|
||||
if (!declaration) {
|
||||
throw notFound(`Managed agent declaration not found: ${agentKey}`);
|
||||
}
|
||||
return declaration;
|
||||
}
|
||||
|
||||
async function getBinding(companyId: string, agentKey: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(pluginEntities)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginEntities.pluginId, options.pluginId),
|
||||
eq(pluginEntities.entityType, MANAGED_AGENT_ENTITY_TYPE),
|
||||
eq(pluginEntities.externalId, bindingExternalId(companyId, agentKey)),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function upsertBinding(
|
||||
companyId: string,
|
||||
declaration: PluginManagedAgentDeclaration,
|
||||
agentId: string,
|
||||
extraData: Record<string, unknown> = {},
|
||||
effectiveAdapterType?: string,
|
||||
) {
|
||||
const adapterType = effectiveAdapterType ?? (await resolveManagedAdapterType(companyId, declaration));
|
||||
const defaultsJson = {
|
||||
agentKey: declaration.agentKey,
|
||||
displayName: declaration.displayName,
|
||||
role: declaration.role ?? "general",
|
||||
title: declaration.title ?? null,
|
||||
icon: declaration.icon ?? null,
|
||||
capabilities: declaration.capabilities ?? null,
|
||||
adapterType,
|
||||
adapterPreference: declaration.adapterPreference ?? null,
|
||||
adapterConfig: declaration.adapterConfig ?? {},
|
||||
runtimeConfig: declaration.runtimeConfig ?? {},
|
||||
permissions: declaration.permissions ?? {},
|
||||
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
|
||||
instructions: declaration.instructions ?? null,
|
||||
};
|
||||
const managedResource = await db
|
||||
.select({ id: pluginManagedResources.id })
|
||||
.from(pluginManagedResources)
|
||||
.where(and(
|
||||
eq(pluginManagedResources.companyId, companyId),
|
||||
eq(pluginManagedResources.pluginId, options.pluginId),
|
||||
eq(pluginManagedResources.resourceKind, "agent"),
|
||||
eq(pluginManagedResources.resourceKey, declaration.agentKey),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (managedResource) {
|
||||
await db
|
||||
.update(pluginManagedResources)
|
||||
.set({ resourceId: agentId, defaultsJson, updatedAt: new Date() })
|
||||
.where(eq(pluginManagedResources.id, managedResource.id));
|
||||
} else {
|
||||
await db.insert(pluginManagedResources).values({
|
||||
companyId,
|
||||
pluginId: options.pluginId,
|
||||
pluginKey: options.pluginKey,
|
||||
resourceKind: "agent",
|
||||
resourceKey: declaration.agentKey,
|
||||
resourceId: agentId,
|
||||
defaultsJson,
|
||||
});
|
||||
}
|
||||
|
||||
const externalId = bindingExternalId(companyId, declaration.agentKey);
|
||||
const data = {
|
||||
pluginKey: options.pluginKey,
|
||||
resourceKind: "agent",
|
||||
resourceKey: declaration.agentKey,
|
||||
agentId,
|
||||
adapterType,
|
||||
declarationSnapshot: declaration,
|
||||
lastReconciledAt: new Date().toISOString(),
|
||||
...extraData,
|
||||
};
|
||||
const existing = await getBinding(companyId, declaration.agentKey);
|
||||
if (existing) {
|
||||
return db
|
||||
.update(pluginEntities)
|
||||
.set({
|
||||
scopeKind: "company",
|
||||
scopeId: companyId,
|
||||
title: declaration.displayName,
|
||||
status: "resolved",
|
||||
data,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginEntities.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
return db
|
||||
.insert(pluginEntities)
|
||||
.values({
|
||||
pluginId: options.pluginId,
|
||||
entityType: MANAGED_AGENT_ENTITY_TYPE,
|
||||
scopeKind: "company",
|
||||
scopeId: companyId,
|
||||
externalId,
|
||||
title: declaration.displayName,
|
||||
status: "resolved",
|
||||
data,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
async function findRelinkCandidate(companyId: string, declaration: PluginManagedAgentDeclaration) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
|
||||
return rows.find((row) => rowIsManagedAgent(row, options.pluginKey, declaration.agentKey)) ?? null;
|
||||
}
|
||||
|
||||
async function companyAdapterUsage(companyId: string) {
|
||||
const rows = await db
|
||||
.select({ adapterType: agents.adapterType })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const adapterType = normalizeAdapterType(row.adapterType);
|
||||
if (!adapterType) continue;
|
||||
counts.set(adapterType, (counts.get(adapterType) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([adapterType, count]) => ({ adapterType, count }))
|
||||
.sort((a, b) => b.count - a.count || a.adapterType.localeCompare(b.adapterType))
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
async function resolveManagedAdapterType(companyId: string, declaration: PluginManagedAgentDeclaration) {
|
||||
return selectPreferredAdapterType(declaration, await companyAdapterUsage(companyId));
|
||||
}
|
||||
|
||||
async function materializeDeclaredInstructions(
|
||||
companyId: string,
|
||||
agent: Agent,
|
||||
declaration: PluginManagedAgentDeclaration,
|
||||
materializeOptions: { replaceExisting: boolean },
|
||||
): Promise<Agent> {
|
||||
const variables = await optionsForInstructionVariables(companyId);
|
||||
const declared = declaredInstructionFiles(declaration, variables);
|
||||
if (!declared) return agent;
|
||||
|
||||
const materialized = await instructions.materializeManagedBundle(
|
||||
agent,
|
||||
declared.files,
|
||||
{
|
||||
entryFile: declared.entryFile,
|
||||
replaceExisting: materializeOptions.replaceExisting,
|
||||
clearLegacyPromptTemplate: true,
|
||||
},
|
||||
);
|
||||
const updated = await agentSvc.update(agent.id, {
|
||||
adapterConfig: materialized.adapterConfig,
|
||||
}, {
|
||||
recordRevision: {
|
||||
source: `plugin:${optionsForRevisionSource()}:managed-agent-instructions`,
|
||||
},
|
||||
});
|
||||
return (updated as Agent | null) ?? { ...agent, adapterConfig: materialized.adapterConfig };
|
||||
}
|
||||
|
||||
async function managedInstructionDefaultDrift(
|
||||
companyId: string,
|
||||
agent: Agent | null,
|
||||
declaration: PluginManagedAgentDeclaration,
|
||||
): Promise<PluginManagedAgentResolution["defaultDrift"]> {
|
||||
if (!agent) return null;
|
||||
const variables = await optionsForInstructionVariables(companyId);
|
||||
const declared = declaredInstructionFiles(declaration, variables);
|
||||
if (!declared) return null;
|
||||
|
||||
let exported: Awaited<ReturnType<typeof instructions.exportFiles>>;
|
||||
try {
|
||||
exported = await instructions.exportFiles(agent);
|
||||
} catch {
|
||||
return { entryFile: declared.entryFile, changedFiles: [declared.entryFile] };
|
||||
}
|
||||
|
||||
const paths = new Set([...Object.keys(declared.files), ...Object.keys(exported.files)]);
|
||||
const changedFiles = [...paths]
|
||||
.filter((filePath) => (exported.files[filePath] ?? null) !== (declared.files[filePath] ?? null))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
if (exported.entryFile !== declared.entryFile && !changedFiles.includes(declared.entryFile)) {
|
||||
changedFiles.unshift(declared.entryFile);
|
||||
}
|
||||
return changedFiles.length > 0 ? { entryFile: declared.entryFile, changedFiles } : null;
|
||||
}
|
||||
|
||||
async function optionsForInstructionVariables(companyId: string) {
|
||||
return options.instructionTemplateVariables ? options.instructionTemplateVariables(companyId) : {};
|
||||
}
|
||||
|
||||
function optionsForRevisionSource() {
|
||||
return options.pluginKey;
|
||||
}
|
||||
|
||||
async function resolution(
|
||||
companyId: string,
|
||||
declaration: PluginManagedAgentDeclaration,
|
||||
agent: Agent | null,
|
||||
status: PluginManagedAgentResolution["status"],
|
||||
approvalId?: string | null,
|
||||
): Promise<PluginManagedAgentResolution> {
|
||||
return {
|
||||
pluginKey: options.pluginKey,
|
||||
resourceKind: "agent",
|
||||
resourceKey: declaration.agentKey,
|
||||
companyId,
|
||||
agentId: agent?.id ?? null,
|
||||
agent,
|
||||
status,
|
||||
approvalId: approvalId ?? null,
|
||||
defaultDrift: await managedInstructionDefaultDrift(companyId, agent, declaration),
|
||||
};
|
||||
}
|
||||
|
||||
async function createManagedAgent(companyId: string, declaration: PluginManagedAgentDeclaration) {
|
||||
const company = await db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!company) throw notFound("Company not found");
|
||||
|
||||
const requiresApproval = company.requireBoardApprovalForNewAgents;
|
||||
const adapterType = await resolveManagedAdapterType(companyId, declaration);
|
||||
let created = await agentSvc.create(companyId, {
|
||||
...declarationPatch(declaration, { adapterType }),
|
||||
status: requiresApproval ? "pending_approval" : declaration.status ?? "idle",
|
||||
metadata: managedMetadata(options.pluginId, options.pluginKey, declaration),
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
}) as Agent;
|
||||
created = await materializeDeclaredInstructions(companyId, created, declaration, { replaceExisting: true });
|
||||
|
||||
let approvalId: string | null = null;
|
||||
if (requiresApproval) {
|
||||
const approval = await approvalSvc.create(companyId, {
|
||||
type: "hire_agent",
|
||||
requestedByAgentId: null,
|
||||
requestedByUserId: null,
|
||||
status: "pending",
|
||||
payload: {
|
||||
name: created.name,
|
||||
role: created.role,
|
||||
title: created.title,
|
||||
icon: created.icon,
|
||||
reportsTo: created.reportsTo,
|
||||
capabilities: created.capabilities,
|
||||
adapterType: created.adapterType,
|
||||
adapterConfig: created.adapterConfig,
|
||||
runtimeConfig: created.runtimeConfig,
|
||||
budgetMonthlyCents: created.budgetMonthlyCents,
|
||||
metadata: created.metadata,
|
||||
agentId: created.id,
|
||||
sourcePluginId: options.pluginId,
|
||||
sourcePluginKey: options.pluginKey,
|
||||
managedResourceKey: declaration.agentKey,
|
||||
},
|
||||
decisionNote: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
approvalId = approval.id;
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "plugin",
|
||||
actorId: options.pluginId,
|
||||
action: "approval.created",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: {
|
||||
type: "hire_agent",
|
||||
linkedAgentId: created.id,
|
||||
sourcePluginKey: options.pluginKey,
|
||||
managedResourceKey: declaration.agentKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await upsertBinding(companyId, declaration, created.id, { approvalId }, adapterType);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "plugin",
|
||||
actorId: options.pluginId,
|
||||
action: "plugin.managed_agent.created",
|
||||
entityType: "agent",
|
||||
entityId: created.id,
|
||||
details: {
|
||||
sourcePluginKey: options.pluginKey,
|
||||
managedResourceKey: declaration.agentKey,
|
||||
adapterType,
|
||||
requiresApproval,
|
||||
approvalId,
|
||||
},
|
||||
});
|
||||
return resolution(companyId, declaration, created as Agent, "created", approvalId);
|
||||
}
|
||||
|
||||
async function get(agentKey: string, companyId: string) {
|
||||
const declaration = declarationFor(agentKey);
|
||||
const binding = await getBinding(companyId, agentKey);
|
||||
const boundAgentId = typeof binding?.data?.agentId === "string" ? binding.data.agentId : null;
|
||||
if (!boundAgentId) return resolution(companyId, declaration, null, "missing");
|
||||
const agent = await agentSvc.getById(boundAgentId);
|
||||
if (!agent || agent.companyId !== companyId || agent.status === "terminated") {
|
||||
return resolution(companyId, declaration, null, "missing");
|
||||
}
|
||||
return resolution(companyId, declaration, agent as Agent, "resolved");
|
||||
}
|
||||
|
||||
async function reconcile(agentKey: string, companyId: string) {
|
||||
const declaration = declarationFor(agentKey);
|
||||
const current = await get(agentKey, companyId);
|
||||
if (current.agent) {
|
||||
await upsertBinding(companyId, declaration, current.agent.id);
|
||||
return current;
|
||||
}
|
||||
|
||||
const relinkCandidate = await findRelinkCandidate(companyId, declaration);
|
||||
if (relinkCandidate) {
|
||||
await upsertBinding(companyId, declaration, relinkCandidate.id);
|
||||
const agent = await agentSvc.getById(relinkCandidate.id);
|
||||
return resolution(companyId, declaration, agent as Agent, "relinked");
|
||||
}
|
||||
|
||||
return createManagedAgent(companyId, declaration);
|
||||
}
|
||||
|
||||
async function reset(agentKey: string, companyId: string) {
|
||||
const declaration = declarationFor(agentKey);
|
||||
const reconciled = await reconcile(agentKey, companyId);
|
||||
if (!reconciled.agent) return reconciled;
|
||||
const currentMetadata = reconciled.agent.metadata && typeof reconciled.agent.metadata === "object"
|
||||
? reconciled.agent.metadata
|
||||
: {};
|
||||
const adapterType = await resolveManagedAdapterType(companyId, declaration);
|
||||
const updated = await agentSvc.update(reconciled.agent.id, {
|
||||
...declarationPatch(declaration, { adapterType }),
|
||||
metadata: managedMetadata(options.pluginId, options.pluginKey, declaration, currentMetadata),
|
||||
}, {
|
||||
recordRevision: {
|
||||
source: `plugin:${options.pluginKey}:managed-agent-reset`,
|
||||
},
|
||||
});
|
||||
if (!updated) throw notFound("Managed agent not found");
|
||||
const updatedAgent = await materializeDeclaredInstructions(companyId, updated as Agent, declaration, { replaceExisting: true });
|
||||
await upsertBinding(companyId, declaration, updatedAgent.id, {}, adapterType);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "plugin",
|
||||
actorId: options.pluginId,
|
||||
action: "plugin.managed_agent.reset",
|
||||
entityType: "agent",
|
||||
entityId: updatedAgent.id,
|
||||
details: {
|
||||
sourcePluginKey: options.pluginKey,
|
||||
managedResourceKey: declaration.agentKey,
|
||||
},
|
||||
});
|
||||
return resolution(companyId, declaration, updatedAgent, "reset");
|
||||
}
|
||||
|
||||
return {
|
||||
get,
|
||||
reconcile,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
pluginManagedResources,
|
||||
plugins,
|
||||
projects,
|
||||
routines,
|
||||
routineTriggers,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
CreateRoutineTrigger,
|
||||
PluginManagedResourceRef,
|
||||
PluginManagedRoutineDeclaration,
|
||||
PluginManagedRoutineResolution,
|
||||
Routine,
|
||||
RoutineManagedByPlugin,
|
||||
RoutineStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { ROUTINE_STATUSES } from "@paperclipai/shared";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { routineService } from "./routines.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
|
||||
const MANAGED_ROUTINE_RESOURCE_KIND = "routine";
|
||||
|
||||
interface PluginManagedRoutineServiceOptions {
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
manifest?: import("@paperclipai/shared").PaperclipPluginManifestV1 | null;
|
||||
pluginWorkerManager?: PluginWorkerManager;
|
||||
}
|
||||
|
||||
interface RoutineOverrides {
|
||||
assigneeAgentId?: string | null;
|
||||
projectId?: string | null;
|
||||
}
|
||||
|
||||
function buildRoutineDefaults(declaration: PluginManagedRoutineDeclaration) {
|
||||
return {
|
||||
routineKey: declaration.routineKey,
|
||||
title: declaration.title,
|
||||
description: declaration.description ?? null,
|
||||
assigneeRef: declaration.assigneeRef ?? null,
|
||||
projectRef: declaration.projectRef ?? null,
|
||||
goalId: declaration.goalId ?? null,
|
||||
status: declaration.status ?? null,
|
||||
priority: declaration.priority ?? "medium",
|
||||
concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active",
|
||||
catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed",
|
||||
variables: declaration.variables ?? [],
|
||||
triggers: declaration.triggers ?? [],
|
||||
issueTemplate: declaration.issueTemplate ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRef(
|
||||
pluginKey: string,
|
||||
ref: PluginManagedResourceRef | null | undefined,
|
||||
resourceKind: "agent" | "project",
|
||||
) {
|
||||
if (!ref) return null;
|
||||
if (ref.resourceKind !== resourceKind) {
|
||||
throw unprocessable(`Managed routine ${resourceKind} ref must target ${resourceKind}`);
|
||||
}
|
||||
if (ref.pluginKey && ref.pluginKey !== pluginKey) {
|
||||
throw unprocessable("Managed routine refs must target the declaring plugin");
|
||||
}
|
||||
return { ...ref, pluginKey };
|
||||
}
|
||||
|
||||
function managedByPlugin(row: {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
manifestJson: { displayName?: string } | null;
|
||||
resourceKey: string;
|
||||
defaultsJson: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): RoutineManagedByPlugin {
|
||||
return {
|
||||
id: row.id,
|
||||
pluginId: row.pluginId,
|
||||
pluginKey: row.pluginKey,
|
||||
pluginDisplayName: row.manifestJson?.displayName ?? row.pluginKey,
|
||||
resourceKind: "routine",
|
||||
resourceKey: row.resourceKey,
|
||||
defaultsJson: row.defaultsJson,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function triggerInput(trigger: NonNullable<PluginManagedRoutineDeclaration["triggers"]>[number]): CreateRoutineTrigger {
|
||||
if (trigger.kind === "schedule") {
|
||||
if (!trigger.cronExpression) {
|
||||
throw unprocessable("Managed schedule routine triggers require cronExpression");
|
||||
}
|
||||
return {
|
||||
kind: "schedule",
|
||||
label: trigger.label ?? null,
|
||||
enabled: trigger.enabled ?? true,
|
||||
cronExpression: trigger.cronExpression,
|
||||
timezone: trigger.timezone ?? "UTC",
|
||||
};
|
||||
}
|
||||
if (trigger.kind === "webhook") {
|
||||
return {
|
||||
kind: "webhook",
|
||||
label: trigger.label ?? null,
|
||||
enabled: trigger.enabled ?? true,
|
||||
signingMode: (trigger.signingMode ?? "bearer") as Extract<CreateRoutineTrigger, { kind: "webhook" }>["signingMode"],
|
||||
replayWindowSec: trigger.replayWindowSec ?? 300,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "api",
|
||||
label: trigger.label ?? null,
|
||||
enabled: trigger.enabled ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginManagedRoutineService(
|
||||
db: Db,
|
||||
options: PluginManagedRoutineServiceOptions,
|
||||
) {
|
||||
const routinesSvc = routineService(db, {
|
||||
pluginWorkerManager: options.pluginWorkerManager,
|
||||
});
|
||||
|
||||
function declarationFor(routineKey: string) {
|
||||
const declaration = options.manifest?.routines?.find((routine) => routine.routineKey === routineKey);
|
||||
if (!declaration) {
|
||||
throw notFound(`Managed routine declaration not found: ${routineKey}`);
|
||||
}
|
||||
return declaration;
|
||||
}
|
||||
|
||||
async function getBinding(companyId: string, routineKey: string) {
|
||||
return db
|
||||
.select({
|
||||
id: pluginManagedResources.id,
|
||||
companyId: pluginManagedResources.companyId,
|
||||
pluginId: pluginManagedResources.pluginId,
|
||||
pluginKey: pluginManagedResources.pluginKey,
|
||||
resourceKind: pluginManagedResources.resourceKind,
|
||||
resourceKey: pluginManagedResources.resourceKey,
|
||||
resourceId: pluginManagedResources.resourceId,
|
||||
defaultsJson: pluginManagedResources.defaultsJson,
|
||||
manifestJson: plugins.manifestJson,
|
||||
createdAt: pluginManagedResources.createdAt,
|
||||
updatedAt: pluginManagedResources.updatedAt,
|
||||
})
|
||||
.from(pluginManagedResources)
|
||||
.innerJoin(plugins, eq(pluginManagedResources.pluginId, plugins.id))
|
||||
.where(
|
||||
and(
|
||||
eq(pluginManagedResources.companyId, companyId),
|
||||
eq(pluginManagedResources.pluginId, options.pluginId),
|
||||
eq(pluginManagedResources.resourceKind, MANAGED_ROUTINE_RESOURCE_KIND),
|
||||
eq(pluginManagedResources.resourceKey, routineKey),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function upsertBinding(
|
||||
companyId: string,
|
||||
declaration: PluginManagedRoutineDeclaration,
|
||||
routineId: string,
|
||||
) {
|
||||
const defaultsJson = buildRoutineDefaults(declaration);
|
||||
const existing = await getBinding(companyId, declaration.routineKey);
|
||||
if (existing) {
|
||||
return db
|
||||
.update(pluginManagedResources)
|
||||
.set({
|
||||
resourceId: routineId,
|
||||
defaultsJson,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginManagedResources.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
return db
|
||||
.insert(pluginManagedResources)
|
||||
.values({
|
||||
companyId,
|
||||
pluginId: options.pluginId,
|
||||
pluginKey: options.pluginKey,
|
||||
resourceKind: MANAGED_ROUTINE_RESOURCE_KIND,
|
||||
resourceKey: declaration.routineKey,
|
||||
resourceId: routineId,
|
||||
defaultsJson,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
async function getRoutineWithManagedBy(companyId: string, declaration: PluginManagedRoutineDeclaration) {
|
||||
const binding = await getBinding(companyId, declaration.routineKey);
|
||||
if (!binding) return null;
|
||||
const routine = await db
|
||||
.select()
|
||||
.from(routines)
|
||||
.where(and(eq(routines.companyId, companyId), eq(routines.id, binding.resourceId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!routine) return null;
|
||||
return {
|
||||
...routine,
|
||||
managedByPlugin: managedByPlugin(binding),
|
||||
} as Routine;
|
||||
}
|
||||
|
||||
async function resolveAgentId(
|
||||
companyId: string,
|
||||
declaration: PluginManagedRoutineDeclaration,
|
||||
overrides?: RoutineOverrides,
|
||||
) {
|
||||
if (overrides?.assigneeAgentId !== undefined) {
|
||||
if (!overrides.assigneeAgentId) return { agentId: null, missingRef: null };
|
||||
const row = await db
|
||||
.select({ id: agents.id })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), eq(agents.id, overrides.assigneeAgentId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Assignee agent not found");
|
||||
return { agentId: row.id, missingRef: null };
|
||||
}
|
||||
|
||||
const ref = normalizeRef(options.pluginKey, declaration.assigneeRef, "agent");
|
||||
if (!ref) return { agentId: null, missingRef: null };
|
||||
const binding = await db
|
||||
.select({ resourceId: pluginManagedResources.resourceId })
|
||||
.from(pluginManagedResources)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginManagedResources.companyId, companyId),
|
||||
eq(pluginManagedResources.pluginId, options.pluginId),
|
||||
eq(pluginManagedResources.resourceKind, "agent"),
|
||||
eq(pluginManagedResources.resourceKey, ref.resourceKey),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!binding) return { agentId: null, missingRef: ref };
|
||||
const row = await db
|
||||
.select({ id: agents.id })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), eq(agents.id, binding.resourceId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? { agentId: row.id, missingRef: null } : { agentId: null, missingRef: ref };
|
||||
}
|
||||
|
||||
async function resolveProjectId(
|
||||
companyId: string,
|
||||
declaration: PluginManagedRoutineDeclaration,
|
||||
overrides?: RoutineOverrides,
|
||||
) {
|
||||
if (overrides?.projectId !== undefined) {
|
||||
if (!overrides.projectId) return { projectId: null, missingRef: null };
|
||||
const row = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), eq(projects.id, overrides.projectId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Project not found");
|
||||
return { projectId: row.id, missingRef: null };
|
||||
}
|
||||
|
||||
const ref = normalizeRef(options.pluginKey, declaration.projectRef, "project");
|
||||
if (!ref) return { projectId: null, missingRef: null };
|
||||
const binding = await db
|
||||
.select({ resourceId: pluginManagedResources.resourceId })
|
||||
.from(pluginManagedResources)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginManagedResources.companyId, companyId),
|
||||
eq(pluginManagedResources.pluginId, options.pluginId),
|
||||
eq(pluginManagedResources.resourceKind, "project"),
|
||||
eq(pluginManagedResources.resourceKey, ref.resourceKey),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!binding) return { projectId: null, missingRef: ref };
|
||||
const row = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), eq(projects.id, binding.resourceId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? { projectId: row.id, missingRef: null } : { projectId: null, missingRef: ref };
|
||||
}
|
||||
|
||||
async function resolveRefs(
|
||||
companyId: string,
|
||||
declaration: PluginManagedRoutineDeclaration,
|
||||
overrides?: RoutineOverrides,
|
||||
) {
|
||||
const [agent, project] = await Promise.all([
|
||||
resolveAgentId(companyId, declaration, overrides),
|
||||
resolveProjectId(companyId, declaration, overrides),
|
||||
]);
|
||||
const missingRefs: PluginManagedResourceRef[] = [];
|
||||
if (agent.missingRef) missingRefs.push(agent.missingRef);
|
||||
if (project.missingRef) missingRefs.push(project.missingRef);
|
||||
return {
|
||||
assigneeAgentId: agent.agentId,
|
||||
projectId: project.projectId,
|
||||
missingRefs,
|
||||
};
|
||||
}
|
||||
|
||||
function resolution(
|
||||
companyId: string,
|
||||
declaration: PluginManagedRoutineDeclaration,
|
||||
routine: Routine | null,
|
||||
status: PluginManagedRoutineResolution["status"],
|
||||
missingRefs: PluginManagedResourceRef[] = [],
|
||||
): PluginManagedRoutineResolution {
|
||||
return {
|
||||
pluginKey: options.pluginKey,
|
||||
resourceKind: "routine",
|
||||
resourceKey: declaration.routineKey,
|
||||
companyId,
|
||||
routineId: routine?.id ?? null,
|
||||
routine,
|
||||
status,
|
||||
missingRefs,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureDefaultTriggers(
|
||||
routineId: string,
|
||||
declaration: PluginManagedRoutineDeclaration,
|
||||
) {
|
||||
const triggers = declaration.triggers ?? [];
|
||||
if (triggers.length === 0) return;
|
||||
const existingCount = await db
|
||||
.select({ id: routineTriggers.id })
|
||||
.from(routineTriggers)
|
||||
.where(eq(routineTriggers.routineId, routineId))
|
||||
.limit(1)
|
||||
.then((rows) => rows.length);
|
||||
if (existingCount > 0) return;
|
||||
|
||||
for (const trigger of triggers) {
|
||||
await routinesSvc.createTrigger(routineId, triggerInput(trigger), { agentId: null, userId: null });
|
||||
}
|
||||
}
|
||||
|
||||
async function createManagedRoutine(
|
||||
companyId: string,
|
||||
declaration: PluginManagedRoutineDeclaration,
|
||||
overrides?: RoutineOverrides,
|
||||
) {
|
||||
const refs = await resolveRefs(companyId, declaration, overrides);
|
||||
if (refs.missingRefs.length > 0) {
|
||||
return resolution(companyId, declaration, null, "missing_refs", refs.missingRefs);
|
||||
}
|
||||
|
||||
const created = await routinesSvc.create(companyId, {
|
||||
projectId: refs.projectId,
|
||||
goalId: declaration.goalId ?? null,
|
||||
title: declaration.title,
|
||||
description: declaration.description ?? null,
|
||||
assigneeAgentId: refs.assigneeAgentId,
|
||||
priority: declaration.priority ?? "medium",
|
||||
status: declaration.status ?? (refs.assigneeAgentId ? "active" : "paused"),
|
||||
concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active",
|
||||
catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed",
|
||||
variables: declaration.variables ?? [],
|
||||
}, { agentId: null, userId: null });
|
||||
await upsertBinding(companyId, declaration, created.id);
|
||||
await ensureDefaultTriggers(created.id, declaration);
|
||||
const routine = await getRoutineWithManagedBy(companyId, declaration);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "plugin",
|
||||
actorId: options.pluginId,
|
||||
action: "plugin.managed_routine.created",
|
||||
entityType: "routine",
|
||||
entityId: created.id,
|
||||
details: {
|
||||
sourcePluginKey: options.pluginKey,
|
||||
managedResourceKey: declaration.routineKey,
|
||||
assigneeAgentId: refs.assigneeAgentId,
|
||||
projectId: refs.projectId,
|
||||
},
|
||||
});
|
||||
return resolution(companyId, declaration, routine, "created");
|
||||
}
|
||||
|
||||
async function get(routineKey: string, companyId: string) {
|
||||
const declaration = declarationFor(routineKey);
|
||||
const routine = await getRoutineWithManagedBy(companyId, declaration);
|
||||
return resolution(companyId, declaration, routine, routine ? "resolved" : "missing");
|
||||
}
|
||||
|
||||
async function reconcile(routineKey: string, companyId: string, overrides?: RoutineOverrides) {
|
||||
const declaration = declarationFor(routineKey);
|
||||
const current = await get(routineKey, companyId);
|
||||
if (current.routine) {
|
||||
await upsertBinding(companyId, declaration, current.routine.id);
|
||||
await ensureDefaultTriggers(current.routine.id, declaration);
|
||||
return current;
|
||||
}
|
||||
return createManagedRoutine(companyId, declaration, overrides);
|
||||
}
|
||||
|
||||
async function reset(routineKey: string, companyId: string, overrides?: RoutineOverrides) {
|
||||
const declaration = declarationFor(routineKey);
|
||||
const current = await get(routineKey, companyId);
|
||||
if (!current.routine) {
|
||||
return createManagedRoutine(companyId, declaration, overrides);
|
||||
}
|
||||
|
||||
const refs = await resolveRefs(companyId, declaration, overrides);
|
||||
if (refs.missingRefs.length > 0) {
|
||||
return resolution(companyId, declaration, current.routine, "missing_refs", refs.missingRefs);
|
||||
}
|
||||
const updated = await routinesSvc.update(current.routine.id, {
|
||||
projectId: refs.projectId,
|
||||
goalId: declaration.goalId ?? null,
|
||||
title: declaration.title,
|
||||
description: declaration.description ?? null,
|
||||
assigneeAgentId: refs.assigneeAgentId,
|
||||
priority: declaration.priority ?? "medium",
|
||||
status: declaration.status ?? (refs.assigneeAgentId ? "active" : "paused"),
|
||||
concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active",
|
||||
catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed",
|
||||
variables: declaration.variables ?? [],
|
||||
}, { agentId: null, userId: null });
|
||||
if (!updated) throw notFound("Managed routine not found");
|
||||
await upsertBinding(companyId, declaration, updated.id);
|
||||
await ensureDefaultTriggers(updated.id, declaration);
|
||||
const routine = await getRoutineWithManagedBy(companyId, declaration);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "plugin",
|
||||
actorId: options.pluginId,
|
||||
action: "plugin.managed_routine.reset",
|
||||
entityType: "routine",
|
||||
entityId: updated.id,
|
||||
details: {
|
||||
sourcePluginKey: options.pluginKey,
|
||||
managedResourceKey: declaration.routineKey,
|
||||
assigneeAgentId: refs.assigneeAgentId,
|
||||
projectId: refs.projectId,
|
||||
},
|
||||
});
|
||||
return resolution(companyId, declaration, routine, "reset");
|
||||
}
|
||||
|
||||
async function update(
|
||||
routineKey: string,
|
||||
companyId: string,
|
||||
patch: { status?: string },
|
||||
) {
|
||||
const declaration = declarationFor(routineKey);
|
||||
const current = await get(routineKey, companyId);
|
||||
if (!current.routine) throw notFound("Managed routine not found");
|
||||
const updatePatch: { status?: RoutineStatus } = {};
|
||||
if (patch.status !== undefined) {
|
||||
if (!ROUTINE_STATUSES.includes(patch.status as RoutineStatus)) {
|
||||
throw unprocessable("Invalid routine status");
|
||||
}
|
||||
updatePatch.status = patch.status as RoutineStatus;
|
||||
}
|
||||
const updated = await routinesSvc.update(current.routine.id, updatePatch, { agentId: null, userId: null });
|
||||
if (!updated) throw notFound("Managed routine not found");
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "plugin",
|
||||
actorId: options.pluginId,
|
||||
action: "plugin.managed_routine.updated",
|
||||
entityType: "routine",
|
||||
entityId: updated.id,
|
||||
details: {
|
||||
sourcePluginKey: options.pluginKey,
|
||||
managedResourceKey: declaration.routineKey,
|
||||
status: updated.status,
|
||||
},
|
||||
});
|
||||
const routine = await getRoutineWithManagedBy(companyId, declaration);
|
||||
return routine ?? updated;
|
||||
}
|
||||
|
||||
async function run(routineKey: string, companyId: string, overrides?: RoutineOverrides) {
|
||||
const declaration = declarationFor(routineKey);
|
||||
const current = await get(routineKey, companyId);
|
||||
if (!current.routine) throw notFound("Managed routine not found");
|
||||
const run = await routinesSvc.runRoutine(current.routine.id, {
|
||||
source: "manual",
|
||||
assigneeAgentId: overrides?.assigneeAgentId,
|
||||
projectId: overrides?.projectId,
|
||||
}, { agentId: null, userId: null });
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "plugin",
|
||||
actorId: options.pluginId,
|
||||
action: "plugin.managed_routine.run_triggered",
|
||||
entityType: "routine_run",
|
||||
entityId: run.id,
|
||||
details: {
|
||||
sourcePluginKey: options.pluginKey,
|
||||
managedResourceKey: declaration.routineKey,
|
||||
routineId: current.routine.id,
|
||||
status: run.status,
|
||||
},
|
||||
});
|
||||
return run;
|
||||
}
|
||||
|
||||
return {
|
||||
get,
|
||||
reconcile,
|
||||
reset,
|
||||
update,
|
||||
run,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
pluginManagedResources,
|
||||
} from "@paperclipai/db";
|
||||
import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||
import type {
|
||||
CompanySkill,
|
||||
PaperclipPluginManifestV1,
|
||||
PluginManagedSkillDeclaration,
|
||||
PluginManagedSkillResolution,
|
||||
} from "@paperclipai/shared";
|
||||
import { notFound } from "../errors.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { companySkillService } from "./company-skills.js";
|
||||
|
||||
const MANAGED_SKILL_RESOURCE_KIND = "skill";
|
||||
|
||||
interface PluginManagedSkillServiceOptions {
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
manifest?: PaperclipPluginManifestV1 | null;
|
||||
}
|
||||
|
||||
function pluginKeySlug(pluginKey: string) {
|
||||
return normalizeAgentUrlKey(pluginKey) ?? "plugin";
|
||||
}
|
||||
|
||||
function canonicalSkillKey(pluginKey: string, skillKey: string) {
|
||||
return `plugin/${pluginKeySlug(pluginKey)}/${skillKey}`;
|
||||
}
|
||||
|
||||
function yamlString(value: string) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function buildDefaultMarkdown(
|
||||
pluginKey: string,
|
||||
declaration: PluginManagedSkillDeclaration,
|
||||
) {
|
||||
const description = declaration.description?.trim() || `${declaration.displayName} plugin skill.`;
|
||||
return [
|
||||
"---",
|
||||
`name: ${yamlString(declaration.displayName)}`,
|
||||
`description: ${yamlString(description)}`,
|
||||
`key: ${yamlString(canonicalSkillKey(pluginKey, declaration.skillKey))}`,
|
||||
"---",
|
||||
"",
|
||||
`# ${declaration.displayName}`,
|
||||
"",
|
||||
description,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function withManagedSkillKey(markdown: string, canonicalKey: string) {
|
||||
const keyLine = `key: ${yamlString(canonicalKey)}`;
|
||||
const normalized = markdown.replace(/\r\n/g, "\n");
|
||||
const frontmatter = /^---\n([\s\S]*?)\n---(\n?)/.exec(normalized);
|
||||
if (!frontmatter) {
|
||||
return [
|
||||
"---",
|
||||
keyLine,
|
||||
"---",
|
||||
"",
|
||||
normalized,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const currentBody = frontmatter[1] ?? "";
|
||||
const nextBody = /^key\s*:/m.test(currentBody)
|
||||
? currentBody.replace(/^key\s*:.*$/m, keyLine)
|
||||
: [currentBody, keyLine].filter(Boolean).join("\n");
|
||||
return `---\n${nextBody}\n---${frontmatter[2] ?? ""}${normalized.slice(frontmatter[0].length)}`;
|
||||
}
|
||||
|
||||
function buildPackageFiles(
|
||||
pluginKey: string,
|
||||
declaration: PluginManagedSkillDeclaration,
|
||||
) {
|
||||
const root = declaration.skillKey;
|
||||
const canonicalKey = canonicalSkillKey(pluginKey, declaration.skillKey);
|
||||
const files: Record<string, string> = {
|
||||
[`${root}/SKILL.md`]: declaration.markdown?.trim()
|
||||
? withManagedSkillKey(declaration.markdown, canonicalKey)
|
||||
: buildDefaultMarkdown(pluginKey, declaration),
|
||||
};
|
||||
for (const file of declaration.files ?? []) {
|
||||
files[`${root}/${file.path}`] = file.content;
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function buildDeclaredSkillFiles(
|
||||
pluginKey: string,
|
||||
declaration: PluginManagedSkillDeclaration,
|
||||
) {
|
||||
const packageFiles = buildPackageFiles(pluginKey, declaration);
|
||||
const root = declaration.skillKey;
|
||||
const prefix = `${root}/`;
|
||||
const files: Record<string, string> = {};
|
||||
for (const [filePath, content] of Object.entries(packageFiles)) {
|
||||
files[filePath.startsWith(prefix) ? filePath.slice(prefix.length) : filePath] = content;
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function buildSkillDefaults(
|
||||
pluginKey: string,
|
||||
declaration: PluginManagedSkillDeclaration,
|
||||
) {
|
||||
return {
|
||||
skillKey: declaration.skillKey,
|
||||
displayName: declaration.displayName,
|
||||
slug: declaration.slug ?? declaration.skillKey,
|
||||
description: declaration.description ?? null,
|
||||
canonicalKey: canonicalSkillKey(pluginKey, declaration.skillKey),
|
||||
files: [
|
||||
"SKILL.md",
|
||||
...(declaration.files ?? []).map((file) => file.path),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function stableJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function resolution(
|
||||
pluginKey: string,
|
||||
companyId: string,
|
||||
declaration: PluginManagedSkillDeclaration,
|
||||
skill: CompanySkill | null,
|
||||
status: PluginManagedSkillResolution["status"],
|
||||
defaultDrift: PluginManagedSkillResolution["defaultDrift"] = null,
|
||||
): PluginManagedSkillResolution {
|
||||
return {
|
||||
pluginKey,
|
||||
resourceKind: "skill",
|
||||
resourceKey: declaration.skillKey,
|
||||
companyId,
|
||||
skillId: skill?.id ?? null,
|
||||
skill,
|
||||
status,
|
||||
defaultDrift,
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginManagedSkillService(
|
||||
db: Db,
|
||||
options: PluginManagedSkillServiceOptions,
|
||||
) {
|
||||
const skills = companySkillService(db);
|
||||
|
||||
function declarationFor(skillKey: string) {
|
||||
const declaration = options.manifest?.skills?.find((skill) => skill.skillKey === skillKey);
|
||||
if (!declaration) {
|
||||
throw notFound(`Managed skill declaration not found: ${skillKey}`);
|
||||
}
|
||||
return declaration;
|
||||
}
|
||||
|
||||
async function getBinding(companyId: string, skillKey: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(pluginManagedResources)
|
||||
.where(and(
|
||||
eq(pluginManagedResources.companyId, companyId),
|
||||
eq(pluginManagedResources.pluginId, options.pluginId),
|
||||
eq(pluginManagedResources.resourceKind, MANAGED_SKILL_RESOURCE_KIND),
|
||||
eq(pluginManagedResources.resourceKey, skillKey),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function upsertBinding(
|
||||
companyId: string,
|
||||
declaration: PluginManagedSkillDeclaration,
|
||||
skillId: string,
|
||||
) {
|
||||
const defaultsJson = buildSkillDefaults(options.pluginKey, declaration);
|
||||
const existing = await getBinding(companyId, declaration.skillKey);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.resourceId === skillId &&
|
||||
stableJson(existing.defaultsJson) === stableJson(defaultsJson)
|
||||
) {
|
||||
return existing;
|
||||
}
|
||||
return db
|
||||
.update(pluginManagedResources)
|
||||
.set({
|
||||
resourceId: skillId,
|
||||
defaultsJson,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginManagedResources.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
return db
|
||||
.insert(pluginManagedResources)
|
||||
.values({
|
||||
companyId,
|
||||
pluginId: options.pluginId,
|
||||
pluginKey: options.pluginKey,
|
||||
resourceKind: MANAGED_SKILL_RESOURCE_KIND,
|
||||
resourceKey: declaration.skillKey,
|
||||
resourceId: skillId,
|
||||
defaultsJson,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
async function getSkill(companyId: string, skillId: string) {
|
||||
return skills.getById(companyId, skillId);
|
||||
}
|
||||
|
||||
async function managedSkillDefaultDrift(
|
||||
companyId: string,
|
||||
skill: CompanySkill | null,
|
||||
declaration: PluginManagedSkillDeclaration,
|
||||
): Promise<PluginManagedSkillResolution["defaultDrift"]> {
|
||||
if (!skill) return null;
|
||||
const declaredFiles = buildDeclaredSkillFiles(options.pluginKey, declaration);
|
||||
const currentFiles: Record<string, string | null> = {};
|
||||
const paths = new Set([
|
||||
...Object.keys(declaredFiles),
|
||||
...skill.fileInventory.map((entry) => entry.path),
|
||||
]);
|
||||
|
||||
for (const filePath of paths) {
|
||||
if (filePath === "SKILL.md") {
|
||||
currentFiles[filePath] = skill.markdown;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
currentFiles[filePath] = (await skills.readFile(companyId, skill.id, filePath))?.content ?? null;
|
||||
} catch {
|
||||
currentFiles[filePath] = null;
|
||||
}
|
||||
}
|
||||
|
||||
const changedFiles = [...paths]
|
||||
.filter((filePath) => (currentFiles[filePath] ?? null) !== (declaredFiles[filePath] ?? null))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
return changedFiles.length > 0 ? { changedFiles } : null;
|
||||
}
|
||||
|
||||
async function resolvedSkill(
|
||||
companyId: string,
|
||||
declaration: PluginManagedSkillDeclaration,
|
||||
skill: CompanySkill | null,
|
||||
status: PluginManagedSkillResolution["status"],
|
||||
) {
|
||||
return resolution(
|
||||
options.pluginKey,
|
||||
companyId,
|
||||
declaration,
|
||||
skill,
|
||||
status,
|
||||
await managedSkillDefaultDrift(companyId, skill, declaration),
|
||||
);
|
||||
}
|
||||
|
||||
async function importDeclaredSkill(
|
||||
companyId: string,
|
||||
declaration: PluginManagedSkillDeclaration,
|
||||
mode: "reconcile" | "reset",
|
||||
) {
|
||||
const beforeByKey = mode === "reconcile"
|
||||
? await skills.getByKey(companyId, canonicalSkillKey(options.pluginKey, declaration.skillKey))
|
||||
: null;
|
||||
if (beforeByKey) {
|
||||
await upsertBinding(companyId, declaration, beforeByKey.id);
|
||||
return { skill: beforeByKey, status: "relinked" as const };
|
||||
}
|
||||
const results = await skills.importPackageFiles(
|
||||
companyId,
|
||||
buildPackageFiles(options.pluginKey, declaration),
|
||||
{ onConflict: "replace" },
|
||||
);
|
||||
const imported = results.find((result) =>
|
||||
result.skill.key === canonicalSkillKey(options.pluginKey, declaration.skillKey)
|
||||
|| result.originalSlug === (declaration.slug ?? declaration.skillKey)
|
||||
|| result.originalSlug === declaration.skillKey
|
||||
)?.skill ?? results[0]?.skill ?? null;
|
||||
if (!imported) {
|
||||
throw notFound(`Managed skill was not imported: ${declaration.skillKey}`);
|
||||
}
|
||||
await upsertBinding(companyId, declaration, imported.id);
|
||||
const status: PluginManagedSkillResolution["status"] = mode === "reset" ? "reset" : "created";
|
||||
return { skill: imported, status };
|
||||
}
|
||||
|
||||
async function get(skillKey: string, companyId: string) {
|
||||
const declaration = declarationFor(skillKey);
|
||||
const binding = await getBinding(companyId, skillKey);
|
||||
if (!binding) return resolvedSkill(companyId, declaration, null, "missing");
|
||||
const skill = await getSkill(companyId, binding.resourceId);
|
||||
return resolvedSkill(companyId, declaration, skill, skill ? "resolved" : "missing");
|
||||
}
|
||||
|
||||
async function reconcile(skillKey: string, companyId: string) {
|
||||
const declaration = declarationFor(skillKey);
|
||||
const current = await get(skillKey, companyId);
|
||||
if (current.skill) {
|
||||
await upsertBinding(companyId, declaration, current.skill.id);
|
||||
return current;
|
||||
}
|
||||
const imported = await importDeclaredSkill(companyId, declaration, "reconcile");
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "plugin",
|
||||
actorId: options.pluginId,
|
||||
action: "plugin.managed_skill.reconciled",
|
||||
entityType: "company_skill",
|
||||
entityId: imported.skill.id,
|
||||
details: {
|
||||
sourcePluginKey: options.pluginKey,
|
||||
managedResourceKey: declaration.skillKey,
|
||||
status: imported.status,
|
||||
},
|
||||
});
|
||||
return resolvedSkill(companyId, declaration, imported.skill, imported.status);
|
||||
}
|
||||
|
||||
async function reset(skillKey: string, companyId: string) {
|
||||
const declaration = declarationFor(skillKey);
|
||||
const imported = await importDeclaredSkill(companyId, declaration, "reset");
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "plugin",
|
||||
actorId: options.pluginId,
|
||||
action: "plugin.managed_skill.reset",
|
||||
entityType: "company_skill",
|
||||
entityId: imported.skill.id,
|
||||
details: {
|
||||
sourcePluginKey: options.pluginKey,
|
||||
managedResourceKey: declaration.skillKey,
|
||||
},
|
||||
});
|
||||
return resolvedSkill(companyId, declaration, imported.skill, "reset");
|
||||
}
|
||||
|
||||
return {
|
||||
get,
|
||||
reconcile,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
plugins,
|
||||
pluginConfig,
|
||||
pluginCompanySettings,
|
||||
pluginEntities,
|
||||
pluginJobs,
|
||||
pluginJobRuns,
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
UpdatePluginStatus,
|
||||
UpsertPluginConfig,
|
||||
PatchPluginConfig,
|
||||
PluginCompanySettings,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginJobRecord,
|
||||
@@ -387,6 +389,64 @@ export function pluginRegistryService(db: Db) {
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
// ----- Company settings ----------------------------------------------
|
||||
|
||||
/** Retrieve company-scoped plugin settings. */
|
||||
getCompanySettings: (pluginId: string, companyId: string): Promise<PluginCompanySettings | null> =>
|
||||
db
|
||||
.select()
|
||||
.from(pluginCompanySettings)
|
||||
.where(and(
|
||||
eq(pluginCompanySettings.pluginId, pluginId),
|
||||
eq(pluginCompanySettings.companyId, companyId),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null) as Promise<PluginCompanySettings | null>,
|
||||
|
||||
/** Create or replace company-scoped plugin settings. */
|
||||
upsertCompanySettings: async (
|
||||
pluginId: string,
|
||||
companyId: string,
|
||||
input: { enabled?: boolean; settingsJson: Record<string, unknown>; lastError?: string | null },
|
||||
): Promise<PluginCompanySettings> => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(pluginCompanySettings)
|
||||
.where(and(
|
||||
eq(pluginCompanySettings.pluginId, pluginId),
|
||||
eq(pluginCompanySettings.companyId, companyId),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existing) {
|
||||
return db
|
||||
.update(pluginCompanySettings)
|
||||
.set({
|
||||
enabled: input.enabled ?? existing.enabled,
|
||||
settingsJson: input.settingsJson,
|
||||
lastError: input.lastError ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginCompanySettings.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]) as Promise<PluginCompanySettings>;
|
||||
}
|
||||
|
||||
return db
|
||||
.insert(pluginCompanySettings)
|
||||
.values({
|
||||
pluginId,
|
||||
companyId,
|
||||
enabled: input.enabled ?? true,
|
||||
settingsJson: input.settingsJson,
|
||||
lastError: input.lastError ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]) as Promise<PluginCompanySettings>;
|
||||
},
|
||||
|
||||
// ----- Entities -------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,38 +33,20 @@
|
||||
* @see services/secrets.ts — secretService used by agent env bindings
|
||||
*/
|
||||
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companySecrets, companySecretVersions, pluginConfig } from "@paperclipai/db";
|
||||
import type { SecretProvider } from "@paperclipai/shared";
|
||||
import { getSecretProvider } from "../secrets/provider-registry.js";
|
||||
import { pluginRegistryService } from "./plugin-registry.js";
|
||||
import {
|
||||
collectSecretRefPaths,
|
||||
isUuidSecretRef,
|
||||
readConfigValueAtPath,
|
||||
} from "./json-schema-secret-refs.js";
|
||||
|
||||
export const PLUGIN_SECRET_REFS_DISABLED_MESSAGE =
|
||||
"Plugin secret references are disabled until company-scoped plugin config lands";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a sanitised error that never leaks secret material.
|
||||
* Only the ref identifier is included; never the resolved value.
|
||||
*/
|
||||
function secretNotFound(secretRef: string): Error {
|
||||
const err = new Error(`Secret not found: ${secretRef}`);
|
||||
err.name = "SecretNotFoundError";
|
||||
return err;
|
||||
}
|
||||
|
||||
function secretVersionNotFound(secretRef: string): Error {
|
||||
const err = new Error(`No version found for secret: ${secretRef}`);
|
||||
err.name = "SecretVersionNotFoundError";
|
||||
return err;
|
||||
}
|
||||
|
||||
function invalidSecretRef(secretRef: string): Error {
|
||||
const err = new Error(`Invalid secret reference: ${secretRef}`);
|
||||
err.name = "InvalidSecretRefError";
|
||||
@@ -86,8 +68,20 @@ export function extractSecretRefsFromConfig(
|
||||
configJson: unknown,
|
||||
schema?: Record<string, unknown> | null,
|
||||
): Set<string> {
|
||||
const refs = new Set<string>();
|
||||
if (configJson == null || typeof configJson !== "object") return refs;
|
||||
return new Set(extractSecretRefPathsFromConfig(configJson, schema).keys());
|
||||
}
|
||||
|
||||
export function extractSecretRefPathsFromConfig(
|
||||
configJson: unknown,
|
||||
schema?: Record<string, unknown> | null,
|
||||
): Map<string, Set<string>> {
|
||||
const refs = new Map<string, Set<string>>();
|
||||
const addRef = (secretRef: string, path: string) => {
|
||||
const existing = refs.get(secretRef) ?? new Set<string>();
|
||||
existing.add(path);
|
||||
refs.set(secretRef, existing);
|
||||
};
|
||||
if (configJson == null || typeof configJson !== "object") return new Map();
|
||||
|
||||
const secretPaths = collectSecretRefPaths(schema);
|
||||
|
||||
@@ -96,7 +90,7 @@ export function extractSecretRefsFromConfig(
|
||||
for (const dotPath of secretPaths) {
|
||||
const current = readConfigValueAtPath(configJson as Record<string, unknown>, dotPath);
|
||||
if (typeof current === "string" && isUuidSecretRef(current)) {
|
||||
refs.add(current);
|
||||
addRef(current, dotPath);
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
@@ -107,7 +101,7 @@ export function extractSecretRefsFromConfig(
|
||||
// instanceConfigSchema.
|
||||
function walkAll(value: unknown): void {
|
||||
if (typeof value === "string") {
|
||||
if (isUuidSecretRef(value)) refs.add(value);
|
||||
if (isUuidSecretRef(value)) addRef(value, "$");
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const item of value) walkAll(item);
|
||||
} else if (value !== null && typeof value === "object") {
|
||||
@@ -205,16 +199,11 @@ function createRateLimiter(maxAttempts: number, windowMs: number) {
|
||||
export function createPluginSecretsHandler(
|
||||
options: PluginSecretsHandlerOptions,
|
||||
): PluginSecretsService {
|
||||
const { db, pluginId } = options;
|
||||
const registry = pluginRegistryService(db);
|
||||
const { pluginId } = options;
|
||||
|
||||
// Rate limit: max 30 resolution attempts per plugin per minute
|
||||
const rateLimiter = createRateLimiter(30, 60_000);
|
||||
|
||||
let cachedAllowedRefs: Set<string> | null = null;
|
||||
let cachedAllowedRefsExpiry = 0;
|
||||
const CONFIG_CACHE_TTL_MS = 30_000; // 30 seconds, matches event bus TTL
|
||||
|
||||
return {
|
||||
async resolve(params: PluginSecretsResolveParams): Promise<string> {
|
||||
const { secretRef } = params;
|
||||
@@ -241,72 +230,9 @@ export function createPluginSecretsHandler(
|
||||
throw invalidSecretRef(trimmedRef);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 1b. Scope check — only allow secrets referenced in this plugin's config
|
||||
// ---------------------------------------------------------------
|
||||
const now = Date.now();
|
||||
if (!cachedAllowedRefs || now > cachedAllowedRefsExpiry) {
|
||||
const [configRow, plugin] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(pluginConfig)
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
registry.getById(pluginId),
|
||||
]);
|
||||
|
||||
const schema = (plugin?.manifestJson as unknown as Record<string, unknown> | null)
|
||||
?.instanceConfigSchema as Record<string, unknown> | undefined;
|
||||
cachedAllowedRefs = extractSecretRefsFromConfig(configRow?.configJson, schema);
|
||||
cachedAllowedRefsExpiry = now + CONFIG_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
if (!cachedAllowedRefs.has(trimmedRef)) {
|
||||
// Return "not found" to avoid leaking whether the secret exists
|
||||
throw secretNotFound(trimmedRef);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 2. Look up the secret record by UUID
|
||||
// ---------------------------------------------------------------
|
||||
const secret = await db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(eq(companySecrets.id, trimmedRef))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!secret) {
|
||||
throw secretNotFound(trimmedRef);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 3. Fetch the latest version's material
|
||||
// ---------------------------------------------------------------
|
||||
const versionRow = await db
|
||||
.select()
|
||||
.from(companySecretVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecretVersions.secretId, secret.id),
|
||||
eq(companySecretVersions.version, secret.latestVersion),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!versionRow) {
|
||||
throw secretVersionNotFound(trimmedRef);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 4. Resolve through the appropriate secret provider
|
||||
// ---------------------------------------------------------------
|
||||
const provider = getSecretProvider(secret.provider as SecretProvider);
|
||||
const resolved = await provider.resolveVersion({
|
||||
material: versionRow.material as Record<string, unknown>,
|
||||
externalRef: secret.externalRef,
|
||||
});
|
||||
|
||||
return resolved;
|
||||
// Fail closed until plugin config and worker runtime both carry an
|
||||
// explicit company scope for secret bindings and resolution.
|
||||
throw new Error(PLUGIN_SECRET_REFS_DISABLED_MESSAGE);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1006,7 +1006,7 @@ export function createPluginWorkerHandle(
|
||||
params: HostToWorkerMethods[M][0],
|
||||
timeoutMs?: number,
|
||||
): Promise<HostToWorkerMethods[M][1]> {
|
||||
return new Promise<HostToWorkerMethods[M][1]>((resolve, reject) => {
|
||||
const rpcPromise = new Promise<HostToWorkerMethods[M][1]>((resolve, reject) => {
|
||||
if (!childProcess?.stdin?.writable) {
|
||||
reject(
|
||||
new Error(
|
||||
@@ -1076,6 +1076,14 @@ export function createPluginWorkerHandle(
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Some call sites hand these promises across async boundaries before
|
||||
// attaching their own handlers. Mark the promise as handled here so a
|
||||
// worker-side JSON-RPC error can fail the caller without killing the host
|
||||
// process via an unhandled rejection.
|
||||
void rpcPromise.catch(() => undefined);
|
||||
|
||||
return rpcPromise;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -14,6 +14,10 @@ import { logger } from "../middleware/logger.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { budgetService } from "./budgets.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import {
|
||||
recoveryAssigneeAdapterOverrides,
|
||||
withRecoveryModelProfileHint,
|
||||
} from "./recovery/model-profile-hint.js";
|
||||
import { RECOVERY_ORIGIN_KINDS } from "./recovery/origins.js";
|
||||
|
||||
export const PRODUCTIVITY_REVIEW_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.issueProductivityReview;
|
||||
@@ -687,6 +691,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
|
||||
goalId: evidence.sourceIssue.goalId,
|
||||
billingCode: evidence.sourceIssue.billingCode,
|
||||
assigneeAgentId: ownerAgentId,
|
||||
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
|
||||
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
originId: evidence.sourceIssue.id,
|
||||
originFingerprint: productivityReviewFingerprint(evidence.sourceIssue.id),
|
||||
@@ -732,21 +737,21 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId: review.id,
|
||||
sourceIssueId: evidence.sourceIssue.id,
|
||||
trigger: evidence.trigger,
|
||||
},
|
||||
}),
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: "productivity_review",
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: review.id,
|
||||
taskId: review.id,
|
||||
wakeReason: "issue_assigned",
|
||||
source: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
sourceIssueId: evidence.sourceIssue.id,
|
||||
productivityReviewTrigger: evidence.trigger,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+268
-46
@@ -1,6 +1,14 @@
|
||||
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { projects, projectGoals, goals, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
|
||||
import {
|
||||
projects,
|
||||
projectGoals,
|
||||
goals,
|
||||
pluginManagedResources,
|
||||
plugins,
|
||||
projectWorkspaces,
|
||||
workspaceRuntimeServices,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
PROJECT_COLORS,
|
||||
deriveProjectUrlKey,
|
||||
@@ -10,9 +18,12 @@ import {
|
||||
type ProjectCodebase,
|
||||
type ProjectExecutionWorkspacePolicy,
|
||||
type ProjectGoalRef,
|
||||
type ProjectManagedByPlugin,
|
||||
type ProjectWorkspaceRuntimeConfig,
|
||||
type ProjectWorkspace,
|
||||
type WorkspaceRuntimeService,
|
||||
type PluginManagedProjectDeclaration,
|
||||
type PluginManagedProjectResolution,
|
||||
} from "@paperclipai/shared";
|
||||
import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runtime-read-model.js";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
@@ -50,6 +61,7 @@ interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy">
|
||||
codebase: ProjectCodebase;
|
||||
workspaces: ProjectWorkspace[];
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
managedByPlugin: ProjectManagedByPlugin | null;
|
||||
}
|
||||
|
||||
interface ProjectShortnameRow {
|
||||
@@ -245,6 +257,40 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
|
||||
arr.push(row);
|
||||
}
|
||||
|
||||
const managedRows = await db
|
||||
.select({
|
||||
id: pluginManagedResources.id,
|
||||
pluginId: pluginManagedResources.pluginId,
|
||||
pluginKey: pluginManagedResources.pluginKey,
|
||||
manifestJson: plugins.manifestJson,
|
||||
resourceKind: pluginManagedResources.resourceKind,
|
||||
resourceKey: pluginManagedResources.resourceKey,
|
||||
resourceId: pluginManagedResources.resourceId,
|
||||
defaultsJson: pluginManagedResources.defaultsJson,
|
||||
createdAt: pluginManagedResources.createdAt,
|
||||
updatedAt: pluginManagedResources.updatedAt,
|
||||
})
|
||||
.from(pluginManagedResources)
|
||||
.innerJoin(plugins, eq(pluginManagedResources.pluginId, plugins.id))
|
||||
.where(and(
|
||||
eq(pluginManagedResources.resourceKind, "project"),
|
||||
inArray(pluginManagedResources.resourceId, projectIds),
|
||||
));
|
||||
const managedByProjectId = new Map<string, ProjectManagedByPlugin>();
|
||||
for (const row of managedRows) {
|
||||
managedByProjectId.set(row.resourceId, {
|
||||
id: row.id,
|
||||
pluginId: row.pluginId,
|
||||
pluginKey: row.pluginKey,
|
||||
pluginDisplayName: row.manifestJson.displayName ?? row.pluginKey,
|
||||
resourceKind: "project",
|
||||
resourceKey: row.resourceKey,
|
||||
defaultsJson: row.defaultsJson,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
const projectWorkspaceRows = map.get(row.id) ?? [];
|
||||
const workspaces = projectWorkspaceRows.map((workspace) =>
|
||||
@@ -264,6 +310,7 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
|
||||
}),
|
||||
workspaces,
|
||||
primaryWorkspace,
|
||||
managedByPlugin: managedByProjectId.get(row.id) ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -337,6 +384,17 @@ function deriveWorkspaceName(input: {
|
||||
return "Workspace";
|
||||
}
|
||||
|
||||
function buildManagedProjectDefaults(declaration: PluginManagedProjectDeclaration) {
|
||||
return {
|
||||
projectKey: declaration.projectKey,
|
||||
displayName: declaration.displayName,
|
||||
description: declaration.description ?? null,
|
||||
status: declaration.status ?? "in_progress",
|
||||
color: declaration.color ?? null,
|
||||
settings: declaration.settings ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectNameForUniqueShortname(
|
||||
requestedName: string,
|
||||
existingProjects: ProjectShortnameRow[],
|
||||
@@ -398,6 +456,58 @@ async function ensureSinglePrimaryWorkspace(
|
||||
}
|
||||
|
||||
export function projectService(db: Db) {
|
||||
const createProject = async (
|
||||
companyId: string,
|
||||
data: Omit<typeof projects.$inferInsert, "companyId"> & { goalIds?: string[] },
|
||||
): Promise<ProjectWithGoals> => {
|
||||
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
||||
|
||||
// Auto-assign a color from the palette if none provided
|
||||
if (!projectData.color) {
|
||||
const existing = await db.select({ color: projects.color }).from(projects).where(eq(projects.companyId, companyId));
|
||||
const usedColors = new Set(existing.map((r) => r.color).filter(Boolean));
|
||||
const nextColor = PROJECT_COLORS.find((c) => !usedColors.has(c)) ?? PROJECT_COLORS[existing.length % PROJECT_COLORS.length];
|
||||
projectData.color = nextColor;
|
||||
}
|
||||
|
||||
const existingProjects = await db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.companyId, companyId));
|
||||
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects);
|
||||
|
||||
// Also write goalId to the legacy column (first goal or null)
|
||||
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
|
||||
|
||||
const row = await db
|
||||
.insert(projects)
|
||||
.values({ ...projectData, goalId: legacyGoalId, companyId })
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (ids && ids.length > 0) {
|
||||
await syncGoalLinks(db, row.id, companyId, ids);
|
||||
}
|
||||
|
||||
const [withGoals] = await attachGoals(db, [row]);
|
||||
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
|
||||
return enriched!;
|
||||
};
|
||||
|
||||
const getProjectById = async (id: string): Promise<ProjectWithGoals | null> => {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
const [withGoals] = await attachGoals(db, [row]);
|
||||
if (!withGoals) return null;
|
||||
const [enriched] = await attachWorkspaces(db, [withGoals]);
|
||||
return enriched ?? null;
|
||||
};
|
||||
|
||||
return {
|
||||
list: async (companyId: string): Promise<ProjectWithGoals[]> => {
|
||||
const rows = await db.select().from(projects).where(eq(projects.companyId, companyId));
|
||||
@@ -418,58 +528,170 @@ export function projectService(db: Db) {
|
||||
return dedupedIds.map((id) => byId.get(id)).filter((project): project is ProjectWithGoals => Boolean(project));
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<ProjectWithGoals | null> => {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, id))
|
||||
getById: getProjectById,
|
||||
|
||||
resolveManagedProject: async (input: {
|
||||
companyId: string;
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
projectKey: string;
|
||||
reset?: boolean;
|
||||
createIfMissing?: boolean;
|
||||
}): Promise<PluginManagedProjectResolution> => {
|
||||
const plugin = await db
|
||||
.select({ id: plugins.id, pluginKey: plugins.pluginKey, manifestJson: plugins.manifestJson })
|
||||
.from(plugins)
|
||||
.where(eq(plugins.id, input.pluginId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
const [withGoals] = await attachGoals(db, [row]);
|
||||
if (!withGoals) return null;
|
||||
const [enriched] = await attachWorkspaces(db, [withGoals]);
|
||||
return enriched ?? null;
|
||||
},
|
||||
|
||||
create: async (
|
||||
companyId: string,
|
||||
data: Omit<typeof projects.$inferInsert, "companyId"> & { goalIds?: string[] },
|
||||
): Promise<ProjectWithGoals> => {
|
||||
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
||||
|
||||
// Auto-assign a color from the palette if none provided
|
||||
if (!projectData.color) {
|
||||
const existing = await db.select({ color: projects.color }).from(projects).where(eq(projects.companyId, companyId));
|
||||
const usedColors = new Set(existing.map((r) => r.color).filter(Boolean));
|
||||
const nextColor = PROJECT_COLORS.find((c) => !usedColors.has(c)) ?? PROJECT_COLORS[existing.length % PROJECT_COLORS.length];
|
||||
projectData.color = nextColor;
|
||||
if (!plugin || plugin.pluginKey !== input.pluginKey) {
|
||||
return {
|
||||
pluginKey: input.pluginKey,
|
||||
resourceKind: "project",
|
||||
resourceKey: input.projectKey,
|
||||
companyId: input.companyId,
|
||||
projectId: null,
|
||||
project: null,
|
||||
status: "missing",
|
||||
};
|
||||
}
|
||||
|
||||
const existingProjects = await db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.companyId, companyId));
|
||||
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects);
|
||||
|
||||
// Also write goalId to the legacy column (first goal or null)
|
||||
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
|
||||
|
||||
const row = await db
|
||||
.insert(projects)
|
||||
.values({ ...projectData, goalId: legacyGoalId, companyId })
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (ids && ids.length > 0) {
|
||||
await syncGoalLinks(db, row.id, companyId, ids);
|
||||
const declaration = plugin.manifestJson.projects?.find((project) => project.projectKey === input.projectKey);
|
||||
if (!declaration) {
|
||||
return {
|
||||
pluginKey: input.pluginKey,
|
||||
resourceKind: "project",
|
||||
resourceKey: input.projectKey,
|
||||
companyId: input.companyId,
|
||||
projectId: null,
|
||||
project: null,
|
||||
status: "missing",
|
||||
};
|
||||
}
|
||||
|
||||
const [withGoals] = await attachGoals(db, [row]);
|
||||
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
|
||||
return enriched!;
|
||||
const defaults = buildManagedProjectDefaults(declaration);
|
||||
const existingBinding = await db
|
||||
.select()
|
||||
.from(pluginManagedResources)
|
||||
.where(and(
|
||||
eq(pluginManagedResources.companyId, input.companyId),
|
||||
eq(pluginManagedResources.pluginId, input.pluginId),
|
||||
eq(pluginManagedResources.resourceKind, "project"),
|
||||
eq(pluginManagedResources.resourceKey, input.projectKey),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existingBinding) {
|
||||
const existingProject = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, input.companyId), eq(projects.id, existingBinding.resourceId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existingProject) {
|
||||
if (input.reset) {
|
||||
await db
|
||||
.update(projects)
|
||||
.set({
|
||||
name: declaration.displayName,
|
||||
description: declaration.description ?? null,
|
||||
status: declaration.status ?? "in_progress",
|
||||
color: declaration.color ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(projects.companyId, input.companyId), eq(projects.id, existingBinding.resourceId)));
|
||||
}
|
||||
if (input.createIfMissing !== false) {
|
||||
await db
|
||||
.update(pluginManagedResources)
|
||||
.set({ defaultsJson: defaults, updatedAt: new Date() })
|
||||
.where(eq(pluginManagedResources.id, existingBinding.id));
|
||||
}
|
||||
const project = await getProjectById(existingBinding.resourceId);
|
||||
return {
|
||||
pluginKey: input.pluginKey,
|
||||
resourceKind: "project",
|
||||
resourceKey: input.projectKey,
|
||||
companyId: input.companyId,
|
||||
projectId: project?.id ?? existingBinding.resourceId,
|
||||
project: project as import("@paperclipai/shared").Project | null,
|
||||
status: input.reset ? "reset" : "resolved",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.createIfMissing === false) {
|
||||
return {
|
||||
pluginKey: input.pluginKey,
|
||||
resourceKind: "project",
|
||||
resourceKey: input.projectKey,
|
||||
companyId: input.companyId,
|
||||
projectId: null,
|
||||
project: null,
|
||||
status: "missing",
|
||||
};
|
||||
}
|
||||
|
||||
const project = await createProject(input.companyId, {
|
||||
name: declaration.displayName,
|
||||
description: declaration.description ?? null,
|
||||
status: declaration.status ?? "in_progress",
|
||||
color: declaration.color ?? undefined,
|
||||
});
|
||||
await db
|
||||
.update(pluginManagedResources)
|
||||
.set({ resourceId: project.id, defaultsJson: defaults, updatedAt: new Date() })
|
||||
.where(eq(pluginManagedResources.id, existingBinding.id));
|
||||
const hydrated = await getProjectById(project.id);
|
||||
return {
|
||||
pluginKey: input.pluginKey,
|
||||
resourceKind: "project",
|
||||
resourceKey: input.projectKey,
|
||||
companyId: input.companyId,
|
||||
projectId: hydrated?.id ?? project.id,
|
||||
project: hydrated as import("@paperclipai/shared").Project | null,
|
||||
status: "relinked",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.createIfMissing === false) {
|
||||
return {
|
||||
pluginKey: input.pluginKey,
|
||||
resourceKind: "project",
|
||||
resourceKey: input.projectKey,
|
||||
companyId: input.companyId,
|
||||
projectId: null,
|
||||
project: null,
|
||||
status: "missing",
|
||||
};
|
||||
}
|
||||
|
||||
const project = await createProject(input.companyId, {
|
||||
name: declaration.displayName,
|
||||
description: declaration.description ?? null,
|
||||
status: declaration.status ?? "in_progress",
|
||||
color: declaration.color ?? undefined,
|
||||
});
|
||||
await db.insert(pluginManagedResources).values({
|
||||
companyId: input.companyId,
|
||||
pluginId: input.pluginId,
|
||||
pluginKey: input.pluginKey,
|
||||
resourceKind: "project",
|
||||
resourceKey: input.projectKey,
|
||||
resourceId: project.id,
|
||||
defaultsJson: defaults,
|
||||
});
|
||||
const hydrated = await getProjectById(project.id);
|
||||
return {
|
||||
pluginKey: input.pluginKey,
|
||||
resourceKind: "project",
|
||||
resourceKey: input.projectKey,
|
||||
companyId: input.companyId,
|
||||
projectId: hydrated?.id ?? project.id,
|
||||
project: hydrated as import("@paperclipai/shared").Project | null,
|
||||
status: "created",
|
||||
};
|
||||
},
|
||||
|
||||
create: createProject,
|
||||
|
||||
update: async (
|
||||
id: string,
|
||||
data: Partial<typeof projects.$inferInsert> & { goalIds?: string[] },
|
||||
|
||||
@@ -42,3 +42,23 @@ export {
|
||||
export type {
|
||||
RunContinuationDecision,
|
||||
} from "./run-liveness-continuations.js";
|
||||
export {
|
||||
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
|
||||
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||
LEGACY_SUCCESSFUL_RUN_HANDOFF_NOTICE_PREFIXES,
|
||||
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
|
||||
SUCCESSFUL_RUN_HANDOFF_OPTIONS,
|
||||
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
|
||||
SUCCESSFUL_RUN_MISSING_STATE_REASON,
|
||||
buildFinishSuccessfulRunHandoffIdempotencyKey,
|
||||
buildSuccessfulRunHandoffExhaustedNotice,
|
||||
buildSuccessfulRunHandoffInstruction,
|
||||
buildSuccessfulRunHandoffRequiredNotice,
|
||||
decideSuccessfulRunHandoff,
|
||||
findExistingFinishSuccessfulRunHandoffWake,
|
||||
isSuccessfulRunHandoffRequiredNoticeBody,
|
||||
} from "./successful-run-handoff.js";
|
||||
export type {
|
||||
SuccessfulRunHandoffNotice,
|
||||
SuccessfulRunHandoffDecision,
|
||||
} from "./successful-run-handoff.js";
|
||||
|
||||
@@ -4,6 +4,7 @@ export type IssueLivenessSeverity = "warning" | "critical";
|
||||
|
||||
export type IssueLivenessState =
|
||||
| "blocked_by_unassigned_issue"
|
||||
| "blocked_by_assigned_backlog_issue"
|
||||
| "blocked_by_uninvokable_assignee"
|
||||
| "blocked_by_cancelled_issue"
|
||||
| "invalid_review_participant"
|
||||
@@ -22,7 +23,10 @@ export interface IssueLivenessIssueInput {
|
||||
assigneeUserId?: string | null;
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
executionPolicy?: Record<string, unknown> | null;
|
||||
executionState?: Record<string, unknown> | null;
|
||||
monitorNextCheckAt?: Date | string | null;
|
||||
monitorAttemptCount?: number | null;
|
||||
}
|
||||
|
||||
export interface IssueLivenessRelationInput {
|
||||
@@ -99,6 +103,7 @@ export interface IssueGraphLivenessInput {
|
||||
pendingInteractions?: IssueLivenessWaitingPathInput[];
|
||||
pendingApprovals?: IssueLivenessWaitingPathInput[];
|
||||
openRecoveryIssues?: IssueLivenessWaitingPathInput[];
|
||||
now?: Date | string;
|
||||
}
|
||||
|
||||
const INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
|
||||
@@ -140,6 +145,45 @@ function hasWaitingPath(
|
||||
return waitingPaths.some((entry) => entry.companyId === companyId && entry.issueId === issueId);
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function readPositiveInteger(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
function readDateMs(value: unknown): number | null {
|
||||
if (!(typeof value === "string" || value instanceof Date)) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
const time = date.getTime();
|
||||
return Number.isNaN(time) ? null : time;
|
||||
}
|
||||
|
||||
function monitorFromIssue(issue: IssueLivenessIssueInput) {
|
||||
const policyMonitor = readRecord(readRecord(issue.executionPolicy)?.monitor);
|
||||
const stateMonitor = readRecord(readRecord(issue.executionState)?.monitor);
|
||||
return { policyMonitor, stateMonitor };
|
||||
}
|
||||
|
||||
function hasScheduledMonitor(issue: IssueLivenessIssueInput, nowMs: number) {
|
||||
const nextCheckAtMs = readDateMs(issue.monitorNextCheckAt);
|
||||
if (nextCheckAtMs === null || nextCheckAtMs <= nowMs) return false;
|
||||
|
||||
const { policyMonitor, stateMonitor } = monitorFromIssue(issue);
|
||||
const timeoutAtMs = readDateMs(policyMonitor?.timeoutAt ?? stateMonitor?.timeoutAt);
|
||||
if (timeoutAtMs !== null && timeoutAtMs <= nowMs) return false;
|
||||
|
||||
const maxAttempts = readPositiveInteger(policyMonitor?.maxAttempts ?? stateMonitor?.maxAttempts);
|
||||
const stateAttemptCount = readPositiveInteger(stateMonitor?.attemptCount) ?? 0;
|
||||
const attemptCount = issue.monitorAttemptCount ?? stateAttemptCount;
|
||||
if (maxAttempts !== null && attemptCount >= maxAttempts) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function readPrincipalAgentId(principal: unknown): string | null {
|
||||
if (!principal || typeof principal !== "object") return null;
|
||||
const value = principal as Record<string, unknown>;
|
||||
@@ -308,6 +352,7 @@ function finding(input: {
|
||||
}
|
||||
|
||||
export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): IssueLivenessFinding[] {
|
||||
const nowMs = readDateMs(input.now ?? new Date()) ?? Date.now();
|
||||
const issuesById = new Map(input.issues.map((issue) => [issue.id, issue]));
|
||||
const agentsById = new Map(input.agents.map((agent) => [agent.id, agent]));
|
||||
const blockersByBlockedIssueId = new Map<string, IssueLivenessRelationInput[]>();
|
||||
@@ -351,6 +396,7 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
|
||||
function hasExplicitWaitingPath(issue: IssueLivenessIssueInput) {
|
||||
return Boolean(issue.assigneeUserId) ||
|
||||
hasScheduledMonitor(issue, nowMs) ||
|
||||
hasActiveExecutionPath(issue.companyId, issue.id, activeRuns, queuedWakeRequests) ||
|
||||
hasWaitingPath(issue.companyId, issue.id, pendingInteractions) ||
|
||||
hasWaitingPath(issue.companyId, issue.id, pendingApprovals) ||
|
||||
@@ -453,6 +499,21 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
return reviewFinding(source, blocker, dependencyPath);
|
||||
}
|
||||
|
||||
if (blocker.status === "backlog" && blocker.assigneeAgentId) {
|
||||
return finding({
|
||||
issue: source,
|
||||
state: "blocked_by_assigned_backlog_issue",
|
||||
reason: `${issueLabel(source)} is blocked by assigned backlog issue ${issueLabel(blocker)} with no wake, active run, human owner, interaction, approval, monitor, or recovery issue owning the next action.`,
|
||||
dependencyPath,
|
||||
recoveryIssue: blocker,
|
||||
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
|
||||
recommendedOwnerCandidates: ownerCandidates,
|
||||
recommendedAction:
|
||||
`Review ${issueLabel(blocker)} and either move it to todo so the assignee wakes, assign a human owner or interaction if it is intentionally parked, or remove it from ${issueLabel(source)}'s blockers if it is no longer required.`,
|
||||
blockerIssueId: blocker.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (!blocker.assigneeAgentId && !blocker.assigneeUserId) {
|
||||
return finding({
|
||||
issue: source,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export const RECOVERY_MODEL_PROFILE_KEY = "cheap" as const;
|
||||
|
||||
export function withRecoveryModelProfileHint<T extends Record<string, unknown>>(
|
||||
input: T,
|
||||
): T & { modelProfile: typeof RECOVERY_MODEL_PROFILE_KEY } {
|
||||
return {
|
||||
...input,
|
||||
modelProfile: RECOVERY_MODEL_PROFILE_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
export function recoveryAssigneeAdapterOverrides() {
|
||||
return { modelProfile: RECOVERY_MODEL_PROFILE_KEY };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import type { RunLivenessState } from "@paperclipai/shared";
|
||||
import { withRecoveryModelProfileHint } from "./model-profile-hint.js";
|
||||
import { RECOVERY_REASON_KINDS } from "./origins.js";
|
||||
|
||||
export const RUN_LIVENESS_CONTINUATION_REASON = RECOVERY_REASON_KINDS.runLivenessContinuation;
|
||||
@@ -155,7 +156,7 @@ export function decideRunLivenessContinuation(input: {
|
||||
return { kind: "skip", reason: "continuation wake already exists for this source run and attempt" };
|
||||
}
|
||||
|
||||
const payload = {
|
||||
const payload = withRecoveryModelProfileHint({
|
||||
issueId: issue.id,
|
||||
sourceRunId: run.id,
|
||||
livenessState,
|
||||
@@ -165,14 +166,14 @@ export function decideRunLivenessContinuation(input: {
|
||||
instruction:
|
||||
nextAction ??
|
||||
"The previous run ended without concrete progress. Take the first concrete action now or mark the issue blocked with a specific unblock request.",
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "enqueue",
|
||||
nextAttempt,
|
||||
idempotencyKey,
|
||||
payload,
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: issue.id,
|
||||
taskId: issue.id,
|
||||
taskKey: issue.id,
|
||||
@@ -183,6 +184,6 @@ export function decideRunLivenessContinuation(input: {
|
||||
livenessContinuationState: livenessState,
|
||||
livenessContinuationReason: livenessReason,
|
||||
livenessContinuationInstruction: payload.instruction,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,13 @@ import { instanceSettingsService } from "../instance-settings.js";
|
||||
import { issueTreeControlService } from "../issue-tree-control.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { getRunLogStore } from "../run-log-store.js";
|
||||
import {
|
||||
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
|
||||
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||
SUCCESSFUL_RUN_MISSING_STATE_REASON,
|
||||
buildSuccessfulRunHandoffExhaustedNotice,
|
||||
type SuccessfulRunHandoffNotice,
|
||||
} from "./successful-run-handoff.js";
|
||||
import {
|
||||
RECOVERY_ORIGIN_KINDS,
|
||||
buildIssueGraphLivenessLeafKey,
|
||||
@@ -42,6 +49,10 @@ import {
|
||||
classifyIssueGraphLiveness,
|
||||
type IssueLivenessFinding,
|
||||
} from "./issue-graph-liveness.js";
|
||||
import {
|
||||
recoveryAssigneeAdapterOverrides,
|
||||
withRecoveryModelProfileHint,
|
||||
} from "./model-profile-hint.js";
|
||||
import { isAutomaticRecoverySuppressedByPauseHold } from "./pause-hold-guard.js";
|
||||
|
||||
const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const;
|
||||
@@ -76,6 +87,16 @@ type LatestIssueRun = Pick<
|
||||
> | null;
|
||||
type SuccessfulLatestIssueRun = NonNullable<LatestIssueRun> & { status: "succeeded" };
|
||||
|
||||
type StrandedRecoveryCause = "stranded_assigned_issue" | typeof SUCCESSFUL_RUN_MISSING_STATE_REASON;
|
||||
|
||||
type SuccessfulRunHandoffRecoveryEvidence = {
|
||||
sourceRunId: string | null;
|
||||
correctiveRunId: string;
|
||||
missingDisposition: string;
|
||||
handoffAttempt: number;
|
||||
maxHandoffAttempts: number;
|
||||
};
|
||||
|
||||
type WatchdogDecisionActor =
|
||||
| { type: "board"; userId?: string | null; runId?: string | null }
|
||||
| { type: "agent"; agentId?: string | null; runId?: string | null }
|
||||
@@ -123,6 +144,39 @@ function didAutomaticRecoveryFail(
|
||||
);
|
||||
}
|
||||
|
||||
function successfulRunHandoffRecoveryEvidence(latestRun: LatestIssueRun): SuccessfulRunHandoffRecoveryEvidence | null {
|
||||
if (!latestRun) return null;
|
||||
|
||||
const context = parseObject(latestRun.contextSnapshot);
|
||||
const wakeReason = readNonEmptyString(context.wakeReason);
|
||||
const handoffReason = readNonEmptyString(context.handoffReason);
|
||||
const isSuccessfulRunHandoff =
|
||||
wakeReason === FINISH_SUCCESSFUL_RUN_HANDOFF_REASON ||
|
||||
handoffReason === SUCCESSFUL_RUN_MISSING_STATE_REASON ||
|
||||
asBoolean(context.handoffRequired, false) === true;
|
||||
if (!isSuccessfulRunHandoff) return null;
|
||||
|
||||
const handoffAttempt = asNumber(context.handoffAttempt, 1);
|
||||
const maxHandoffAttempts = asNumber(
|
||||
context.maxHandoffAttempts,
|
||||
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
|
||||
);
|
||||
return {
|
||||
sourceRunId: readNonEmptyString(context.sourceRunId) ?? readNonEmptyString(context.resumeFromRunId),
|
||||
correctiveRunId: latestRun.id,
|
||||
missingDisposition: readNonEmptyString(context.missingDisposition) ?? "clear_next_step",
|
||||
handoffAttempt,
|
||||
maxHandoffAttempts,
|
||||
};
|
||||
}
|
||||
|
||||
function isExhaustedSuccessfulRunHandoff(latestRun: LatestIssueRun) {
|
||||
const evidence = successfulRunHandoffRecoveryEvidence(latestRun);
|
||||
if (!evidence) return null;
|
||||
if (evidence.handoffAttempt < evidence.maxHandoffAttempts) return { ...evidence, exhausted: false };
|
||||
return { ...evidence, exhausted: true };
|
||||
}
|
||||
|
||||
function issueIdFromRunContext(contextSnapshot: unknown) {
|
||||
const context = parseObject(contextSnapshot);
|
||||
return readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
|
||||
@@ -145,6 +199,11 @@ function runUiLink(run: { id: string; agentId: string }, prefix: string) {
|
||||
return `[${run.id}](/${prefix}/agents/${run.agentId}/runs/${run.id})`;
|
||||
}
|
||||
|
||||
function agentUiLink(agent: { id: string; name: string | null } | null, prefix: string) {
|
||||
if (!agent) return "unknown";
|
||||
return `[${agent.name ?? agent.id}](/${prefix}/agents/${agent.id})`;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null) {
|
||||
if (ms === null) return "unknown";
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
@@ -172,6 +231,36 @@ function formatIssueLinksForComment(relations: Array<{ identifier?: string | nul
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function unwrapDatabaseConflictError(error: unknown) {
|
||||
if (!error || typeof error !== "object") return null;
|
||||
|
||||
const candidate = error as {
|
||||
code?: string;
|
||||
constraint?: string;
|
||||
constraint_name?: string;
|
||||
message?: string;
|
||||
cause?: unknown;
|
||||
};
|
||||
|
||||
if (
|
||||
typeof candidate.code === "string" ||
|
||||
typeof candidate.constraint === "string" ||
|
||||
typeof candidate.constraint_name === "string"
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const cause = candidate.cause;
|
||||
if (!cause || typeof cause !== "object") return candidate;
|
||||
|
||||
return cause as {
|
||||
code?: string;
|
||||
constraint?: string;
|
||||
constraint_name?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function isAgentInvokable(agent: typeof agents.$inferSelect | null | undefined) {
|
||||
return Boolean(agent && !["paused", "terminated", "pending_approval"].includes(agent.status));
|
||||
}
|
||||
@@ -391,20 +480,20 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: input.reason,
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId: input.issueId,
|
||||
...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}),
|
||||
},
|
||||
}),
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: input.issueId,
|
||||
taskId: input.issueId,
|
||||
wakeReason: input.reason,
|
||||
retryReason: input.retryReason,
|
||||
source: input.source,
|
||||
...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (queued && input.retryOfRunId) {
|
||||
@@ -427,18 +516,18 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId: issue.id,
|
||||
mutation: "assigned_todo_liveness_dispatch",
|
||||
},
|
||||
}),
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: issue.id,
|
||||
taskId: issue.id,
|
||||
wakeReason: "issue_assigned",
|
||||
source: "issue.assigned_todo_liveness_dispatch",
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -542,18 +631,18 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId: candidate.id,
|
||||
mutation: "unassigned_blocker_recovery",
|
||||
},
|
||||
}),
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: candidate.id,
|
||||
taskId: candidate.id,
|
||||
wakeReason: "issue_assigned",
|
||||
source: "issue.unassigned_blocker_recovery",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (queued) {
|
||||
@@ -869,21 +958,23 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
}
|
||||
|
||||
function isUniqueStaleRunEvaluationConflict(error: unknown) {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const maybe = error as { code?: string; constraint?: string; message?: string };
|
||||
const maybe = unwrapDatabaseConflictError(error);
|
||||
if (!maybe) return false;
|
||||
return maybe.code === "23505" &&
|
||||
(
|
||||
maybe.constraint === "issues_active_stale_run_evaluation_uq" ||
|
||||
maybe.constraint_name === "issues_active_stale_run_evaluation_uq" ||
|
||||
typeof maybe.message === "string" && maybe.message.includes("issues_active_stale_run_evaluation_uq")
|
||||
);
|
||||
}
|
||||
|
||||
function isUniqueStrandedIssueRecoveryConflict(error: unknown) {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const maybe = error as { code?: string; constraint?: string; message?: string };
|
||||
const maybe = unwrapDatabaseConflictError(error);
|
||||
if (!maybe) return false;
|
||||
return maybe.code === "23505" &&
|
||||
(
|
||||
maybe.constraint === "issues_active_stranded_issue_recovery_uq" ||
|
||||
maybe.constraint_name === "issues_active_stranded_issue_recovery_uq" ||
|
||||
typeof maybe.message === "string" && maybe.message.includes("issues_active_stranded_issue_recovery_uq")
|
||||
);
|
||||
}
|
||||
@@ -995,6 +1086,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
goalId: sourceIssue?.goalId ?? null,
|
||||
billingCode: sourceIssue?.billingCode ?? null,
|
||||
assigneeAgentId: ownerAgentId,
|
||||
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
|
||||
originKind: STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND,
|
||||
originId: input.run.id,
|
||||
originRunId: input.run.id,
|
||||
@@ -1036,21 +1128,21 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId: evaluation.id,
|
||||
staleRunId: input.run.id,
|
||||
sourceIssueId: sourceIssue?.id ?? null,
|
||||
},
|
||||
}),
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: evaluation.id,
|
||||
taskId: evaluation.id,
|
||||
wakeReason: "issue_assigned",
|
||||
source: STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND,
|
||||
staleRunId: input.run.id,
|
||||
sourceIssueId: sourceIssue?.id ?? null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
return { kind: "created" as const, evaluationIssueId: evaluation.id };
|
||||
@@ -1253,6 +1345,33 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
function isStrandedIssueRecoveryIssue(issue: typeof issues.$inferSelect) {
|
||||
return issue.originKind === STRANDED_ISSUE_RECOVERY_ORIGIN_KIND;
|
||||
}
|
||||
|
||||
async function buildNestedStrandedRecoveryLine(issue: typeof issues.$inferSelect, prefix: string) {
|
||||
const sourceIssueId = readNonEmptyString(issue.originId);
|
||||
const sourceIssue = sourceIssueId
|
||||
? await db
|
||||
.select({ id: issues.id, identifier: issues.identifier })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, issue.companyId), eq(issues.id, sourceIssueId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const sourceLine = sourceIssue
|
||||
? `- Original source issue: ${issueUiLink(sourceIssue, prefix)}`
|
||||
: sourceIssueId
|
||||
? `- Original source issue: \`${sourceIssueId}\``
|
||||
: "- Original source issue: unknown";
|
||||
|
||||
return [
|
||||
"",
|
||||
"- Nested recovery: suppressed because this issue is already a `stranded_issue_recovery` issue.",
|
||||
sourceLine,
|
||||
"- Next action: the assigned recovery owner or board operator should fix the runtime/adapter problem, resolve or reassign the original source issue, then mark this recovery issue done or cancelled.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function resolveStrandedIssueRecoveryOwnerAgentId(issue: typeof issues.$inferSelect) {
|
||||
const candidateIds: string[] = [];
|
||||
if (issue.assigneeAgentId) {
|
||||
@@ -1294,11 +1413,45 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
latestRun: LatestIssueRun;
|
||||
previousStatus: "todo" | "in_progress";
|
||||
prefix: string;
|
||||
recoveryCause?: StrandedRecoveryCause;
|
||||
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
|
||||
sourceAssignee?: Pick<typeof agents.$inferSelect, "id" | "name"> | null;
|
||||
}) {
|
||||
const sourceIssue = issueUiLink({ identifier: input.issue.identifier, id: input.issue.id }, input.prefix);
|
||||
const runLink = input.latestRun
|
||||
? `[\`${input.latestRun.id}\`](/${input.prefix}/agents/${input.latestRun.agentId}/runs/${input.latestRun.id})`
|
||||
: "none";
|
||||
if (input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON) {
|
||||
const sourceRunId = input.successfulRunHandoffEvidence?.sourceRunId;
|
||||
const sourceRunLink = sourceRunId && input.latestRun
|
||||
? `[\`${sourceRunId}\`](/${input.prefix}/agents/${input.latestRun.agentId}/runs/${sourceRunId})`
|
||||
: "unknown";
|
||||
const missingDisposition = input.successfulRunHandoffEvidence?.missingDisposition ?? "clear_next_step";
|
||||
return [
|
||||
"Paperclip exhausted the bounded corrective handoff for a successful run that still has no valid issue disposition.",
|
||||
"",
|
||||
"This is not a runtime/adapter crash report. The source run succeeded; the remaining problem is the missing `done`, `in_review`, `blocked`, delegated follow-up, or explicit continuation path.",
|
||||
"",
|
||||
"## Safe Evidence",
|
||||
"",
|
||||
`- Source issue: ${sourceIssue}`,
|
||||
`- Source run: ${sourceRunLink}`,
|
||||
`- Corrective handoff run: ${runLink}`,
|
||||
`- Source assignee: ${agentUiLink(input.sourceAssignee ?? null, input.prefix)}`,
|
||||
`- Latest issue status: \`${input.issue.status}\``,
|
||||
`- Latest handoff run status: \`${input.latestRun?.status ?? "unknown"}\``,
|
||||
`- Normalized cause: \`${SUCCESSFUL_RUN_MISSING_STATE_REASON}\``,
|
||||
`- Missing disposition: \`${missingDisposition}\``,
|
||||
"- Suggested manager action: choose and record a valid issue disposition without copying transcript content.",
|
||||
"",
|
||||
"## Required Action",
|
||||
"",
|
||||
"- Inspect the source issue and run metadata, not raw transcript excerpts.",
|
||||
"- Choose a valid issue disposition: `done`/`cancelled`, `in_review` with an owner, `blocked` with first-class blockers, delegated follow-up work, or an explicit continuation path.",
|
||||
"- When the source issue has a clear owner and disposition, mark this recovery issue done.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const retryReason = readNonEmptyString(parseObject(input.latestRun?.contextSnapshot)?.retryReason) ?? "unknown";
|
||||
const failureSummary = summarizeRunFailureForIssueComment(input.latestRun);
|
||||
|
||||
@@ -1331,6 +1484,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
issue: typeof issues.$inferSelect;
|
||||
latestRun: LatestIssueRun;
|
||||
previousStatus: "todo" | "in_progress";
|
||||
recoveryCause?: StrandedRecoveryCause;
|
||||
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
|
||||
}) {
|
||||
if (isStrandedIssueRecoveryIssue(input.issue)) return null;
|
||||
|
||||
@@ -1341,15 +1496,22 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
if (!ownerAgentId) return null;
|
||||
|
||||
const prefix = await getCompanyIssuePrefix(input.issue.companyId);
|
||||
const sourceAssignee = input.issue.assigneeAgentId ? await getAgent(input.issue.assigneeAgentId) : null;
|
||||
const recoveryCause = input.recoveryCause ?? "stranded_assigned_issue";
|
||||
let recovery: Awaited<ReturnType<typeof issuesSvc.create>>;
|
||||
try {
|
||||
recovery = await issuesSvc.create(input.issue.companyId, {
|
||||
title: `Recover stalled issue ${input.issue.identifier ?? input.issue.title}`,
|
||||
title: recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
|
||||
? `Recover missing next step ${input.issue.identifier ?? input.issue.title}`
|
||||
: `Recover stalled issue ${input.issue.identifier ?? input.issue.title}`,
|
||||
description: buildStrandedIssueRecoveryDescription({
|
||||
issue: input.issue,
|
||||
latestRun: input.latestRun,
|
||||
previousStatus: input.previousStatus,
|
||||
prefix,
|
||||
recoveryCause,
|
||||
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
|
||||
sourceAssignee,
|
||||
}),
|
||||
status: "todo",
|
||||
priority: input.issue.priority,
|
||||
@@ -1357,6 +1519,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
projectId: input.issue.projectId,
|
||||
goalId: input.issue.goalId,
|
||||
assigneeAgentId: ownerAgentId,
|
||||
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
|
||||
originKind: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
|
||||
originId: input.issue.id,
|
||||
originRunId: input.latestRun?.id ?? null,
|
||||
@@ -1364,6 +1527,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
|
||||
input.issue.companyId,
|
||||
input.issue.id,
|
||||
recoveryCause,
|
||||
input.latestRun?.id ?? "no-run",
|
||||
].join(":"),
|
||||
billingCode: input.issue.billingCode,
|
||||
@@ -1380,21 +1544,23 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId: recovery.id,
|
||||
sourceIssueId: input.issue.id,
|
||||
strandedRunId: input.latestRun?.id ?? null,
|
||||
},
|
||||
recoveryCause,
|
||||
}),
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: recovery.id,
|
||||
taskId: recovery.id,
|
||||
wakeReason: "issue_assigned",
|
||||
source: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
|
||||
sourceIssueId: input.issue.id,
|
||||
strandedRunId: input.latestRun?.id ?? null,
|
||||
},
|
||||
recoveryCause,
|
||||
}),
|
||||
});
|
||||
|
||||
return recovery;
|
||||
@@ -1512,21 +1678,21 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
issue: typeof issues.$inferSelect;
|
||||
previousStatus: "todo" | "in_progress";
|
||||
latestRun: LatestIssueRun;
|
||||
comment: string;
|
||||
comment?: string;
|
||||
recoveryCause?: StrandedRecoveryCause;
|
||||
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
|
||||
}) {
|
||||
if (isStrandedIssueRecoveryIssue(input.issue)) {
|
||||
return escalateStrandedRecoveryIssueInPlace({
|
||||
const nestedRecoverySuppressed = isStrandedIssueRecoveryIssue(input.issue);
|
||||
let recoveryIssue: typeof issues.$inferSelect | null = null;
|
||||
if (!nestedRecoverySuppressed) {
|
||||
recoveryIssue = await ensureStrandedIssueRecoveryIssue({
|
||||
issue: input.issue,
|
||||
previousStatus: input.previousStatus,
|
||||
latestRun: input.latestRun,
|
||||
recoveryCause: input.recoveryCause,
|
||||
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
|
||||
});
|
||||
}
|
||||
|
||||
const recoveryIssue = await ensureStrandedIssueRecoveryIssue({
|
||||
issue: input.issue,
|
||||
previousStatus: input.previousStatus,
|
||||
latestRun: input.latestRun,
|
||||
});
|
||||
const blockerIds = await existingUnresolvedBlockerIssueIds(input.issue.companyId, input.issue.id);
|
||||
const nextBlockerIds = recoveryIssue
|
||||
? [...new Set([...blockerIds, recoveryIssue.id])]
|
||||
@@ -1538,19 +1704,51 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
if (!updated) return null;
|
||||
|
||||
const prefix = await getCompanyIssuePrefix(input.issue.companyId);
|
||||
const recoveryLine = recoveryIssue
|
||||
? [
|
||||
const recoveryOwner = recoveryIssue?.assigneeAgentId ? await getAgent(recoveryIssue.assigneeAgentId) : null;
|
||||
const sourceAssignee = input.issue.assigneeAgentId ? await getAgent(input.issue.assigneeAgentId) : null;
|
||||
let notice: SuccessfulRunHandoffNotice | null = null;
|
||||
if (input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON && input.successfulRunHandoffEvidence) {
|
||||
notice = buildSuccessfulRunHandoffExhaustedNotice({
|
||||
issue: input.issue,
|
||||
sourceRun: input.successfulRunHandoffEvidence.sourceRunId
|
||||
? { id: input.successfulRunHandoffEvidence.sourceRunId, status: "succeeded" }
|
||||
: null,
|
||||
correctiveRun: input.latestRun ? { id: input.latestRun.id, status: input.latestRun.status } : null,
|
||||
sourceAssignee,
|
||||
recoveryIssue,
|
||||
recoveryOwner,
|
||||
latestIssueStatus: input.issue.status,
|
||||
latestHandoffRunStatus: input.latestRun?.status ?? "unknown",
|
||||
missingDisposition: input.successfulRunHandoffEvidence.missingDisposition,
|
||||
});
|
||||
}
|
||||
let recoveryLine: string;
|
||||
if (nestedRecoverySuppressed) {
|
||||
recoveryLine = await buildNestedStrandedRecoveryLine(input.issue, prefix);
|
||||
} else if (recoveryIssue) {
|
||||
recoveryLine = [
|
||||
"",
|
||||
`- Recovery issue: ${issueUiLink({ identifier: recoveryIssue.identifier, id: recoveryIssue.id }, prefix)}`,
|
||||
`- Recovery owner: ${agentUiLink(recoveryOwner, prefix)}`,
|
||||
"- Next action: the recovery owner should either restore a live execution path or record the manual resolution, then mark the recovery issue done.",
|
||||
].join("\n")
|
||||
: [
|
||||
].join("\n");
|
||||
} else {
|
||||
recoveryLine = [
|
||||
"",
|
||||
"- Recovery issue: none created because Paperclip could not find an invokable manager, creator, or executive owner with budget available.",
|
||||
"- Next action: a board operator should assign an invokable recovery owner, fix the agent/runtime state, or record an intentional manual resolution.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
await issuesSvc.addComment(input.issue.id, `${input.comment}${recoveryLine}`, {});
|
||||
if (notice) {
|
||||
await issuesSvc.addComment(input.issue.id, notice.body, {}, {
|
||||
authorType: "system",
|
||||
presentation: notice.presentation,
|
||||
metadata: notice.metadata,
|
||||
});
|
||||
} else {
|
||||
await issuesSvc.addComment(input.issue.id, `${input.comment ?? ""}${recoveryLine}`, {});
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: input.issue.companyId,
|
||||
@@ -1558,18 +1756,24 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
actorId: "system",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
action: "issue.updated",
|
||||
action: input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
|
||||
? "issue.successful_run_handoff_escalated"
|
||||
: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: input.issue.id,
|
||||
details: {
|
||||
identifier: input.issue.identifier,
|
||||
status: "blocked",
|
||||
previousStatus: input.previousStatus,
|
||||
source: "recovery.reconcile_stranded_assigned_issue",
|
||||
source: input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
|
||||
? "recovery.reconcile_successful_run_handoff_missing_state"
|
||||
: "recovery.reconcile_stranded_assigned_issue",
|
||||
recoveryCause: input.recoveryCause ?? "stranded_assigned_issue",
|
||||
latestRunId: input.latestRun?.id ?? null,
|
||||
latestRunStatus: input.latestRun?.status ?? null,
|
||||
latestRunErrorCode: input.latestRun?.errorCode ?? null,
|
||||
recoveryIssueId: recoveryIssue?.id ?? null,
|
||||
nestedRecoverySuppressed,
|
||||
blockerIssueIds: nextBlockerIds,
|
||||
},
|
||||
});
|
||||
@@ -1596,6 +1800,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
productiveContinuationObserved: 0,
|
||||
successfulContinuationObserved: 0,
|
||||
orphanBlockersAssigned: 0,
|
||||
successfulRunHandoffEscalated: 0,
|
||||
escalated: 0,
|
||||
skipped: 0,
|
||||
issueIds: [] as string[],
|
||||
@@ -1713,6 +1918,28 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const handoffEvidence = isExhaustedSuccessfulRunHandoff(latestRun);
|
||||
if (handoffEvidence) {
|
||||
if (!handoffEvidence.exhausted) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const updated = await escalateStrandedAssignedIssue({
|
||||
issue,
|
||||
previousStatus: "in_progress",
|
||||
latestRun,
|
||||
recoveryCause: SUCCESSFUL_RUN_MISSING_STATE_REASON,
|
||||
successfulRunHandoffEvidence: handoffEvidence,
|
||||
});
|
||||
if (updated) {
|
||||
result.successfulRunHandoffEscalated += 1;
|
||||
result.issueIds.push(issue.id);
|
||||
} else {
|
||||
result.skipped += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isSuccessfulInProgressContinuationRun(latestRun)) {
|
||||
const successfulRun = latestRun;
|
||||
|
||||
@@ -1836,7 +2063,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
createdByAgentId: issues.createdByAgentId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
executionPolicy: issues.executionPolicy,
|
||||
executionState: issues.executionState,
|
||||
monitorNextCheckAt: issues.monitorNextCheckAt,
|
||||
monitorAttemptCount: issues.monitorAttemptCount,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
@@ -1920,19 +2150,41 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
companyId: issues.companyId,
|
||||
id: issues.id,
|
||||
status: issues.status,
|
||||
originKind: issues.originKind,
|
||||
originId: issues.originId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
isNull(issues.hiddenAt),
|
||||
eq(issues.originKind, STRANDED_ISSUE_RECOVERY_ORIGIN_KIND),
|
||||
inArray(issues.originKind, [
|
||||
STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
|
||||
RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation,
|
||||
]),
|
||||
notInArray(issues.status, ["done", "cancelled"]),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const openRecoveryIssues = recoveryIssueRows.flatMap((row) => {
|
||||
if (row.originKind === RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation) {
|
||||
const parsed = parseIssueGraphLivenessIncidentKey(row.originId);
|
||||
if (!parsed || parsed.companyId !== row.companyId) return [];
|
||||
if (parsed.state !== "blocked_by_assigned_backlog_issue") return [];
|
||||
return [
|
||||
{
|
||||
companyId: row.companyId,
|
||||
issueId: parsed.issueId,
|
||||
status: row.status,
|
||||
},
|
||||
{
|
||||
companyId: row.companyId,
|
||||
issueId: parsed.leafIssueId,
|
||||
status: row.status,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const issueId = readNonEmptyString(row.originId);
|
||||
if (!issueId) return [];
|
||||
return [{
|
||||
@@ -1966,6 +2218,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
pendingInteractions: interactionRows,
|
||||
pendingApprovals: approvalRows,
|
||||
openRecoveryIssues,
|
||||
now: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2389,6 +2642,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
projectId: recoveryIssue.projectId,
|
||||
goalId: recoveryIssue.goalId,
|
||||
assigneeAgentId: ownerSelection.agentId,
|
||||
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
|
||||
originKind: RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation,
|
||||
originId: input.finding.incidentKey,
|
||||
originFingerprint: livenessRecoveryLeafFingerprint(input.finding),
|
||||
@@ -2469,15 +2723,15 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId: escalation.id,
|
||||
sourceIssueId: issue.id,
|
||||
recoveryIssueId: recoveryIssue.id,
|
||||
incidentKey: input.finding.incidentKey,
|
||||
},
|
||||
}),
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: escalation.id,
|
||||
taskId: escalation.id,
|
||||
wakeReason: "issue_assigned",
|
||||
@@ -2485,7 +2739,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
sourceIssueId: issue.id,
|
||||
recoveryIssueId: recoveryIssue.id,
|
||||
incidentKey: input.finding.incidentKey,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
logger.warn({
|
||||
@@ -2575,6 +2829,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
|
||||
return {
|
||||
buildRunOutputSilence,
|
||||
escalateStrandedRecoveryIssueInPlace,
|
||||
escalateStrandedAssignedIssue,
|
||||
recordWatchdogDecision,
|
||||
scanSilentActiveRuns,
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
|
||||
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
|
||||
SUCCESSFUL_RUN_MISSING_STATE_REASON,
|
||||
buildFinishSuccessfulRunHandoffIdempotencyKey,
|
||||
buildSuccessfulRunHandoffExhaustedNotice,
|
||||
buildSuccessfulRunHandoffRequiredNotice,
|
||||
decideSuccessfulRunHandoff,
|
||||
isIdempotentFinishSuccessfulRunHandoffWakeStatus,
|
||||
isSuccessfulRunHandoffRequiredNoticeBody,
|
||||
} from "./successful-run-handoff.js";
|
||||
|
||||
const run = {
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
status: "succeeded",
|
||||
contextSnapshot: { issueId: "issue-1" },
|
||||
} as any;
|
||||
|
||||
const issue = {
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1",
|
||||
title: "Finish backend handoff",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
executionState: null,
|
||||
} as any;
|
||||
|
||||
const agent = {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
status: "idle",
|
||||
} as any;
|
||||
|
||||
function decide(overrides: Partial<Parameters<typeof decideSuccessfulRunHandoff>[0]> = {}) {
|
||||
return decideSuccessfulRunHandoff({
|
||||
run,
|
||||
issue,
|
||||
agent,
|
||||
livenessState: "advanced",
|
||||
detectedProgressSummary: "Run produced concrete action evidence: 1 issue comment(s)",
|
||||
taskKey: "issue-1",
|
||||
hasActiveExecutionPath: false,
|
||||
hasQueuedWake: false,
|
||||
hasPendingInteractionOrApproval: false,
|
||||
hasExplicitBlockerPath: false,
|
||||
hasOpenRecoveryIssue: false,
|
||||
hasPauseHold: false,
|
||||
budgetBlocked: false,
|
||||
idempotentWakeExists: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("successful run handoff decision", () => {
|
||||
it("queues one corrective handoff wake for a successful progress run without a visible next action", () => {
|
||||
const decision = decide();
|
||||
|
||||
expect(decision.kind).toBe("enqueue");
|
||||
if (decision.kind !== "enqueue") return;
|
||||
expect(decision.idempotencyKey).toBe("finish_successful_run_handoff:issue-1:run-1:1");
|
||||
expect(decision.payload).toMatchObject({
|
||||
issueId: "issue-1",
|
||||
sourceRunId: "run-1",
|
||||
handoffRequired: true,
|
||||
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
|
||||
missingDisposition: "clear_next_step",
|
||||
handoffAttempt: 1,
|
||||
maxHandoffAttempts: 1,
|
||||
resumeIntent: true,
|
||||
resumeFromRunId: "run-1",
|
||||
modelProfile: "cheap",
|
||||
});
|
||||
expect(decision.contextSnapshot).toMatchObject({
|
||||
wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||
handoffRequired: true,
|
||||
modelProfile: "cheap",
|
||||
});
|
||||
expect(decision.instruction).toContain("Resolve the missing disposition before creating or revising any new artifacts");
|
||||
expect(decision.instruction).toContain("Choose **exactly one** outcome");
|
||||
expect(decision.instruction).toContain("record an explicit continuation path");
|
||||
});
|
||||
|
||||
it("does not queue when the issue already has a valid disposition", () => {
|
||||
expect(decide({ issue: { ...issue, status: "done" } as any })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "issue status done is a valid disposition",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not queue when a successful run records an accepted next-action path", () => {
|
||||
expect(decide({ issue: { ...issue, status: "in_review" } as any })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "issue status in_review is a valid disposition",
|
||||
});
|
||||
expect(decide({ issue: { ...issue, status: "blocked" } as any })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "issue status blocked is a valid disposition",
|
||||
});
|
||||
expect(decide({ hasPendingInteractionOrApproval: true })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "pending interaction or approval owns the next action",
|
||||
});
|
||||
expect(decide({ hasActiveExecutionPath: true })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "issue already has an active execution path",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not queue when another wake or dependency path already owns the next action", () => {
|
||||
expect(decide({ hasQueuedWake: true })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "issue already has a queued or deferred wake",
|
||||
});
|
||||
expect(decide({ hasExplicitBlockerPath: true })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "explicit blocker path owns the next action",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not queue when a successful run has no progress signal", () => {
|
||||
expect(decide({ livenessState: null, detectedProgressSummary: null })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "successful run did not produce handoff-relevant progress",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat adapter or runtime failures as missing-disposition handoffs", () => {
|
||||
expect(decide({ run: { ...run, status: "failed", errorCode: "adapter_failed" } as any })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "source run did not succeed",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not queue on missing-comment retry bookkeeping runs", () => {
|
||||
expect(decide({ run: { ...run, issueCommentStatus: "retry_exhausted" } as any })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "missing issue comment retry owns the next action",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not loop from a corrective handoff run", () => {
|
||||
expect(decide({
|
||||
run: {
|
||||
...run,
|
||||
id: "run-2",
|
||||
contextSnapshot: {
|
||||
issueId: "issue-1",
|
||||
wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||
handoffRequired: true,
|
||||
},
|
||||
} as any,
|
||||
})).toEqual({
|
||||
kind: "skip",
|
||||
reason: "source run is already a corrective handoff run",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not queue for issue monitor maintenance runs", () => {
|
||||
expect(decide({
|
||||
run: {
|
||||
...run,
|
||||
contextSnapshot: {
|
||||
issueId: "issue-1",
|
||||
source: "issue.monitor",
|
||||
wakeReason: "issue_monitor_due",
|
||||
},
|
||||
} as any,
|
||||
})).toEqual({
|
||||
kind: "skip",
|
||||
reason: "issue monitor run owns its own recovery path",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a stable one-attempt idempotency key", () => {
|
||||
expect(buildFinishSuccessfulRunHandoffIdempotencyKey({
|
||||
issueId: "issue-1",
|
||||
sourceRunId: "run-1",
|
||||
})).toBe("finish_successful_run_handoff:issue-1:run-1:1");
|
||||
});
|
||||
|
||||
it("allows failed or cancelled corrective wakes to be retried", () => {
|
||||
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("queued")).toBe(true);
|
||||
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("claimed")).toBe(true);
|
||||
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("completed")).toBe(true);
|
||||
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("failed")).toBe(false);
|
||||
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("cancelled")).toBe(false);
|
||||
});
|
||||
|
||||
it("builds the required system notice with hidden structured metadata", () => {
|
||||
const notice = buildSuccessfulRunHandoffRequiredNotice({
|
||||
issue: {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
identifier: "PAP-1",
|
||||
title: "Finish backend handoff",
|
||||
status: "in_progress",
|
||||
} as any,
|
||||
run: {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
status: "succeeded",
|
||||
} as any,
|
||||
agent: {
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
name: "CodexCoder",
|
||||
} as any,
|
||||
detectedProgressSummary: "Run produced concrete action evidence: 1 issue comment(s)",
|
||||
});
|
||||
|
||||
expect(notice.body).toBe(SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY);
|
||||
expect(notice.presentation).toEqual({
|
||||
kind: "system_notice",
|
||||
tone: "warning",
|
||||
title: "Missing issue disposition",
|
||||
detailsDefaultOpen: false,
|
||||
});
|
||||
expect(notice.metadata.sourceRunId).toBe("22222222-2222-4222-8222-222222222222");
|
||||
expect(notice.metadata.sections).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Required action",
|
||||
rows: expect.arrayContaining([
|
||||
expect.objectContaining({ type: "issue_link", identifier: "PAP-1" }),
|
||||
expect.objectContaining({ type: "agent_link", name: "CodexCoder" }),
|
||||
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "Run evidence",
|
||||
rows: expect.arrayContaining([
|
||||
expect.objectContaining({ type: "run_link", runId: "22222222-2222-4222-8222-222222222222" }),
|
||||
expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }),
|
||||
expect.objectContaining({ type: "key_value", label: "Detected progress" }),
|
||||
]),
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("builds the exhausted system notice with recovery metadata", () => {
|
||||
const notice = buildSuccessfulRunHandoffExhaustedNotice({
|
||||
issue: {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
identifier: "PAP-1",
|
||||
title: "Finish backend handoff",
|
||||
status: "in_progress",
|
||||
} as any,
|
||||
sourceRun: { id: "22222222-2222-4222-8222-222222222222", status: "succeeded" } as any,
|
||||
correctiveRun: { id: "44444444-4444-4444-8444-444444444444", status: "failed" } as any,
|
||||
sourceAssignee: { id: "33333333-3333-4333-8333-333333333333", name: "CodexCoder" } as any,
|
||||
recoveryIssue: {
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
identifier: "PAP-2",
|
||||
title: "Recover missing next step PAP-1",
|
||||
status: "todo",
|
||||
} as any,
|
||||
recoveryOwner: { id: "66666666-6666-4666-8666-666666666666", name: "CTO" } as any,
|
||||
latestIssueStatus: "in_progress",
|
||||
latestHandoffRunStatus: "failed",
|
||||
missingDisposition: "clear_next_step",
|
||||
});
|
||||
|
||||
expect(notice.body).toBe(SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY);
|
||||
expect(notice.presentation).toMatchObject({
|
||||
kind: "system_notice",
|
||||
tone: "danger",
|
||||
detailsDefaultOpen: false,
|
||||
});
|
||||
expect(notice.metadata.sourceRunId).toBe("22222222-2222-4222-8222-222222222222");
|
||||
expect(notice.metadata.sections).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Recovery owner",
|
||||
rows: expect.arrayContaining([
|
||||
expect.objectContaining({ type: "issue_link", identifier: "PAP-2" }),
|
||||
expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "CTO" }),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "Run evidence",
|
||||
rows: expect.arrayContaining([
|
||||
expect.objectContaining({ type: "run_link", label: "Source run" }),
|
||||
expect.objectContaining({ type: "run_link", label: "Corrective handoff run" }),
|
||||
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
|
||||
]),
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("recognizes new notices and legacy markdown headings for fallback deduplication", () => {
|
||||
expect(isSuccessfulRunHandoffRequiredNoticeBody(SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY)).toBe(true);
|
||||
expect(isSuccessfulRunHandoffRequiredNoticeBody("## Successful run missing issue disposition\n\nold body")).toBe(true);
|
||||
expect(isSuccessfulRunHandoffRequiredNoticeBody("## This issue still needs a next step\n\nold body")).toBe(true);
|
||||
expect(isSuccessfulRunHandoffRequiredNoticeBody("Unrelated comment")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import type { IssueCommentMetadata, IssueCommentPresentation, RunLivenessState } from "@paperclipai/shared";
|
||||
import { withRecoveryModelProfileHint } from "./model-profile-hint.js";
|
||||
|
||||
export const FINISH_SUCCESSFUL_RUN_HANDOFF_REASON = "finish_successful_run_handoff";
|
||||
export const SUCCESSFUL_RUN_MISSING_STATE_REASON = "successful_run_missing_state";
|
||||
export const DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS = 1;
|
||||
export const SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY =
|
||||
"Paperclip needs a disposition before this issue can continue.";
|
||||
export const SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY =
|
||||
"Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner.";
|
||||
export const LEGACY_SUCCESSFUL_RUN_HANDOFF_NOTICE_PREFIXES = [
|
||||
"## This issue still needs a next step",
|
||||
"## Successful run missing issue disposition",
|
||||
] as const;
|
||||
|
||||
export const SUCCESSFUL_RUN_HANDOFF_OPTIONS = [
|
||||
"mark_done_or_cancelled",
|
||||
"send_for_review_or_ask_for_input",
|
||||
"mark_blocked",
|
||||
"delegate_or_continue_from_checkpoint",
|
||||
] as const;
|
||||
|
||||
const PRODUCTIVE_SUCCESS_LIVENESS_STATES = new Set<RunLivenessState>([
|
||||
"advanced",
|
||||
"completed",
|
||||
"blocked",
|
||||
"needs_followup",
|
||||
]);
|
||||
|
||||
const IDEMPOTENT_HANDOFF_WAKE_STATUSES = [
|
||||
"queued",
|
||||
"deferred_issue_execution",
|
||||
"claimed",
|
||||
"completed",
|
||||
];
|
||||
const IDEMPOTENT_HANDOFF_WAKE_STATUS_SET = new Set<string>(IDEMPOTENT_HANDOFF_WAKE_STATUSES);
|
||||
|
||||
export function isIdempotentFinishSuccessfulRunHandoffWakeStatus(status: string) {
|
||||
return IDEMPOTENT_HANDOFF_WAKE_STATUS_SET.has(status);
|
||||
}
|
||||
|
||||
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
|
||||
type IssueRow = Pick<
|
||||
typeof issues.$inferSelect,
|
||||
"id" | "companyId" | "identifier" | "title" | "status" | "assigneeAgentId" | "assigneeUserId" | "executionState"
|
||||
>;
|
||||
type AgentRow = Pick<typeof agents.$inferSelect, "id" | "companyId" | "status">;
|
||||
type NoticeIssue = Pick<typeof issues.$inferSelect, "id" | "identifier" | "title" | "status">;
|
||||
type NoticeRun = Pick<typeof heartbeatRuns.$inferSelect, "id" | "status">;
|
||||
type NoticeAgent = Pick<typeof agents.$inferSelect, "id" | "name">;
|
||||
type NullableNoticeAgent = NoticeAgent | null | undefined;
|
||||
type NullableNoticeIssue = NoticeIssue | null | undefined;
|
||||
type NullableNoticeRun = NoticeRun | null | undefined;
|
||||
|
||||
export type SuccessfulRunHandoffNotice = {
|
||||
body: string;
|
||||
presentation: IssueCommentPresentation;
|
||||
metadata: IssueCommentMetadata;
|
||||
};
|
||||
|
||||
export type SuccessfulRunHandoffDecision =
|
||||
| {
|
||||
kind: "enqueue";
|
||||
idempotencyKey: string;
|
||||
payload: Record<string, unknown>;
|
||||
contextSnapshot: Record<string, unknown>;
|
||||
instruction: string;
|
||||
}
|
||||
| {
|
||||
kind: "skip";
|
||||
reason: string;
|
||||
};
|
||||
|
||||
function metadataText(value: unknown, fallback = "unknown") {
|
||||
const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
|
||||
const resolved = text.length > 0 ? text : fallback;
|
||||
return resolved.length > 2000 ? `${resolved.slice(0, 1997)}...` : resolved;
|
||||
}
|
||||
|
||||
function keyValueRow(label: string, value: unknown): IssueCommentMetadata["sections"][number]["rows"][number] {
|
||||
return { type: "key_value", label, value: metadataText(value) };
|
||||
}
|
||||
|
||||
function issueLinkRow(
|
||||
label: string,
|
||||
issue: NullableNoticeIssue,
|
||||
): IssueCommentMetadata["sections"][number]["rows"][number] {
|
||||
if (!issue) return keyValueRow(label, "unknown");
|
||||
return {
|
||||
type: "issue_link",
|
||||
label,
|
||||
issueId: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
};
|
||||
}
|
||||
|
||||
function runLinkRow(
|
||||
label: string,
|
||||
run: NullableNoticeRun,
|
||||
): IssueCommentMetadata["sections"][number]["rows"][number] {
|
||||
if (!run) return keyValueRow(label, "unknown");
|
||||
return { type: "run_link", label, runId: run.id, title: run.status };
|
||||
}
|
||||
|
||||
function agentLinkRow(
|
||||
label: string,
|
||||
agent: NullableNoticeAgent,
|
||||
): IssueCommentMetadata["sections"][number]["rows"][number] {
|
||||
if (!agent) return keyValueRow(label, "unknown");
|
||||
return { type: "agent_link", label, agentId: agent.id, name: agent.name };
|
||||
}
|
||||
|
||||
function systemNoticePresentation(input: {
|
||||
tone: IssueCommentPresentation["tone"];
|
||||
title: string;
|
||||
}): IssueCommentPresentation {
|
||||
return {
|
||||
kind: "system_notice",
|
||||
tone: input.tone,
|
||||
title: input.title,
|
||||
detailsDefaultOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function isSuccessfulRunHandoffRequiredNoticeBody(body: string) {
|
||||
const trimmed = body.trim();
|
||||
return trimmed === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY ||
|
||||
LEGACY_SUCCESSFUL_RUN_HANDOFF_NOTICE_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function buildSuccessfulRunHandoffRequiredNotice(input: {
|
||||
issue: NoticeIssue;
|
||||
run: NoticeRun;
|
||||
agent: NoticeAgent;
|
||||
detectedProgressSummary: string;
|
||||
}): SuccessfulRunHandoffNotice {
|
||||
return {
|
||||
body: SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
|
||||
presentation: systemNoticePresentation({
|
||||
tone: "warning",
|
||||
title: "Missing issue disposition",
|
||||
}),
|
||||
metadata: {
|
||||
version: 1,
|
||||
sourceRunId: input.run.id,
|
||||
sections: [
|
||||
{
|
||||
title: "Required action",
|
||||
rows: [
|
||||
issueLinkRow("Source issue", input.issue),
|
||||
agentLinkRow("Assignee", input.agent),
|
||||
keyValueRow("Missing disposition", "clear_next_step"),
|
||||
keyValueRow(
|
||||
"Valid dispositions",
|
||||
"done, cancelled, in_review with an owner, blocked with blockers, delegated follow-up, or explicit continuation",
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Run evidence",
|
||||
rows: [
|
||||
runLinkRow("Successful run", input.run),
|
||||
keyValueRow("Run status", input.run.status),
|
||||
keyValueRow("Normalized cause", SUCCESSFUL_RUN_MISSING_STATE_REASON),
|
||||
keyValueRow("Detected progress", input.detectedProgressSummary),
|
||||
keyValueRow("Automatic retry", "one corrective handoff wake queued"),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSuccessfulRunHandoffExhaustedNotice(input: {
|
||||
issue: NoticeIssue;
|
||||
sourceRun: NullableNoticeRun;
|
||||
correctiveRun: NullableNoticeRun;
|
||||
sourceAssignee: NullableNoticeAgent;
|
||||
recoveryIssue: NullableNoticeIssue;
|
||||
recoveryOwner: NullableNoticeAgent;
|
||||
latestIssueStatus: string;
|
||||
latestHandoffRunStatus: string;
|
||||
missingDisposition: string;
|
||||
}): SuccessfulRunHandoffNotice {
|
||||
return {
|
||||
body: SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
|
||||
presentation: systemNoticePresentation({
|
||||
tone: "danger",
|
||||
title: "Missing disposition recovery blocked",
|
||||
}),
|
||||
metadata: {
|
||||
version: 1,
|
||||
sourceRunId: input.sourceRun?.id ?? null,
|
||||
sections: [
|
||||
{
|
||||
title: "Recovery owner",
|
||||
rows: [
|
||||
issueLinkRow("Source issue", input.issue),
|
||||
issueLinkRow("Recovery issue", input.recoveryIssue),
|
||||
agentLinkRow("Recovery owner", input.recoveryOwner),
|
||||
agentLinkRow("Source assignee", input.sourceAssignee),
|
||||
keyValueRow("Suggested action", "choose and record a valid issue disposition without copying transcript content"),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Run evidence",
|
||||
rows: [
|
||||
runLinkRow("Source run", input.sourceRun),
|
||||
runLinkRow("Corrective handoff run", input.correctiveRun),
|
||||
keyValueRow("Latest issue status", input.latestIssueStatus),
|
||||
keyValueRow("Latest handoff run status", input.latestHandoffRunStatus),
|
||||
keyValueRow("Normalized cause", SUCCESSFUL_RUN_MISSING_STATE_REASON),
|
||||
keyValueRow("Missing disposition", input.missingDisposition),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFinishSuccessfulRunHandoffIdempotencyKey(input: {
|
||||
issueId: string;
|
||||
sourceRunId: string;
|
||||
attempt?: number;
|
||||
}) {
|
||||
return [
|
||||
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||
input.issueId,
|
||||
input.sourceRunId,
|
||||
String(input.attempt ?? 1),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export async function findExistingFinishSuccessfulRunHandoffWake(
|
||||
db: Db,
|
||||
input: {
|
||||
companyId: string;
|
||||
idempotencyKey: string;
|
||||
},
|
||||
) {
|
||||
return db
|
||||
.select({ id: agentWakeupRequests.id, status: agentWakeupRequests.status })
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, input.companyId),
|
||||
eq(agentWakeupRequests.idempotencyKey, input.idempotencyKey),
|
||||
inArray(agentWakeupRequests.status, IDEMPOTENT_HANDOFF_WAKE_STATUSES),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isCorrectiveHandoffRun(run: HeartbeatRunRow) {
|
||||
const context = readRecord(run.contextSnapshot);
|
||||
return context.handoffRequired === true ||
|
||||
readString(context.wakeReason) === FINISH_SUCCESSFUL_RUN_HANDOFF_REASON;
|
||||
}
|
||||
|
||||
function isIssueMonitorMaintenanceRun(run: HeartbeatRunRow) {
|
||||
const context = readRecord(run.contextSnapshot);
|
||||
const wakeReason = readString(context.wakeReason);
|
||||
const source = readString(context.source);
|
||||
return Boolean(wakeReason?.startsWith("issue_monitor") || source?.startsWith("issue.monitor"));
|
||||
}
|
||||
|
||||
function isProductiveSuccessfulRun(input: {
|
||||
livenessState: RunLivenessState | null;
|
||||
detectedProgressSummary: string | null;
|
||||
}) {
|
||||
if (input.livenessState && PRODUCTIVE_SUCCESS_LIVENESS_STATES.has(input.livenessState)) return true;
|
||||
return Boolean(input.detectedProgressSummary);
|
||||
}
|
||||
|
||||
export function buildSuccessfulRunHandoffInstruction(input: {
|
||||
issueIdentifier: string | null;
|
||||
sourceRunId: string;
|
||||
}) {
|
||||
const issueLabel = input.issueIdentifier ?? "this issue";
|
||||
return [
|
||||
`Your previous run on ${issueLabel} succeeded, but the issue is still in \`in_progress\` and Paperclip cannot identify a valid issue disposition.`,
|
||||
"",
|
||||
"Resolve the missing disposition before creating or revising any new artifacts. Choose **exactly one** outcome and perform the matching Paperclip action:",
|
||||
"",
|
||||
"**Is the issue finished?**",
|
||||
"1. Mark it `done` (scope complete) or `cancelled` (intentionally stopped).",
|
||||
"",
|
||||
"**Does someone else need to look at it?**",
|
||||
"2. Move it to `in_review` with a real reviewer path — `executionState.currentParticipant`, a human owner via `assigneeUserId`, a pending issue-thread interaction, or a linked pending approval.",
|
||||
"",
|
||||
"**Can it not continue right now?**",
|
||||
"3. Mark it `blocked` with first-class blockers (`blockedByIssueIds`) or a clearly named unblock owner/action.",
|
||||
"",
|
||||
"**Is there more work to do?**",
|
||||
`4. Either delegate follow-up work (create/link a follow-up issue and block this one on it, or close this issue if its scope is independently complete) or record an explicit continuation path with \`resumeIntent: true\`, \`resumeFromRunId: ${input.sourceRunId}\`, and a concrete next action.`,
|
||||
"",
|
||||
"Comments, document revisions, work-product writes, and continuation summaries are supporting evidence only — they do not satisfy this handoff unless the issue state/path also records one valid disposition.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function decideSuccessfulRunHandoff(input: {
|
||||
run: HeartbeatRunRow;
|
||||
issue: IssueRow | null;
|
||||
agent: AgentRow | null;
|
||||
livenessState: RunLivenessState | null;
|
||||
detectedProgressSummary: string | null;
|
||||
taskKey: string | null;
|
||||
hasActiveExecutionPath: boolean;
|
||||
hasQueuedWake: boolean;
|
||||
hasPendingInteractionOrApproval: boolean;
|
||||
hasExplicitBlockerPath: boolean;
|
||||
hasOpenRecoveryIssue: boolean;
|
||||
hasPauseHold: boolean;
|
||||
budgetBlocked: boolean;
|
||||
idempotentWakeExists: boolean;
|
||||
}): SuccessfulRunHandoffDecision {
|
||||
const { run, issue, agent } = input;
|
||||
|
||||
if (run.status !== "succeeded") return { kind: "skip", reason: "source run did not succeed" };
|
||||
if (isCorrectiveHandoffRun(run)) return { kind: "skip", reason: "source run is already a corrective handoff run" };
|
||||
if (isIssueMonitorMaintenanceRun(run)) return { kind: "skip", reason: "issue monitor run owns its own recovery path" };
|
||||
if (run.issueCommentStatus === "retry_queued" || run.issueCommentStatus === "retry_exhausted") {
|
||||
return { kind: "skip", reason: "missing issue comment retry owns the next action" };
|
||||
}
|
||||
if (!issue) return { kind: "skip", reason: "issue not found" };
|
||||
if (!agent) return { kind: "skip", reason: "agent not found" };
|
||||
if (issue.companyId !== run.companyId || agent.companyId !== run.companyId) {
|
||||
return { kind: "skip", reason: "company scope mismatch" };
|
||||
}
|
||||
if (issue.assigneeAgentId !== run.agentId) {
|
||||
return { kind: "skip", reason: "issue is no longer assigned to the source run agent" };
|
||||
}
|
||||
if (issue.assigneeUserId) return { kind: "skip", reason: "issue is human-owned" };
|
||||
if (issue.status !== "in_progress") return { kind: "skip", reason: `issue status ${issue.status} is a valid disposition` };
|
||||
if (issue.executionState) return { kind: "skip", reason: "issue has execution policy state" };
|
||||
if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") {
|
||||
return { kind: "skip", reason: `agent status ${agent.status} is not invokable` };
|
||||
}
|
||||
if (!isProductiveSuccessfulRun(input)) {
|
||||
return { kind: "skip", reason: "successful run did not produce handoff-relevant progress" };
|
||||
}
|
||||
if (input.hasActiveExecutionPath) return { kind: "skip", reason: "issue already has an active execution path" };
|
||||
if (input.hasQueuedWake) return { kind: "skip", reason: "issue already has a queued or deferred wake" };
|
||||
if (input.hasPendingInteractionOrApproval) {
|
||||
return { kind: "skip", reason: "pending interaction or approval owns the next action" };
|
||||
}
|
||||
if (input.hasExplicitBlockerPath) return { kind: "skip", reason: "explicit blocker path owns the next action" };
|
||||
if (input.hasOpenRecoveryIssue) return { kind: "skip", reason: "open recovery issue owns the ambiguity" };
|
||||
if (input.hasPauseHold) return { kind: "skip", reason: "issue is under an active pause hold" };
|
||||
if (input.budgetBlocked) return { kind: "skip", reason: "budget hard stop blocks corrective wake" };
|
||||
if (input.idempotentWakeExists) {
|
||||
return { kind: "skip", reason: "corrective handoff wake already exists for this source run" };
|
||||
}
|
||||
|
||||
const instruction = buildSuccessfulRunHandoffInstruction({
|
||||
issueIdentifier: issue.identifier,
|
||||
sourceRunId: run.id,
|
||||
});
|
||||
const payload = withRecoveryModelProfileHint({
|
||||
issueId: issue.id,
|
||||
taskId: issue.id,
|
||||
sourceIssueId: issue.id,
|
||||
sourceRunId: run.id,
|
||||
handoffRequired: true,
|
||||
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
|
||||
missingDisposition: "clear_next_step",
|
||||
validDispositionOptions: [...SUCCESSFUL_RUN_HANDOFF_OPTIONS],
|
||||
detectedProgressSummary: input.detectedProgressSummary,
|
||||
handoffAttempt: 1,
|
||||
maxHandoffAttempts: DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
|
||||
resumeIntent: true,
|
||||
followUpRequested: true,
|
||||
resumeFromRunId: run.id,
|
||||
...(input.taskKey ? { taskKey: input.taskKey } : {}),
|
||||
instruction,
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "enqueue",
|
||||
idempotencyKey: buildFinishSuccessfulRunHandoffIdempotencyKey({
|
||||
issueId: issue.id,
|
||||
sourceRunId: run.id,
|
||||
}),
|
||||
payload,
|
||||
instruction,
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
...payload,
|
||||
wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||
livenessState: input.livenessState,
|
||||
}),
|
||||
};
|
||||
}
|
||||
+710
-115
File diff suppressed because it is too large
Load Diff
+1888
-73
File diff suppressed because it is too large
Load Diff
@@ -51,6 +51,7 @@ export interface ExecutionWorkspaceIssueRef {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
title: string | null;
|
||||
workMode?: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspaceAgentRef {
|
||||
@@ -108,6 +109,11 @@ interface RuntimeServiceRecord extends RuntimeServiceRef {
|
||||
processGroupId: number | null;
|
||||
}
|
||||
|
||||
type StoppedRuntimeServiceReuseCandidate = {
|
||||
id: string;
|
||||
port: number | null;
|
||||
};
|
||||
|
||||
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
|
||||
const runtimeServicesByReuseKey = new Map<string, string>();
|
||||
const runtimeServiceLeasesByRun = new Map<string, string[]>();
|
||||
@@ -707,6 +713,7 @@ function buildWorkspaceCommandEnv(input: {
|
||||
env.PAPERCLIP_ISSUE_ID = input.issue?.id ?? "";
|
||||
env.PAPERCLIP_ISSUE_IDENTIFIER = input.issue?.identifier ?? "";
|
||||
env.PAPERCLIP_ISSUE_TITLE = input.issue?.title ?? "";
|
||||
env.PAPERCLIP_ISSUE_WORK_MODE = input.issue?.workMode ?? "";
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -1815,6 +1822,33 @@ async function persistRuntimeServiceRecord(db: Db | undefined, record: RuntimeSe
|
||||
});
|
||||
}
|
||||
|
||||
async function findStoppedRuntimeServiceReuseCandidate(input: {
|
||||
db?: Db;
|
||||
companyId: string;
|
||||
reuseKey: string | null;
|
||||
}): Promise<StoppedRuntimeServiceReuseCandidate | null> {
|
||||
if (!input.db || !input.reuseKey) return null;
|
||||
const row = await input.db
|
||||
.select({
|
||||
id: workspaceRuntimeServices.id,
|
||||
port: workspaceRuntimeServices.port,
|
||||
})
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceRuntimeServices.companyId, input.companyId),
|
||||
eq(workspaceRuntimeServices.reuseKey, input.reuseKey),
|
||||
eq(workspaceRuntimeServices.provider, "local_process"),
|
||||
eq(workspaceRuntimeServices.status, "stopped"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(workspaceRuntimeServices.updatedAt))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
function clearIdleTimer(record: RuntimeServiceRecord) {
|
||||
if (!record.idleTimer) return;
|
||||
clearTimeout(record.idleTimer);
|
||||
@@ -1927,9 +1961,20 @@ async function startLocalRuntimeService(input: {
|
||||
const serviceIdentityFingerprint = input.reuseKey ?? envFingerprint;
|
||||
const explicitPort = identity.explicitPort;
|
||||
const identityPort = identity.identityPort;
|
||||
const stoppedReuseCandidate = await findStoppedRuntimeServiceReuseCandidate({
|
||||
db: input.db,
|
||||
companyId: input.agent.companyId,
|
||||
reuseKey: input.reuseKey,
|
||||
});
|
||||
const reusableStoppedPort =
|
||||
asString(portConfig.type, "") === "auto" && stoppedReuseCandidate?.port
|
||||
? (await readLocalServicePortOwner(stoppedReuseCandidate.port))
|
||||
? null
|
||||
: stoppedReuseCandidate.port
|
||||
: null;
|
||||
const port =
|
||||
asString(portConfig.type, "") === "auto"
|
||||
? await allocatePort()
|
||||
? (reusableStoppedPort ?? await allocatePort())
|
||||
: explicitPort > 0
|
||||
? explicitPort
|
||||
: null;
|
||||
@@ -2073,7 +2118,7 @@ async function startLocalRuntimeService(input: {
|
||||
}
|
||||
|
||||
const record: RuntimeServiceRecord = {
|
||||
id: randomUUID(),
|
||||
id: stoppedReuseCandidate?.id ?? randomUUID(),
|
||||
companyId: input.agent.companyId,
|
||||
projectId: input.workspace.projectId,
|
||||
projectWorkspaceId: input.workspace.workspaceId,
|
||||
|
||||
Reference in New Issue
Block a user