mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Skills can track a branch in an authorized GitHub repository. > - Sources previously offered only a default-branch repair action. > - People need to select a listed branch or enter a branch that GitHub has not listed. > - The server must validate the selected ref with the caller's authorized connection. > - This pull request connects the approved branch picker to production APIs. > - Installed skills remain usable while the source changes its tracked ref. ## Linked Issues or Issue Description **Subsystem affected** Skills UI, shared API contracts, GitHub source services, and agent API discovery. **Problem or motivation** The default-branch repair action does not let people choose another branch. The settings service also validates the old ref when a caller supplies a new ref other than HEAD. A missing tracked branch can prevent connection repair even when the selected account has repository access. **Proposed solution** Load a bounded branch list with the authorized connection. Mark the default branch. Search listed branches or accept a typed ref. Validate the selected ref before saving. Keep the chosen value after a load error, invalid ref, or concurrent update. Save the selected connection and branch together during connection repair. **Alternatives considered** A default-branch button does not support a named branch. A text field alone does not help people find the repository default or available branches. **Roadmap alignment** This improves the existing Skills source manager. It does not add a new skill import system. **Additional context** Refs #16066 and #16256. This is the production implementation of the approved branch-selection Storybook journeys. ## What Changed - Add an authorized branch-list endpoint and shared response/query types. Register it in agent API discovery. - Validate the requested ref, including the returned commit SHA. Preserve source leases, revision checks, project ownership, skill identities, selections, and installed versions. - Add the searchable branch picker to Sources and connection repair. Allow typed refs during branch discovery and after discovery fails. - Keep save errors in the dialog. Retain the selected branch through a concurrent source update. - Use the production components and API client in Storybook. Add keyboard, touch, loading, retry, invalid-ref, connection-repair, and concurrent-update browser journeys. - Update product documentation and the feature/access maps. ## Verification - Red/green: two new database regressions fail against the previous service and pass against the implementation. - Linux database/service suite: 85 tests passed. This includes source visibility, human/agent authorization, selected connections, immutable versions, and invalid refs. - Rebased branch and review fixes: 94 focused UI, route, branch-validation, and fixture tests passed. Two conflict-message regressions and the stale-retry fixture regression fail before the fix and pass after it. - Storybook build passed. All 26 focused browser journeys passed. - Repository typecheck and build passed. Final UI typecheck passed after the review fixes. - The full local Vitest run was interrupted when the checkout and its dependencies disappeared. It reported missing working-directory/Postgres files and worker startup errors. Its two timed-out tests pass after the checkout was restored. The complete CI suite passed on the final head, `cf5d4c8f0a`: [CI run](https://github.com/paperclipai/paperclip/actions/runs/38571068332). One unchanged workspace timestamp test required a single rerun. Its release-minus-one-millisecond value can equal the original confirmation time. The rerun passed. - Live acceptance passed on implementation head `5dd935bf14` in an isolated macOS arm64 instance with Node 24.21.0 and an authorized private GitHub repository. The UI loaded the actual default branch, saved branch and selected connection together, and preserved the installed skill/version IDs after reload. An invalid typed ref retained the draft and showed the provider error. A valid typed HEAD ref then saved successfully. The owned runtime and database were stopped after the test. Focused unit, Linux database, and browser suites passed again after the error-handling review fixes. - Greptile reviewed final head `cf5d4c8f0a` at 5/5. Both review threads are resolved; the fresh review has no remaining findings. - Token gates and map/catalog checks passed. - Review entry points: `Skills / Project repository skills / 03 Branch selection components` and `04 Branch selection journeys`. Run `pnpm storybook` to inspect them. - Browser command: `pnpm exec playwright test --config tests/storybook-visual/playwright.config.ts tests/storybook-visual/skill-source-branch-selection.spec.ts tests/storybook-visual/project-repository-skills.spec.ts`. - Manual check: open an independent GitHub source, choose a new branch, select the default or type a ref, save, and reload. Try an invalid ref and confirm the selection remains in the dialog. For a project-bound source, change its ref in project settings. ## Risks - GitHub discovery is bounded to 100 listed branches. The default branch is always included. Typed refs remain available. - A source can change while the dialog is open. The API rejects a stale revision and the UI keeps the draft for retry. - A ref change schedules the existing refresh pipeline. Installed versions remain available until a successful refresh imports new content. - No database migration or credential storage change is required. Existing HEAD requests remain supported. ## Model Used OpenAI Codex, GPT-6 family. The serving model's exact identifier and context-window size are not exposed in this session. Used reasoning, repository tools, code execution, and browser tools. ## 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>
451 lines
39 KiB
TypeScript
451 lines
39 KiB
TypeScript
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, rejectIncompleteSkillPackages, 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, 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'>> {
|
||
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 => {
|
||
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.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;
|
||
if (head.sha === source.lastScanCommit || (treeSha && treeSha === source.lastScanTreeSha)) {
|
||
await db.transaction(async tx => {
|
||
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);
|
||
// A project source may already be in package-only mode after initial fallback.
|
||
if (source.accessScope === 'projects' && scan.packageMode === 'skills') rejectIncompleteSkillPackages(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 omittedInstalledFiles = current.entries.some(entry => entry.skillId && current.repositoryPackage?.files.some(file => {
|
||
const root = path.posix.dirname(entry.path);
|
||
return root !== '.' && !file.path.startsWith(`${root}/`);
|
||
}));
|
||
if (source.packageMode === 'repository' && scan.packageMode === 'skills'
|
||
&& selection?.packageMode !== 'skills' && current.entries.some(entry => entry.skillId)
|
||
&& (omittedInstalledFiles || 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 => {
|
||
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.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 => {
|
||
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) {
|
||
// 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 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') {
|
||
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 };
|
||
}
|