mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## Thinking Path > - Paperclip manages agent work and the skills that agents use. > - Project repositories create durable import jobs, and installed sources can refresh automatically. > - The server queried these records every five seconds, even when no work existed. > - Idle tenant databases need to stop receiving those discovery queries. > - This change uses the existing transaction notifications and delivery coordinator. > - Saved refresh, retry, and lease deadlines determine when the worker runs next. > - Cloud does not yet wake these deadlines, so outstanding skill work retains the instance. ## Linked Issues or Issue Description **What existing behavior does this improve?** Skill-source background synchronization. This continues the idle-work migrations in #15625 and #17000. A search found no overlapping open polling-removal PR. **Current behavior** On master `b11010b0d4`, startup installs a five-second skill-source interval and also calls it from the external-object scheduler. Each sweep queries active companies, import capacity, pending imports, and automatic refreshes. **Proposed behavior** Run at startup, after a relevant transaction settles, or at a saved deadline. Stay SQL-quiet when no eligible work remains. Preserve import admission, authorization, atomic claims, retry backoff, and the existing five-minute upstream check. **Reason and benefit** Remove recurring database activity from inactive tenant instances without requiring new tables or a new scheduler. ## What Changed - Replace both recurring skill-sync calls with one delivery-coordinator registration. - Notify from attachment/detachment, source creation/settings/manual refresh/disconnection, project/company deletion, and company status changes, including budget resume. - Restore the earliest eligible `nextSyncAt` or lease-expiry deadline. Due backlogs and conflicts retry after five seconds; no idle recovery cadence remains. - Admit deferred imports again after a batch frees capacity. Preserve requests through drain, standby, restart, and commits during a sweep. - Add sleep-safety predicates for imports and eligible automatic sources until Cloud supports these deadlines. - Document the lifecycle and process-local writer contract. No schema or API changes. - Guard external test database URLs before migrations or cleanup: require a `_test` database name and reject database/options overrides. ## Verification - 147 targeted tests pass: 39 server-startup tests, 14 new real-PostgreSQL lifecycle tests, 20 shared coordinator/scheduler tests, 63 existing import/refresh/retry regression tests, and 11 test-database guard cases. - CI exposed a stale startup mock without the new `start` method. The fixture is updated and all 39 startup tests pass locally. - Tests exercise real enqueue/settings/company-reactivation producers, nested commit, rollback, saved retry/lease restart, manual refresh failure, capacity admission, concurrent commit, shutdown cancellation, and SQL quiet after startup or pause. - Sleep-safety tests reject sleep for future imports and automatic refreshes, and accept inactive retained state. - `pnpm -r typecheck` and `pnpm build` pass. - `pnpm test:run` was attempted and stopped after repeated embedded-PostgreSQL startup failures in unrelated suites. The two existing import/refresh suites initially skipped for the same host limitation; after adding explicit disposable-database options, all 63 regression tests passed against PostgreSQL 17. Full CI passed on `ddb3ee51369baa8c7153bae9fd88efb8b903f2bc`: 55 successful checks, one neutral review check, and two intentional skips. ## Risks - Notifications are process-local. Independent SQL writers and separate live replicas do not wake this worker; startup reconstructs persisted work. - Enabled automatic sources still contact GitHub every five minutes. Their upstream changes have no event ingress here. - Cloud scheduled wakes currently cover routines and issue monitors, not skill sync. This change retains instances with outstanding skill obligations. It does not claim measured host/database suspension or fleet savings. - No change to source authorization, installed content on failure, or existing claim semantics. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository tools, and code execution. The host does not expose an exact model revision or context-window size to this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
469 lines
41 KiB
TypeScript
469 lines
41 KiB
TypeScript
import { DELIVERY_QUEUES, notifyDeliveryWork } from './delivery-work-notifications.js';
|
||
import { reserveAutomaticSkillWork, settleAutomaticSkillWork } from './skill-source-refresh-budget.js';
|
||
import { skillSourceRepairAction } from './skill-source-problems.js';
|
||
import { listSkillSourceBranches } from './skill-source-branches.js';
|
||
import { createHash, randomUUID } from 'node:crypto';
|
||
import { skillSourceRetryAt } from './skill-source-retry.js';
|
||
import path from 'node:path';
|
||
import { promises as fs } from 'node:fs';
|
||
import { folderService } from './folders.js';
|
||
import { and, eq, isNull, lt, or, sql } from 'drizzle-orm';
|
||
import { companySkillSources as sources, companySkillSourceEntries as entries, companySkills, companySkillRepositorySnapshots, projectSkillSources, projects, type Db, authUsers } from '@paperclipai/db';
|
||
import type { CompanySkill, SkillSource, SkillSourceCreateRequest, SkillSourceDiscoveryRequest, SkillSourceSelectionRequest, SkillSourceRefreshResult, SkillSourcePreviewRequest, SkillSourceSettingsRequest, SkillSourceDisconnectRequest, SkillSourceDisconnectResult, SkillSourceBranchesQuery } from '@paperclipai/shared';
|
||
import { normalizeAgentUrlKey } from '@paperclipai/shared';
|
||
import { conflict, notFound, unprocessable } from '../errors.js';
|
||
import { companySkillService, parseSkillImportSourceInput } from './company-skills.js';
|
||
import { scanGitHubSkillsWithPackageFallback as scanGitHubSkills, previewGitHubSkillFile, type GitHubRead, type ScannedSkillSource, type SkillScanOptions } from './github-skill-source.js';
|
||
import { skillAccessService } from './skill-access.js';
|
||
import { skillSnapshotHash } from './skill-snapshot.js';
|
||
|
||
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||
type SourceRow = typeof sources.$inferSelect;
|
||
export type SkillSourceContext = {
|
||
actor: { type: 'user' | 'agent' | 'system'; userId?: string | null; agentId?: string | null };
|
||
setup?: { userId: string | null; agentId?: string | null; localTrusted: boolean; grantId?: string | null };
|
||
projectBindingId?: string;
|
||
projectBindingLeaseToken?: string;
|
||
read: (connectionId: string | null) => GitHubRead;
|
||
authorize: (action: 'skills.import' | 'skills.update' | 'skills.edit' | 'skills.remove' | 'skills.create', resource: { skillId?: string; skillKey?: string; sourceType: 'git'; sourceLocator: string }) => Promise<void>;
|
||
audit: (tx: Tx, sourceId: string, action: string, details: Record<string, unknown>) => Promise<void>;
|
||
publishCommittedActivity?: () => void;
|
||
};
|
||
const scope = (companyId: string, id: string) => and(eq(sources.companyId, companyId), eq(sources.id, id));
|
||
|
||
export function skillSourceService(db: Db) {
|
||
const skills = companySkillService(db);
|
||
async function row(companyId: string, id: string, database: Db | Tx = db) {
|
||
const [source] = await database.select().from(sources).where(scope(companyId, id));
|
||
if (!source || source.removedAt) throw notFound('Skill source not found.');
|
||
return source;
|
||
}
|
||
async function detail(companyId: string, id: string, database: Db | Tx = db): Promise<SkillSource> {
|
||
await skillAccessService(database as Db).assertSource(companyId, id);
|
||
const { leaseToken, leaseExpiresAt, createdAt: _created, removedAt: _removed, refreshUserId: _user, refreshAgentId: _agent, refreshLocalTrusted: _local, refreshGrantId: _grant, failureCount: _failures, nextSyncAt: _next, lastScanTreeSha: _tree, automaticBudgetBytes: _bytes, automaticBudgetAt: _budgetAt, ...source } = await row(companyId, id, database);
|
||
const permitted = await skillAccessService(database as Db).projectIds(companyId, id);
|
||
const associated = await database.select({ id: projects.id, name: projects.name, visibility: projects.visibility, active: projectSkillSources.active })
|
||
.from(projectSkillSources).innerJoin(projects, eq(projects.id, projectSkillSources.projectId))
|
||
.where(and(eq(projectSkillSources.companyId, companyId), eq(projectSkillSources.sourceId, id)));
|
||
const visibleProjects = new Map<string, typeof associated[number]>();
|
||
for (const project of associated) {
|
||
if (!permitted.some(binding => binding.projectId === project.id)) continue;
|
||
const previous = visibleProjects.get(project.id);
|
||
visibleProjects.set(project.id, { ...project, active: project.active || Boolean(previous?.active) });
|
||
}
|
||
const refreshing = leaseToken && leaseExpiresAt && leaseExpiresAt > new Date();
|
||
const trackingActive = source.accessScope === 'company' || associated.some(project => project.active);
|
||
const selected = await database.select().from(entries).where(and(eq(entries.companyId, companyId), eq(entries.sourceId, id))).orderBy(entries.path);
|
||
return { ...source, repairAction: skillSourceRepairAction(source.lastError), trackingActive, entries: selected, projects: [...visibleProjects.values()].map(project => ({ ...project, visibility: project.visibility as 'open' | 'private' })),
|
||
syncState: !source.enabled || !source.automaticUpdates || !trackingActive ? 'paused' : refreshing ? 'refreshing' : source.lastError ? 'error' : source.lastSuccessAt ? 'ready' : 'pending' };
|
||
}
|
||
async function list(companyId: string) {
|
||
const rows = await db.select({ id: sources.id }).from(sources).where(and(eq(sources.companyId, companyId), isNull(sources.removedAt))).orderBy(sources.fullName);
|
||
const visible = await skillAccessService(db).visibleSourceIds(companyId);
|
||
return Promise.all(rows.filter(source => visible === null || visible.includes(source.id)).map(source => detail(companyId, source.id)));
|
||
}
|
||
async function sourceForSkill(companyId: string, skillId: string) {
|
||
const [entry] = await db.select().from(entries).where(and(eq(entries.companyId, companyId), eq(entries.skillId, skillId)));
|
||
return entry ? detail(companyId, entry.sourceId) : null;
|
||
}
|
||
async function discover(input: SkillSourceDiscoveryRequest, context: SkillSourceContext, options?: SkillScanOptions) {
|
||
await context.authorize('skills.import', { sourceType: 'git', sourceLocator: input.repositoryUrl });
|
||
const { skills: _files, repositoryFiles: _repositoryFiles, repositorySubset: _subset, defaultBranch: _defaultBranch, treeSha: _tree, downloadBytes: _download, scanBytes: _scan, ...result } = await scanGitHubSkills(input, context.read(input.connectionId ?? null), { ...options, retainFiles: false });
|
||
return result;
|
||
}
|
||
async function preview(input: SkillSourcePreviewRequest, context: SkillSourceContext) {
|
||
await context.authorize('skills.import', { sourceType: 'git', sourceLocator: input.repositoryUrl });
|
||
return previewGitHubSkillFile(input, context.read(input.connectionId ?? null));
|
||
}
|
||
async function authorizeScan(source: SourceRow, scan: ScannedSkillSource, selectedPaths: string[], context: SkillSourceContext) {
|
||
const previous = await db.select().from(entries).where(and(eq(entries.companyId, source.companyId), eq(entries.sourceId, source.id)));
|
||
for (const candidate of scan.skills.filter(candidate => selectedPaths.includes(candidate.path) && !candidate.error)) {
|
||
const old = previous.find(entry => entry.path === candidate.path);
|
||
const skill = old?.skillId ? await skills.getById(source.companyId, old.skillId) : null;
|
||
await context.authorize(skill ? 'skills.update' : 'skills.import', {
|
||
sourceType: 'git', sourceLocator: source.repositoryUrl, ...(skill ? { skillId: skill.id, skillKey: skill.key } : { skillKey: newSkillKey(scan, candidate.path, candidate.name, source.id, source.revision) }),
|
||
});
|
||
}
|
||
}
|
||
async function authorizeSelection(source: SourceRow, selectedPaths: string[], context: SkillSourceContext) {
|
||
for (const entry of (await detail(source.companyId, source.id)).entries) {
|
||
if (!entry.skillId || (entry.selection === 'selected') === selectedPaths.includes(entry.path)) continue;
|
||
const skill = await skills.getById(source.companyId, entry.skillId);
|
||
if (skill) await context.authorize('skills.edit', { sourceType: 'git', sourceLocator: source.repositoryUrl, skillId: skill.id, skillKey: skill.key });
|
||
}
|
||
}
|
||
function newSkillKey(scan: ScannedSkillSource, skillPath: string, name: string, sourceId: string, revision = 0) {
|
||
// New imports after removal must not overwrite retained editable copies.
|
||
// Existing entries keep their installed identity across later revisions.
|
||
const identity = createHash('sha256').update(`${scan.repositoryId}:${sourceId}${revision ? `:${revision}` : ''}:${skillPath}`).digest('hex').slice(0, 16);
|
||
return `github/${scan.repositoryId}/${identity}/${normalizeAgentUrlKey(name) || 'skill'}`;
|
||
}
|
||
async function publish(tx: Tx, source: SourceRow, scan: ScannedSkillSource, selectedPaths: string[], excludedFolders: string[], context: SkillSourceContext, selectionReviewed: boolean): Promise<Omit<SkillSourceRefreshResult, 'source'>> {
|
||
await tx.execute(sql`SET CONSTRAINTS paperclip_skill_runtime_changed DEFERRED`);
|
||
const mode = scan.packageMode ?? 'skills';
|
||
if (mode === 'repository' && scan.skills.length > 0 && (scan.repositoryPackage?.error || !scan.repositoryFiles?.length)) {
|
||
throw unprocessable(scan.repositoryPackage?.error ?? 'Repository package has no files. Scan it again before importing.');
|
||
}
|
||
let repositorySnapshotId: string | undefined;
|
||
const repositoryHash = mode === 'repository' && scan.repositoryFiles?.length && !scan.repositoryPackage?.error ? skillSnapshotHash(scan.repositoryFiles) : null;
|
||
if (repositoryHash) {
|
||
await tx.insert(companySkillRepositorySnapshots).values({ companyId: source.companyId, contentHash: repositoryHash, fileInventory: scan.repositoryFiles! })
|
||
.onConflictDoNothing();
|
||
const [snapshot] = await tx.select({ id: companySkillRepositorySnapshots.id }).from(companySkillRepositorySnapshots).where(and(
|
||
eq(companySkillRepositorySnapshots.companyId, source.companyId), eq(companySkillRepositorySnapshots.contentHash, repositoryHash)));
|
||
repositorySnapshotId = snapshot!.id;
|
||
}
|
||
const previous = await tx.select().from(entries).where(and(eq(entries.companyId, source.companyId), eq(entries.sourceId, source.id)));
|
||
const imported: CompanySkill[] = [], updated: CompanySkill[] = [];
|
||
const warnings = [...new Set([...scan.warnings, ...(scan.repositoryPackage?.warnings ?? [])])];
|
||
let unchanged = 0;
|
||
const selected = new Set(selectedPaths);
|
||
const scanned = new Set(scan.skills.map(candidate => candidate.path));
|
||
for (const chosen of selected) {
|
||
if (!scanned.has(chosen) && !previous.some(entry => entry.path === chosen)) throw unprocessable('A selected skill was not found in this repository scan.');
|
||
}
|
||
for (const candidate of scan.skills) {
|
||
const old = previous.find(entry => entry.path === candidate.path);
|
||
const selection = selected.has(candidate.path) ? 'selected' as const
|
||
: (selectionReviewed && (Boolean(old) || source.revision === 0)) || old?.selection === 'excluded' || excludedFolders.some(folder => !folder || candidate.path.startsWith(`${folder}/`)) ? 'excluded' as const : 'new' as const;
|
||
let skillId = old?.skillId ?? null;
|
||
const current = skillId ? await skills.getById(source.companyId, skillId, tx) : null;
|
||
if (selection === 'selected' && !candidate.error) {
|
||
const hash = repositoryHash ? createHash('sha256').update(`${repositoryHash}:${candidate.path}`).digest('hex') : skillSnapshotHash(candidate.files);
|
||
const slug = normalizeAgentUrlKey(candidate.name) || 'skill';
|
||
const key = current?.key ?? newSkillKey(scan, candidate.path, candidate.name, source.id, source.revision);
|
||
const ownerRepo = scan.fullName.split('/');
|
||
const metadata = { ...(current?.metadata ?? {}), sourceKind: 'github', hostname: 'github.com',
|
||
owner: ownerRepo[0], repo: ownerRepo[1], ref: current?.metadata?.snapshotHash === hash && current.currentVersionId ? current.sourceRef : scan.commitSha, trackingRef: scan.trackingRef,
|
||
repoSkillDir: path.posix.dirname(candidate.path) === '.' ? '' : path.posix.dirname(candidate.path),
|
||
skillSourceId: source.id, skillSourcePath: candidate.path, skillSourceState: 'synced', snapshotHash: hash, packageMode: mode };
|
||
const trustFiles = mode === 'repository' ? scan.repositoryFiles! : candidate.files;
|
||
const values = { name: candidate.name, description: candidate.description, markdown: candidate.files.find(file => file.path === 'SKILL.md')!.content,
|
||
accessSourceId: source.accessScope === 'projects' ? source.id : current?.accessSourceId ?? null,
|
||
sourceType: 'github', sourceLocator: scan.repositoryUrl, sourceRef: current?.metadata?.snapshotHash === hash && current.currentVersionId ? current.sourceRef : scan.commitSha,
|
||
fileInventory: candidate.files.map(({ path, kind }) => ({ path, kind })),
|
||
trustLevel: trustFiles.some(file => file.kind === 'script' || file.executable) ? 'scripts_executables' : trustFiles.some(file => file.kind === 'asset' || file.kind === 'other') ? 'assets' : 'markdown_only',
|
||
metadata, updatedAt: new Date() };
|
||
const changed = !current?.currentVersionId || current.metadata?.snapshotHash !== hash;
|
||
if (current && changed) await tx.update(companySkills).set(values).where(and(eq(companySkills.companyId, source.companyId), eq(companySkills.id, current.id)));
|
||
else if (!current) {
|
||
const [created] = await tx.insert(companySkills).values({ ...values, companyId: source.companyId, key, slug, installCount: 1 }).returning({ id: companySkills.id });
|
||
skillId = created!.id;
|
||
}
|
||
else if (current.metadata?.skillSourceState !== 'synced' || current.trustLevel !== values.trustLevel) {
|
||
await tx.update(companySkills).set({ metadata, trustLevel: values.trustLevel }).where(and(eq(companySkills.companyId, source.companyId), eq(companySkills.id, current.id)));
|
||
}
|
||
const installed = (await skills.getById(source.companyId, skillId!, tx))!;
|
||
if (changed) {
|
||
const version = await skills.createVersion(source.companyId, skillId!, { label: `GitHub ${scan.commitSha.slice(0, 8)}` }, context.actor, {
|
||
database: tx, skipInventoryRefresh: true, skill: installed, fileInventory: candidate.files,
|
||
...(repositorySnapshotId ? { repositorySnapshotId, repositorySkillPath: candidate.path } : {}),
|
||
});
|
||
installed.currentVersionId = version.id;
|
||
(current ? updated : imported).push(installed);
|
||
} else unchanged++;
|
||
} else if (current) {
|
||
await tx.update(companySkills).set({ metadata: { ...current.metadata, skillSourceId: source.id, skillSourcePath: candidate.path,
|
||
skillSourceState: selection !== 'selected' ? 'not_syncing' : 'update_failed' } }).where(and(eq(companySkills.companyId, source.companyId), eq(companySkills.id, current.id)));
|
||
}
|
||
if (candidate.error && selection === 'selected') warnings.push(`${candidate.path}: ${candidate.error}`);
|
||
if (selection === 'selected') {
|
||
warnings.push(...(candidate.inspection?.warnings.filter(warning => warning.includes('not included') || warning.includes('couldn’t be included')) ?? []));
|
||
warnings.push(...(candidate.inspection?.references.map(reference => `${reference.resolvedPath} — Referenced by ${candidate.path}; not included.`) ?? []));
|
||
}
|
||
await tx.insert(entries).values({ companyId: source.companyId, sourceId: source.id, path: candidate.path, name: candidate.name,
|
||
description: candidate.description, inspection: candidate.inspection ?? null, skillId, selection, present: true, error: candidate.error })
|
||
.onConflictDoUpdate({ target: [entries.sourceId, entries.path], set: { name: candidate.name, description: candidate.description, inspection: candidate.inspection ?? null, skillId, selection, present: true, error: candidate.error } });
|
||
}
|
||
for (const old of previous.filter(entry => !scanned.has(entry.path))) {
|
||
await tx.update(entries).set({ present: false, selection: selected.has(old.path) ? 'selected' : 'excluded', error: null }).where(eq(entries.id, old.id));
|
||
if (old.skillId) {
|
||
const current = await skills.getById(source.companyId, old.skillId, tx);
|
||
if (current) await tx.update(companySkills).set({ metadata: { ...current.metadata, skillSourceId: source.id, skillSourcePath: old.path, skillSourceState: 'removed' } }).where(and(eq(companySkills.companyId, source.companyId), eq(companySkills.id, old.skillId)));
|
||
}
|
||
}
|
||
// HEAD remains the default-branch alias across refreshes and branch renames.
|
||
await tx.update(sources).set({ repositoryId: scan.repositoryId, repositoryUrl: scan.repositoryUrl, fullName: scan.fullName,
|
||
packageMode: mode, repositoryPackage: scan.repositoryPackage ?? null,
|
||
trackingRef: source.trackingRef === 'HEAD' ? 'HEAD' : scan.trackingRef, excludedFolders, lastSuccessAt: new Date(), lastScanCommit: scan.commitSha, lastScanTreeSha: scan.treeSha ?? null, lastError: null,
|
||
revision: source.revision + 1, leaseToken: null, leaseExpiresAt: null, failureCount: 0, nextSyncAt: new Date(Date.now() + 5 * 60_000) }).where(scope(source.companyId, source.id));
|
||
await context.audit(tx, source.id, 'company.skill_source_refreshed', { commit: scan.commitSha, importedCount: imported.length, updatedCount: updated.length, unchanged, warningCount: warnings.length });
|
||
return { imported, updated, unchanged, warnings: [...new Set(warnings)] };
|
||
}
|
||
async function create(companyId: string, input: SkillSourceCreateRequest, context: SkillSourceContext, staged?: ScannedSkillSource) {
|
||
await context.authorize('skills.import', { sourceType: 'git', sourceLocator: input.repositoryUrl });
|
||
const reader = context.read(input.connectionId ?? null);
|
||
const scan = staged ?? await scanGitHubSkills(input, reader);
|
||
if (!context.projectBindingId && scan.trackingRef === scan.defaultBranch && (await list(companyId)).some(source => source.repositoryUrl.toLowerCase() === scan.repositoryUrl.toLowerCase() && source.trackingRef === 'HEAD')) {
|
||
throw conflict('This repository is already in Sources. Manage its skills there.');
|
||
}
|
||
const id = randomUUID();
|
||
// New imports are authorized per generated skill identity too, before publishing.
|
||
for (const candidate of scan.skills.filter(candidate => input.selectedPaths.includes(candidate.path) && !candidate.error)) {
|
||
await context.authorize('skills.import', { sourceType: 'git', sourceLocator: scan.repositoryUrl,
|
||
skillKey: newSkillKey(scan, candidate.path, candidate.name, id) });
|
||
}
|
||
const published = await db.transaction(async tx => {
|
||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||
const [source] = await tx.insert(sources).values({ id, companyId, repositoryId: scan.repositoryId, repositoryUrl: scan.repositoryUrl, fullName: scan.fullName,
|
||
packageMode: scan.packageMode ?? 'skills', trackingRef: context.projectBindingId && input.trackingRef === 'HEAD' ? 'HEAD' : scan.trackingRef, connectionId: scan.connectionId ?? input.connectionId ?? null, lastAttemptAt: new Date(),
|
||
accessScope: context.projectBindingId ? 'projects' : 'company', importNewSkills: Boolean(context.projectBindingId),
|
||
refreshUserId: context.setup?.userId, refreshAgentId: context.setup?.agentId, refreshLocalTrusted: context.setup?.localTrusted ?? false, refreshGrantId: reader.grantId ?? context.setup?.grantId }).onConflictDoNothing().returning();
|
||
if (!source) throw conflict('This repository and branch are already in Sources. Manage its skills there.');
|
||
if (context.projectBindingId) {
|
||
const [binding] = await tx.select().from(projectSkillSources).where(and(eq(projectSkillSources.companyId, companyId), eq(projectSkillSources.id, context.projectBindingId))).for('update');
|
||
if (!binding?.active || (context.projectBindingLeaseToken && binding.leaseToken !== context.projectBindingLeaseToken)) throw conflict('Project repository changed during import.');
|
||
await tx.update(projectSkillSources).set({ sourceId: source.id, status: 'ready', lastError: null }).where(eq(projectSkillSources.id, binding.id));
|
||
}
|
||
const result = await publish(tx, source, scan, input.selectedPaths, input.excludedFolders ?? [], context, true);
|
||
return { ...result, source: await detail(companyId, id, tx) };
|
||
});
|
||
context.publishCommittedActivity?.();
|
||
return published;
|
||
}
|
||
async function refresh(companyId: string, id: string, context: SkillSourceContext, selection?: SkillSourceSelectionRequest, staged?: ScannedSkillSource, options: { checkCommit?: boolean; automatic?: boolean; now?: Date; signal?: AbortSignal } = {}) {
|
||
let source = await row(companyId, id);
|
||
if (!source.enabled && !selection) throw conflict('This source is disconnected. Save its selection to reconnect.');
|
||
await context.authorize(selection ? 'skills.edit' : 'skills.update', { sourceType: 'git', sourceLocator: source.repositoryUrl });
|
||
if (selection) await authorizeSelection(source, selection.selectedPaths, context);
|
||
if (selection && selection.revision !== source.revision) throw conflict('This source changed. Reload before saving your selection.');
|
||
const token = randomUUID();
|
||
const [leased] = await db.transaction(async tx => {
|
||
if (!options.automatic) await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||
return tx.update(sources).set({ leaseToken: token, leaseExpiresAt: new Date(Date.now() + 10 * 60_000), lastAttemptAt: new Date() })
|
||
.where(and(scope(companyId, id), eq(sources.revision, source.revision), or(isNull(sources.leaseToken), lt(sources.leaseExpiresAt, new Date())))).returning();
|
||
});
|
||
if (!leased) throw conflict('This source is refreshing or changed. Try again after it finishes.');
|
||
source = leased;
|
||
try {
|
||
const connectionId = selection?.connectionId !== undefined ? selection.connectionId : source.connectionId;
|
||
const reader = context.read(connectionId);
|
||
if (options.checkCommit && source.repositoryId && source.lastScanCommit) {
|
||
const repo = await reader(`/repos/${source.fullName}`, options.signal) as { id: number; default_branch: string };
|
||
if (String(repo.id) !== source.repositoryId) throw conflict('The repository at this URL has changed identity. Add it as a new source.');
|
||
const ref = source.trackingRef === 'HEAD' ? repo.default_branch : source.trackingRef;
|
||
const head = await reader(`/repos/${source.fullName}/commits/${encodeURIComponent(ref)}`, options.signal) as { sha: string; commit?: { tree?: { sha?: string } } };
|
||
const rawTreeSha = head.commit?.tree?.sha;
|
||
const treeSha = rawTreeSha && /^[a-f0-9]{40}$/i.test(rawTreeSha) ? rawTreeSha.toLowerCase() : null;
|
||
const [legacyDependencyFailure] = await db.select({ id: entries.id }).from(entries).where(and(
|
||
eq(entries.companyId, companyId), eq(entries.sourceId, id),
|
||
sql`${entries.error} like 'This skill depends on files that its imported package cannot provide.%'`,
|
||
)).limit(1);
|
||
if (!legacyDependencyFailure && (head.sha === source.lastScanCommit || (treeSha && treeSha === source.lastScanTreeSha))) {
|
||
await db.transaction(async tx => {
|
||
if (!options.automatic) await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||
const [locked] = await tx.select().from(sources).where(scope(companyId, id)).for('update');
|
||
if (!locked || locked.leaseToken !== token || locked.revision !== source.revision) throw conflict('Source refresh changed. Try again.');
|
||
await context.audit(tx, id, 'company.skill_source_checked', { commit: head.sha, unchangedTree: head.sha !== source.lastScanCommit });
|
||
await tx.update(sources).set({ leaseToken: null, leaseExpiresAt: null, lastError: null, lastSuccessAt: new Date(), failureCount: 0, lastScanCommit: head.sha, lastScanTreeSha: treeSha ?? source.lastScanTreeSha,
|
||
connectionId: reader.connectionId ?? source.connectionId, refreshGrantId: reader.grantId ?? source.refreshGrantId,
|
||
nextSyncAt: new Date(Date.now() + 5 * 60_000) }).where(scope(companyId, id));
|
||
});
|
||
context.publishCommittedActivity?.();
|
||
return { source: await detail(companyId, id), imported: [], updated: [], unchanged: (await detail(companyId, id)).entries.filter(entry => entry.selection === 'selected').length, warnings: [] };
|
||
}
|
||
}
|
||
const packageMode = selection?.packageMode ?? (source.accessScope === 'projects' ? 'repository' : source.packageMode);
|
||
if (options.automatic) await reserveAutomaticSkillWork(db, source, token, options.now ?? new Date());
|
||
const scan = staged ?? await scanGitHubSkills({ repositoryUrl: source.repositoryUrl, trackingRef: source.trackingRef, packageMode }, reader, { signal: options.signal });
|
||
if (options.automatic) await settleAutomaticSkillWork(db, companyId, id, token,
|
||
packageMode === 'repository' && (scan.packageMode === 'skills' || scan.repositorySubset) ? {} : scan);
|
||
if (source.repositoryId && source.repositoryId !== scan.repositoryId) throw conflict('The repository at this URL has changed identity. Add it as a new source.');
|
||
const current = await detail(companyId, id);
|
||
const includedRepositoryPaths = new Set(scan.repositoryFiles?.map(file => file.path));
|
||
const omittedInstalledFiles = current.entries.some(entry => entry.skillId && current.repositoryPackage?.files.some(file => {
|
||
if (scan.repositorySubset) return !includedRepositoryPaths.has(file.path);
|
||
const root = path.posix.dirname(entry.path);
|
||
return root !== '.' && !file.path.startsWith(`${root}/`);
|
||
}));
|
||
if (source.packageMode === 'repository' && (scan.packageMode === 'skills' || scan.repositorySubset)
|
||
&& selection?.packageMode !== 'skills' && current.entries.some(entry => entry.skillId)
|
||
&& (omittedInstalledFiles || (scan.packageMode === 'skills' && scan.skills.some(skill => skill.inspection?.references.length)))) {
|
||
throw unprocessable('The repository exceeds the supported package size. Installed repository skills and their dependencies were kept. Reduce the repository package size before refreshing.');
|
||
}
|
||
const selectedPaths = selection?.selectedPaths ?? current.entries.filter(entry => entry.selection === 'selected').map(entry => entry.path);
|
||
if (!selection && source.importNewSkills) {
|
||
for (const candidate of scan.skills) {
|
||
if (current.entries.some(entry => entry.path === candidate.path) || source.excludedFolders.some(folder => !folder || candidate.path.startsWith(`${folder}/`))) continue;
|
||
selectedPaths.push(candidate.path);
|
||
}
|
||
}
|
||
await authorizeScan(source, scan, selectedPaths, context);
|
||
const published = await db.transaction(async tx => {
|
||
if (!options.automatic) await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||
const [locked] = await tx.select().from(sources).where(scope(companyId, id)).for('update');
|
||
if (!locked || locked.leaseToken !== token || locked.revision !== source.revision || locked.leaseExpiresAt! < new Date()) throw conflict('Source refresh expired or changed. Try again.');
|
||
const result = await publish(tx, locked, scan, selectedPaths, selection?.excludedFolders ?? source.excludedFolders, context, Boolean(selection));
|
||
if (selection || scan.connectionId !== undefined) await tx.update(sources).set({ ...(selection ? { enabled: true } : {}), connectionId: scan.connectionId ?? connectionId,
|
||
...(source.refreshGrantId === null && reader.grantId ? { refreshGrantId: reader.grantId } : {}),
|
||
...(selection?.connectionId !== undefined || (!source.refreshUserId && !source.refreshLocalTrusted && context.setup) ? { refreshUserId: context.setup?.userId, refreshAgentId: context.setup?.agentId ?? null, refreshLocalTrusted: context.setup?.localTrusted ?? false, refreshGrantId: reader.grantId ?? null } : {}) }).where(scope(companyId, id));
|
||
return { ...result, source: await detail(companyId, id, tx) };
|
||
});
|
||
context.publishCommittedActivity?.();
|
||
return published;
|
||
} catch (error) {
|
||
// Provider errors have already been sanitized. Never persist arbitrary exception text.
|
||
const message = error instanceof Error && 'status' in error ? error.message : 'Refresh failed. The installed skills were kept. Try again.';
|
||
await db.transaction(async tx => {
|
||
if (!options.automatic) await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||
await tx.update(sources).set({ leaseToken: null, leaseExpiresAt: null, lastError: options.signal?.aborted ? source.lastError : message, failureCount: options.signal?.aborted ? source.failureCount : source.failureCount + 1, nextSyncAt: options.signal?.aborted ? new Date() : skillSourceRetryAt(error, source.failureCount) }).where(and(scope(companyId, id), eq(sources.leaseToken, token)));
|
||
});
|
||
throw error;
|
||
}
|
||
}
|
||
async function branches(companyId: string, id: string, input: SkillSourceBranchesQuery, context: SkillSourceContext, signal?: AbortSignal) {
|
||
await skillAccessService(db).assertSource(companyId, id);
|
||
const source = await row(companyId, id);
|
||
await context.authorize('skills.edit', { sourceType: 'git', sourceLocator: source.repositoryUrl });
|
||
return listSkillSourceBranches(source, context.read(input.connectionId ?? source.connectionId), signal);
|
||
}
|
||
async function settings(companyId: string, id: string, input: SkillSourceSettingsRequest, context: SkillSourceContext) {
|
||
await skillAccessService(db).assertSource(companyId, id);
|
||
const source = await row(companyId, id);
|
||
await context.authorize('skills.edit', { sourceType: 'git', sourceLocator: source.repositoryUrl });
|
||
// Validate a replacement connection before binding unattended work to it.
|
||
let reader: GitHubRead | undefined;
|
||
if (input.trackingRef !== undefined) {
|
||
const [binding] = await db.select({ id: projectSkillSources.id }).from(projectSkillSources)
|
||
.where(and(eq(projectSkillSources.companyId, companyId), eq(projectSkillSources.sourceId, id), eq(projectSkillSources.active, true))).limit(1);
|
||
if (source.accessScope === 'projects' || binding) throw unprocessable('Update the repository branch in its project settings.');
|
||
}
|
||
if (input.connectionId !== undefined || input.trackingRef !== undefined) {
|
||
reader = context.read(input.connectionId !== undefined ? input.connectionId : source.connectionId);
|
||
const repository = await reader(`/repos/${source.fullName}`) as { id: number; default_branch: string };
|
||
if (source.repositoryId && String(repository.id) !== source.repositoryId) throw conflict('The repository at this URL has changed identity. Add it as a new source.');
|
||
const requestedRef = input.trackingRef ?? source.trackingRef;
|
||
const ref = requestedRef === 'HEAD' ? repository.default_branch : requestedRef;
|
||
if (typeof ref !== 'string' || !ref) throw unprocessable('The selected branch or revision is unavailable.', { code: 'skill_source_ref_unavailable' });
|
||
const commit = await reader(`/repos/${source.fullName}/commits/${encodeURIComponent(ref)}`) as { sha?: unknown } | null;
|
||
if (!commit || typeof commit.sha !== 'string' || !/^[a-f0-9]{40}$/i.test(commit.sha)) throw unprocessable('The selected branch or revision is unavailable.', { code: 'skill_source_ref_unavailable' });
|
||
}
|
||
return db.transaction(async tx => {
|
||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||
if (input.trackingRef !== undefined) {
|
||
// Attachment/removal lock the source before changing its project bindings.
|
||
await tx.select({ id: sources.id }).from(sources).where(scope(companyId, id)).for('update');
|
||
const [binding] = await tx.select({ id: projectSkillSources.id }).from(projectSkillSources)
|
||
.where(and(eq(projectSkillSources.companyId, companyId), eq(projectSkillSources.sourceId, id), eq(projectSkillSources.active, true))).limit(1);
|
||
if (binding) throw unprocessable('Update the repository branch in its project settings.');
|
||
const [duplicate] = await tx.select({ id: sources.id }).from(sources).where(and(
|
||
eq(sources.companyId, companyId), eq(sources.repositoryUrl, source.repositoryUrl), eq(sources.trackingRef, input.trackingRef), isNull(sources.removedAt), sql`${sources.id} <> ${id}`));
|
||
if (duplicate) throw conflict('This repository and branch are already in Sources. Use that source; installed skills were kept.');
|
||
}
|
||
const [updated] = await tx.update(sources).set({
|
||
...(input.automaticUpdates !== undefined ? { automaticUpdates: input.automaticUpdates } : {}),
|
||
...(input.importNewSkills !== undefined ? { importNewSkills: input.importNewSkills } : {}),
|
||
...(input.connectionId !== undefined ? { connectionId: reader?.connectionId ?? input.connectionId,
|
||
refreshUserId: context.setup?.userId, refreshAgentId: context.setup?.agentId ?? null,
|
||
refreshLocalTrusted: context.setup?.localTrusted ?? false, refreshGrantId: reader?.grantId ?? null, lastError: null } : {}),
|
||
...(input.trackingRef !== undefined ? { trackingRef: input.trackingRef, lastError: null, failureCount: 0 } : {}),
|
||
revision: sql`${sources.revision} + 1`, nextSyncAt: new Date(),
|
||
}).where(and(scope(companyId, id), eq(sources.revision, input.revision), or(isNull(sources.leaseToken), lt(sources.leaseExpiresAt, new Date())))).returning();
|
||
if (!updated) throw conflict('This source is refreshing or changed. Reload before saving.', { code: 'skill_source_settings_changed' });
|
||
await context.audit(tx, id, 'company.skill_source_settings_updated', { automaticUpdates: updated.automaticUpdates, importNewSkills: updated.importNewSkills, connectionChanged: input.connectionId !== undefined,
|
||
...(input.trackingRef !== undefined ? { previousRef: source.trackingRef, trackingRef: updated.trackingRef } : {}) });
|
||
return detail(companyId, id, tx);
|
||
});
|
||
}
|
||
async function disconnect(companyId: string, id: string, context: SkillSourceContext, input: SkillSourceDisconnectRequest): Promise<SkillSourceDisconnectResult> {
|
||
const source = await detail(companyId, id);
|
||
if (source.revision !== input.revision) throw conflict('This source changed. Reload before disconnecting.');
|
||
await context.authorize('skills.edit', { sourceType: 'git', sourceLocator: source.repositoryUrl });
|
||
const skillIds = [...new Set(source.entries.flatMap(entry => entry.skillId ? [entry.skillId] : []))];
|
||
for (const skillId of skillIds) {
|
||
const skill = await skills.getById(companyId, skillId);
|
||
if (!skill) continue;
|
||
const resource = { sourceType: 'git' as const, sourceLocator: source.repositoryUrl, skillId, skillKey: skill.key };
|
||
await context.authorize(input.skills === 'delete' ? 'skills.remove' : 'skills.edit', resource);
|
||
if (input.skills === 'copy') await context.authorize('skills.create', resource);
|
||
}
|
||
async function lock(tx: Tx) {
|
||
const [locked] = await tx.select().from(sources).where(scope(companyId, id)).for('update');
|
||
if (!locked || locked.revision !== input.revision) throw conflict('This source changed. Reload before disconnecting.');
|
||
}
|
||
async function remove(tx: Tx, copiedCount: number, deletedCount: number) {
|
||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||
// Stop both pending attachments and an in-flight refresh before removing tracking.
|
||
await tx.update(projectSkillSources).set({ active: false, leaseToken: null, leaseExpiresAt: null })
|
||
.where(and(eq(projectSkillSources.companyId, companyId), eq(projectSkillSources.sourceId, id)));
|
||
if (source.accessScope === 'projects') {
|
||
// Copies and forks keep this source identity as their dynamic access boundary.
|
||
await tx.delete(entries).where(and(eq(entries.companyId, companyId), eq(entries.sourceId, id)));
|
||
await tx.update(sources).set({ removedAt: new Date(), enabled: false, automaticUpdates: false,
|
||
revision: sql`${sources.revision} + 1`, leaseToken: null, leaseExpiresAt: null })
|
||
.where(scope(companyId, id));
|
||
} else {
|
||
await tx.delete(sources).where(scope(companyId, id));
|
||
}
|
||
await context.audit(tx, id, 'company.skill_source_disconnected', { skills: input.skills, skillIds, copiedCount, deletedCount });
|
||
}
|
||
if (input.skills === 'delete') {
|
||
const deleted = await skills.deleteSkills(companyId, skillIds, (tx, deleted) => remove(tx, 0, deleted.length), lock);
|
||
return { id, copiedCount: 0, deletedCount: deleted.length };
|
||
}
|
||
let folderId: string | null = null;
|
||
if (skillIds.length && context.actor.type === 'user' && context.actor.userId) {
|
||
const [user] = await db.select({ name: authUsers.name }).from(authUsers).where(eq(authUsers.id, context.actor.userId));
|
||
folderId = (await folderService(db).ensureMyFolder(companyId, context.actor.userId, user?.name ?? null)).id;
|
||
}
|
||
const directories: string[] = [];
|
||
try {
|
||
return await db.transaction(async tx => {
|
||
await tx.execute(sql`SET CONSTRAINTS paperclip_skill_runtime_changed DEFERRED`);
|
||
await lock(tx);
|
||
for (const skillId of skillIds) await skills.makeImportedSkillEditable(companyId, skillId, context.actor, folderId, tx, directories);
|
||
await remove(tx, skillIds.length, 0);
|
||
return { id, copiedCount: skillIds.length, deletedCount: 0 };
|
||
});
|
||
} catch (error) {
|
||
await Promise.all(directories.map(directory => fs.rm(directory, { recursive: true, force: true })));
|
||
throw error;
|
||
}
|
||
}
|
||
async function importFromUrl(companyId: string, input: string, context: SkillSourceContext) {
|
||
const parsed = parseSkillImportSourceInput(input);
|
||
const url = new URL(parsed.resolvedSource);
|
||
const parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent);
|
||
const repositoryUrl = `https://github.com/${parts[0]}/${parts[1]!.replace(/\.git$/i, '')}`.toLowerCase();
|
||
const previous = (await list(companyId)).filter(source => source.repositoryUrl.toLowerCase() === repositoryUrl);
|
||
let trackingRef: string | undefined;
|
||
let prefix = '';
|
||
let commitSha: string | undefined;
|
||
// Prefer a saved authorization for compatibility imports. Every read still checks this caller.
|
||
const connectionId = previous.find(source => source.enabled)?.connectionId ?? null;
|
||
const read = context.read(connectionId);
|
||
if (parts.length > 2) {
|
||
if (!['tree', 'blob'].includes(parts[2]!)) throw unprocessable('Use a GitHub repository, folder, or SKILL.md URL.');
|
||
const suffix = parts.slice(3);
|
||
// GitHub URLs do not delimit slash-containing refs. Resolve the longest valid ref.
|
||
for (let count = suffix.length; count > 0; count--) {
|
||
const candidate = suffix.slice(0, count).join('/');
|
||
try {
|
||
const commit = await read(`/repos/${parts[0]}/${parts[1]!.replace(/\.git$/i, '')}/commits/${encodeURIComponent(candidate)}`) as { sha: string };
|
||
trackingRef = candidate; commitSha = commit.sha; prefix = suffix.slice(count).join('/'); break;
|
||
} catch (error) {
|
||
if (!(error instanceof Error && 'details' in error && (error.details as { status?: number })?.status === 404)) throw error;
|
||
}
|
||
}
|
||
if (!trackingRef) throw unprocessable('GitHub branch or commit was not found.');
|
||
}
|
||
await context.authorize('skills.import', { sourceType: 'git', sourceLocator: repositoryUrl });
|
||
let scan = await scanGitHubSkills({ repositoryUrl, trackingRef, commitSha }, read);
|
||
const existing = previous.find(source => source.trackingRef === scan.trackingRef || (source.trackingRef === 'HEAD' && scan.trackingRef === scan.defaultBranch));
|
||
if (existing?.packageMode === 'repository' || existing?.accessScope === 'projects') {
|
||
scan = await scanGitHubSkills({ repositoryUrl, trackingRef: scan.trackingRef, commitSha: scan.commitSha, packageMode: 'repository' }, read);
|
||
}
|
||
const selectedPaths = scan.candidates.filter(candidate =>
|
||
(!prefix || candidate.path === prefix || candidate.path.startsWith(`${prefix}/`)) &&
|
||
(!parsed.requestedSkillSlug || normalizeAgentUrlKey(candidate.name) === parsed.requestedSkillSlug || candidate.path.split('/').at(-2) === parsed.requestedSkillSlug)
|
||
).map(candidate => candidate.path);
|
||
if (!selectedPaths.length) throw unprocessable('No matching SKILL.md files were found in this repository.');
|
||
const result = existing
|
||
? await refresh(companyId, existing.id, context, { revision: existing.revision, selectedPaths: [...new Set([...existing.entries.filter(entry => entry.selection === 'selected').map(entry => entry.path), ...selectedPaths])], excludedFolders: existing.excludedFolders }, scan)
|
||
: await create(companyId, { repositoryUrl: scan.repositoryUrl, trackingRef: scan.trackingRef, commitSha: scan.commitSha, connectionId, selectedPaths }, context, scan);
|
||
const imported = await Promise.all(result.source.entries.filter(entry => selectedPaths.includes(entry.path) && entry.skillId && !entry.error).map(entry => skills.getById(companyId, entry.skillId!)));
|
||
return { imported: imported.filter((skill): skill is CompanySkill => Boolean(skill)), warnings: result.warnings };
|
||
}
|
||
return { list, detail, sourceForSkill, discover, preview, create, refresh, branches, settings, disconnect, importFromUrl };
|
||
}
|