fix(skills): pull upstream skill runtime resolution to stop event-loop starvation
Build: Production / build (push) Failing after 12m39s

The fork's listRuntimeSkillEntries rematerialized every skill's files from
the DB on every heartbeat run dispatch — fs.rm + fs.mkdir + per-file
readFile/writeFile, sequentially per skill. With 24 configured skills and
5 concurrent agents, this saturated the Node event loop badly enough that
executeRun continuations couldn't reach activeRunExecutions.add() within
the orphan-reaper's 5-min threshold, causing reaper to false-positive runs
as "process_lost".

Upstream's listRuntimeSkillEntries calls resolveRuntimeSkillSource, which
checks if the materialized directory already exists on disk and short-
circuits when it does. Fixes the symptom at the root.

Replaces these files with upstream/master content:
  - server/src/services/company-skills.ts
  - server/src/services/heartbeat.ts
  - server/src/services/workspace-runtime.ts
  - server/src/services/company-portability.ts
  - server/src/routes/company-skills.ts
  - server/src/routes/agents.ts
  - packages/adapter-utils/src/server-utils.ts

Pulls in supporting upstream files:
  - server/src/services/catalog-provenance.ts
  - server/src/services/skills-catalog.ts
  - server/src/services/github-fetch.ts
  - server/src/services/portable-path.ts
  - packages/skills-catalog/ (new package)
  - packages/db document_annotation_* schema + migration 0091
  - packages/shared document-annotation types/validators

Drops fork features (to be re-evaluated later):
  - Gitea/Forgejo git skill sources (server/src/services/git-source.ts deleted)
  - PAT support for private skill repos
  - Fork-specific secret-export portability extensions

Adds agentId: null to acquireRunLease test-probe call in routes/agents.ts
to satisfy the fork's environment-runtime agentId requirement (kept).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 09:26:51 -04:00
parent 562693197a
commit 548d958f18
52 changed files with 24613 additions and 2036 deletions
+101 -405
View File
@@ -14,7 +14,6 @@ const companySvc = {
const agentSvc = {
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
};
@@ -29,7 +28,6 @@ const accessSvc = {
const projectSvc = {
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
createWorkspace: vi.fn(),
@@ -67,26 +65,6 @@ const assetSvc = {
const secretSvc = {
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config, secretKeys: new Set<string>() })),
normalizeEnvBindingsForPersistence: vi.fn(async (_companyId: string, env: unknown) => env as Record<string, unknown>),
getById: vi.fn(async (id: string) => {
if (id === "secret-1") return { id: "secret-1", name: "anthropic-api-key", provider: "local_encrypted" };
if (id === "secret-2") return { id: "secret-2", name: "gh-token", provider: "local_encrypted" };
return null;
}),
resolveSecretValue: vi.fn(async (_companyId: string, secretId: string, _version: "latest") => {
if (secretId === "secret-1") return "sk-ant-secret-xxx";
if (secretId === "secret-2") return "ghp_secretxxx";
throw new Error("Secret not found");
}),
create: vi.fn(async (companyId: string, input: { name: string; provider: string; value: string; description?: string | null }) => ({
id: `new-secret-${input.name}`,
companyId,
name: input.name,
provider: input.provider,
description: input.description ?? null,
latestVersion: 1,
})),
getByName: vi.fn(async (_companyId: string, name: string) => null),
};
const agentInstructionsSvc = {
@@ -138,25 +116,6 @@ vi.mock("../routes/org-chart-svg.js", () => ({
renderOrgChartPng: vi.fn(async () => Buffer.from("png")),
}));
const gitSourceMock = vi.hoisted(() => ({
resolveGitRef: vi.fn(),
openRepoSnapshot: vi.fn(),
}));
// parseGitSourceUrl stays real (the shim parseGitHubSourceUrl delegates to it
// and is asserted by existing tests). Only the network-touching functions are
// overridable per-test.
vi.mock("../services/git-source.js", async () => {
const actual = await vi.importActual<typeof import("../services/git-source.js")>(
"../services/git-source.js",
);
return {
...actual,
resolveGitRef: gitSourceMock.resolveGitRef,
openRepoSnapshot: gitSourceMock.openRepoSnapshot,
};
});
const { companyPortabilityService, parseGitHubSourceUrl } = await import("../services/company-portability.js");
function asTextFile(entry: CompanyPortabilityFileEntry | undefined) {
@@ -500,6 +459,7 @@ describe("company portability", () => {
expect(extension).not.toContain("instructionsFilePath");
expect(extension).not.toContain("command:");
expect(extension).not.toContain("secretId");
expect(extension).not.toContain('type: "secret_ref"');
expect(extension).toContain("inputs:");
expect(extension).toContain("ANTHROPIC_API_KEY:");
expect(extension).toContain('requirement: "optional"');
@@ -678,6 +638,106 @@ describe("company portability", () => {
expect(asTextFile(exported.files["skills/paperclipai/paperclip/paperclip/references/api.md"])).toContain("# API");
});
it("exports catalog skill provenance in portable Paperclip frontmatter", async () => {
const portability = companyPortabilityService({} as any);
const catalogKey = "paperclipai/bundled/software-development/review";
const originHash = "sha256:catalog-origin";
const catalogSkill = {
id: "skill-catalog",
companyId: "company-1",
key: catalogKey,
slug: "review",
name: "review",
description: "Catalog review skill",
markdown: "---\nname: review\ndescription: Catalog review skill\n---\n\n# Review\n",
sourceType: "catalog",
sourceLocator: "/tmp/paperclip/catalog/review",
sourceRef: originHash,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [
{ path: "SKILL.md", kind: "skill" },
{ path: "references/checklist.md", kind: "reference" },
],
metadata: {
sourceKind: "catalog",
skillKey: catalogKey,
catalogId: "paperclipai:bundled:software-development:review",
catalogKey,
catalogKind: "bundled",
catalogCategory: "software-development",
catalogPath: "catalog/bundled/software-development/review",
packageName: "@paperclipai/skills-catalog",
packageVersion: "0.3.1",
originHash,
originVersion: "0.3.1",
originSnapshotLocator: "/tmp/local-only-origin",
installedHash: "sha256:installed",
userModifiedAt: "2026-05-01T00:00:00.000Z",
updateHoldReason: "local_modifications",
auditVerdict: "warning",
auditCodes: ["local_modifications"],
auditScannedAt: "2026-05-02T00:00:00.000Z",
auditScanVersion: "skills-audit-v1",
},
};
companySkillSvc.listFull.mockResolvedValue([catalogSkill]);
companySkillSvc.readFile.mockImplementation(async (_companyId: string, skillId: string, relativePath: string) => ({
skillId,
path: relativePath,
kind: relativePath === "SKILL.md" ? "skill" : "reference",
content: relativePath === "SKILL.md"
? "---\nname: review\ndescription: Catalog review skill\n---\n\n# Review\n"
: "# Checklist\n",
language: "markdown",
markdown: true,
editable: true,
}));
const exported = await portability.exportBundle("company-1", {
include: {
company: false,
agents: false,
projects: false,
issues: false,
skills: true,
},
expandReferencedSkills: true,
});
const skillMarkdown = asTextFile(exported.files["skills/paperclipai/bundled/software-development/review/SKILL.md"]);
expect(skillMarkdown).toContain("paperclip:");
expect(skillMarkdown).toContain("catalog:");
expect(skillMarkdown).toContain(`sourceRef: "${originHash}"`);
expect(skillMarkdown).toContain('catalogId: "paperclipai:bundled:software-development:review"');
expect(skillMarkdown).toContain(`catalogKey: "${catalogKey}"`);
expect(skillMarkdown).toContain('catalogKind: "bundled"');
expect(skillMarkdown).toContain('catalogPath: "catalog/bundled/software-development/review"');
expect(skillMarkdown).toContain('packageName: "@paperclipai/skills-catalog"');
expect(skillMarkdown).toContain('packageVersion: "0.3.1"');
expect(skillMarkdown).toContain('installedHash: "sha256:installed"');
expect(skillMarkdown).toContain('auditVerdict: "warning"');
expect(skillMarkdown).not.toContain("originSnapshotLocator");
expect(exported.manifest.skills[0]).toMatchObject({
key: catalogKey,
sourceType: "catalog",
sourceRef: originHash,
metadata: expect.objectContaining({
sourceKind: "catalog",
skillKey: catalogKey,
originHash,
catalogId: "paperclipai:bundled:software-development:review",
catalogKey,
catalogKind: "bundled",
catalogPath: "catalog/bundled/software-development/review",
packageName: "@paperclipai/skills-catalog",
packageVersion: "0.3.1",
installedHash: "sha256:installed",
auditCodes: ["local_modifications"],
}),
});
});
it("exports only selected skills when skills filter is provided", async () => {
const portability = companyPortabilityService({} as any);
@@ -1314,9 +1374,6 @@ describe("company portability", () => {
requirement: "optional",
defaultValue: "",
portability: "portable",
secretName: "anthropic-api-key",
secretProvider: "local_encrypted",
type: "secret_ref",
},
{
key: "GH_TOKEN",
@@ -1327,9 +1384,6 @@ describe("company portability", () => {
requirement: "optional",
defaultValue: "",
portability: "portable",
secretName: "gh-token",
secretProvider: "local_encrypted",
type: "secret_ref",
},
]);
});
@@ -1453,9 +1507,6 @@ describe("company portability", () => {
requirement: "optional",
defaultValue: "",
portability: "portable",
secretName: null,
secretProvider: null,
type: "plain",
});
});
@@ -3063,191 +3114,6 @@ describe("company portability", () => {
}));
});
describe("secret env vars", () => {
beforeEach(() => {
// Reset create/getByName to ensure clean state per test
secretSvc.create.mockReset();
secretSvc.getByName.mockReset();
secretSvc.getById.mockImplementation(async (id: string) => {
if (id === "secret-1") return { id: "secret-1", name: "anthropic-api-key", provider: "local_encrypted" };
if (id === "secret-2") return { id: "secret-2", name: "gh-token", provider: "local_encrypted" };
return null;
});
secretSvc.resolveSecretValue.mockImplementation(async (_companyId: string, secretId: string) => {
if (secretId === "secret-1") return "sk-ant-secret-xxx";
if (secretId === "secret-2") return "ghp_secretxxx";
throw new Error("Secret not found");
});
secretSvc.create.mockImplementation(async (companyId: string, input: { name: string; provider: string; value: string; description?: string | null }) => ({
id: `new-secret-${input.name}`,
companyId,
name: input.name,
provider: input.provider,
description: input.description ?? null,
latestVersion: 1,
}));
secretSvc.getByName.mockResolvedValue(null);
});
it("exports secret env var metadata with secretName and secretProvider", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
});
const secretInput = exported.manifest.envInputs.find(
(e: any) => e.key === "ANTHROPIC_API_KEY" && e.kind === "secret",
);
expect(secretInput).toBeDefined();
expect(secretInput.secretName).toBe("anthropic-api-key");
expect(secretInput.secretProvider).toBe("local_encrypted");
});
it("exports secret values to manifest when includeSecrets is true", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
includeSecrets: true,
});
expect(exported.manifest.secrets).toBeDefined();
expect(exported.manifest.secrets).toContainEqual(expect.objectContaining({
name: "anthropic-api-key",
provider: "local_encrypted",
currentValue: "sk-ant-secret-xxx",
}));
});
it("omits secrets section when includeSecrets is false", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
includeSecrets: false,
});
expect(exported.manifest.secrets).toBeUndefined();
});
it("writes placeholder when resolveSecretValue throws (cross-instance decryption failure)", async () => {
secretSvc.resolveSecretValue.mockImplementation(async () => {
throw new Error("Decryption failed: missing master key");
});
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
includeSecrets: true,
});
const secretEntry = exported.manifest.secrets?.find((s: any) => s.name === "anthropic-api-key");
expect(secretEntry?.currentValue).toBe("<decryption-key-missing:anthropic-api-key>");
expect(exported.warnings).toContainEqual(expect.stringContaining("could not be decrypted during export"));
});
it("imports secrets and remaps secret_ref bindings to new secret IDs", async () => {
const portability = companyPortabilityService({} as any);
agentSvc.create.mockImplementation(async (companyId: string, patch: Record<string, unknown>) => ({
id: "new-agent-1",
companyId,
...patch,
}));
agentSvc.update.mockImplementation(async (id: string, patch: Record<string, unknown>) => patch as any);
agentSvc.getById.mockImplementation(async (id: string) => {
if (id === "new-agent-1") {
return { id: "new-agent-1", adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "placeholder-secret" } } } };
}
return null;
});
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
includeSecrets: true,
});
const imported = await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { agents: true, company: false, projects: false, issues: false, skills: false },
target: { mode: "existing_company", companyId: "company-imported" },
agents: ["claudecoder"],
collisionStrategy: "rename",
}, "user-1");
expect(secretSvc.create).toHaveBeenCalled();
expect(agentSvc.update).toHaveBeenCalledWith(
"new-agent-1",
expect.any(Object),
);
});
it("reuses existing secret on conflict during import", async () => {
secretSvc.getByName.mockImplementation(async (_companyId: string, name: string) => {
if (name === "anthropic-api-key") return { id: "existing-secret-1", name, provider: "local_encrypted" };
return null;
});
const portability = companyPortabilityService({} as any);
agentSvc.create.mockImplementation(async (companyId: string, patch: Record<string, unknown>) => ({
id: "new-agent-1",
companyId,
...patch,
}));
agentSvc.update.mockImplementation(async (id: string, patch: Record<string, unknown>) => patch as any);
agentSvc.getById.mockImplementation(async (id: string) => {
if (id === "new-agent-1") {
return { id: "new-agent-1", adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "placeholder-secret" } } } };
}
return null;
});
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
includeSecrets: true,
});
await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { agents: true, company: false, projects: false, issues: false, skills: false },
target: { mode: "existing_company", companyId: "company-imported" },
agents: ["claudecoder"],
collisionStrategy: "rename",
}, "user-1");
expect(agentSvc.update).toHaveBeenCalled();
});
it("exports plain env vars faithfully", async () => {
agentSvc.list.mockResolvedValue([{
id: "agent-1",
name: "TestAgent",
status: "idle",
role: "agent",
title: null,
icon: null,
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {
env: {
PLAIN_VAR: { type: "plain", value: "plain-value" },
ANOTHER_VAR: { type: "plain", value: "another-value" },
},
},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
}]);
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["testagent"],
});
const plainInputs = exported.manifest.envInputs.filter((e: any) => e.kind === "plain");
expect(plainInputs).toContainEqual(expect.objectContaining({
key: "PLAIN_VAR",
defaultValue: "plain-value",
}));
expect(plainInputs).toContainEqual(expect.objectContaining({
key: "ANOTHER_VAR",
defaultValue: "another-value",
}));
});
});
it("nameOverrides applied after collision detection do not re-validate uniqueness", async () => {
const portability = companyPortabilityService({} as any);
@@ -3398,173 +3264,3 @@ describe("company portability", () => {
expect(preview.plan.issuePlans).toHaveLength(0);
});
});
describe("git source orchestration via resolveSource", () => {
const minimalCompanyMarkdown = "---\ncompany:\n name: Demo\n---\n# Demo\n";
const githubUrl = "https://git.example.com/acme/co?ref=main&path=";
function makeSnapshot(overrides: {
files?: string[];
fileContents?: Record<string, string>;
binaryContents?: Record<string, Uint8Array>;
readBinaryReject?: Error;
} = {}) {
const files = overrides.files ?? ["COMPANY.md"];
const fileContents = overrides.fileContents ?? { "COMPANY.md": minimalCompanyMarkdown };
const binaryContents = overrides.binaryContents ?? {};
return {
sha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
listFiles: vi.fn(async () => files),
readFile: vi.fn(async (p: string) => {
if (p in fileContents) return fileContents[p];
throw Object.assign(new Error(`not found: ${p}`), { code: "NotFoundError" });
}),
readFileOptional: vi.fn(async (p: string) => fileContents[p] ?? null),
readBinary: vi.fn(async (p: string) => {
if (overrides.readBinaryReject) throw overrides.readBinaryReject;
if (p in binaryContents) return binaryContents[p]!;
throw Object.assign(new Error(`not found: ${p}`), { code: "NotFoundError" });
}),
};
}
function setupResolveStub() {
gitSourceMock.resolveGitRef.mockResolvedValue({
pinnedSha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
trackingRef: "main",
});
}
beforeEach(() => {
gitSourceMock.resolveGitRef.mockReset();
gitSourceMock.openRepoSnapshot.mockReset();
companySvc.getById.mockResolvedValue(null);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([]);
issueSvc.listComments.mockResolvedValue([]);
companySkillSvc.list.mockResolvedValue([]);
});
it("opens a snapshot and walks the tree for a github source", async () => {
setupResolveStub();
const snapshot = makeSnapshot({
files: ["COMPANY.md", "README.md", "skills/x/SKILL.md"],
fileContents: {
"COMPANY.md": minimalCompanyMarkdown,
"README.md": "# readme",
"skills/x/SKILL.md": "---\nname: x\n---\n",
},
});
gitSourceMock.openRepoSnapshot.mockResolvedValue(snapshot);
const portability = companyPortabilityService({} as any);
const preview = await portability.previewImport({
source: { type: "github", url: githubUrl },
include: { company: true, agents: false, projects: false, issues: false, skills: false },
target: { mode: "new_company", newCompanyName: "Demo" },
agents: "all",
collisionStrategy: "rename",
});
expect(gitSourceMock.resolveGitRef).toHaveBeenCalledTimes(1);
expect(gitSourceMock.openRepoSnapshot).toHaveBeenCalledTimes(1);
expect(snapshot.listFiles).toHaveBeenCalled();
expect(snapshot.readFileOptional).toHaveBeenCalledWith("COMPANY.md");
expect(snapshot.readFile).toHaveBeenCalledWith("README.md");
expect(snapshot.readFile).toHaveBeenCalledWith("skills/x/SKILL.md");
expect(preview.errors).toEqual([]);
});
it("falls back from main to master when the main ref does not exist", async () => {
setupResolveStub();
const masterSnap = makeSnapshot();
// First call (ref=main) rejects; second (ref=master) succeeds.
gitSourceMock.openRepoSnapshot
.mockRejectedValueOnce(new Error("ref not found"))
.mockResolvedValueOnce(masterSnap);
const portability = companyPortabilityService({} as any);
const preview = await portability.previewImport({
source: { type: "github", url: githubUrl },
include: { company: true, agents: false, projects: false, issues: false, skills: false },
target: { mode: "new_company", newCompanyName: "Demo" },
agents: "all",
collisionStrategy: "rename",
});
expect(gitSourceMock.openRepoSnapshot).toHaveBeenCalledTimes(2);
expect(masterSnap.readFileOptional).toHaveBeenCalledWith("COMPANY.md");
expect(preview.warnings).toContain("Git ref main not found; falling back to master.");
});
it("throws when COMPANY.md is missing on both main and master", async () => {
setupResolveStub();
const emptySnap = makeSnapshot({ fileContents: {} });
gitSourceMock.openRepoSnapshot.mockResolvedValue(emptySnap);
const portability = companyPortabilityService({} as any);
await expect(
portability.previewImport({
source: { type: "github", url: githubUrl },
include: { company: true, agents: false, projects: false, issues: false, skills: false },
target: { mode: "new_company", newCompanyName: "Demo" },
agents: "all",
collisionStrategy: "rename",
}),
).rejects.toThrow(/missing COMPANY.md/i);
});
it("fetches a referenced company logo as binary", async () => {
setupResolveStub();
// logoPath lives in .paperclip.yaml (paperclip extension), not COMPANY.md.
const paperclipYaml = "company:\n logoPath: images/logo.png\n";
const logoBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
const snapshot = makeSnapshot({
files: ["COMPANY.md", ".paperclip.yaml", "images/logo.png"],
fileContents: {
"COMPANY.md": minimalCompanyMarkdown,
".paperclip.yaml": paperclipYaml,
},
binaryContents: { "images/logo.png": logoBytes },
});
gitSourceMock.openRepoSnapshot.mockResolvedValue(snapshot);
const portability = companyPortabilityService({} as any);
await portability.previewImport({
source: { type: "github", url: githubUrl },
include: { company: true, agents: false, projects: false, issues: false, skills: false },
target: { mode: "new_company", newCompanyName: "Demo" },
agents: "all",
collisionStrategy: "rename",
});
expect(snapshot.readBinary).toHaveBeenCalledWith("images/logo.png");
});
it("warns instead of throwing when the logo blob can't be read", async () => {
setupResolveStub();
const paperclipYaml = "company:\n logoPath: images/logo.png\n";
const snapshot = makeSnapshot({
files: ["COMPANY.md", ".paperclip.yaml"],
fileContents: {
"COMPANY.md": minimalCompanyMarkdown,
".paperclip.yaml": paperclipYaml,
},
readBinaryReject: new Error("blob missing"),
});
gitSourceMock.openRepoSnapshot.mockResolvedValue(snapshot);
const portability = companyPortabilityService({} as any);
const preview = await portability.previewImport({
source: { type: "github", url: githubUrl },
include: { company: true, agents: false, projects: false, issues: false, skills: false },
target: { mode: "new_company", newCompanyName: "Demo" },
agents: "all",
collisionStrategy: "rename",
});
expect(snapshot.readBinary).toHaveBeenCalled();
expect(preview.warnings.some((w: string) => /Failed to fetch company logo/i.test(w))).toBe(true);
});
});
@@ -216,7 +216,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
return {
companyId,
agentId,
environment: {
id: environmentId,
companyId,
@@ -1395,298 +1394,4 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
expect(sshRelease).not.toHaveBeenCalled();
expect(acquired.lease.metadata?.driver).toBe("local");
});
// -------------------------------------------------------------------------
// agentId is threaded through plugin RPC params (see protocol.ts —
// PluginEnvironmentAcquireLeaseParams.agentId and
// PluginEnvironmentResumeLeaseParams.agentId). Plugin-backed sandbox
// providers can use this to scope lease state (subdirs, PVCs, etc.) per
// agent without callbacks or DB lookups. The runtime must forward it when
// present and omit it when null/undefined so older plugin SDKs that don't
// declare the field aren't surprised.
// -------------------------------------------------------------------------
it("plugin-driver acquireLease: forwards agentId in the RPC payload when present", async () => {
const pluginId = randomUUID();
const workerManager = {
isRunning: vi.fn(() => true),
call: vi.fn(async (_pluginId: string, method: string) => {
if (method === "environmentAcquireLease") {
return { providerLeaseId: "plugin-lease-agent", metadata: { remoteCwd: "/workspace" } };
}
return undefined;
}),
} as unknown as PluginWorkerManager;
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
const { companyId, agentId, environment, runId } = await seedEnvironment({
driver: "plugin",
name: "Plugin agentId fwd",
config: {
pluginKey: "acme.environments",
driverKey: "fake-plugin",
driverConfig: { template: "base" },
},
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "acme.environments",
packageName: "@acme/paperclip-environments",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "acme.environments",
apiVersion: 1,
version: "1.0.0",
displayName: "Acme",
description: "Test",
author: "Acme",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [{ driverKey: "fake-plugin", displayName: "Fake", configSchema: { type: "object" } }],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
agentId,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
expect(workerManager.call).toHaveBeenCalledWith(
pluginId,
"environmentAcquireLease",
expect.objectContaining({ agentId }),
);
});
it("plugin-driver acquireLease: omits agentId from RPC payload when null", async () => {
const pluginId = randomUUID();
const workerManager = {
isRunning: vi.fn(() => true),
call: vi.fn(async (_pluginId: string, method: string) => {
if (method === "environmentAcquireLease") {
return { providerLeaseId: "plugin-lease-no-agent", metadata: { remoteCwd: "/workspace" } };
}
return undefined;
}),
} as unknown as PluginWorkerManager;
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
const { companyId, environment, runId } = await seedEnvironment({
driver: "plugin",
name: "Plugin agentId null",
config: {
pluginKey: "acme.environments",
driverKey: "fake-plugin",
driverConfig: { template: "base" },
},
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "acme.environments",
packageName: "@acme/paperclip-environments",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "acme.environments",
apiVersion: 1,
version: "1.0.0",
displayName: "Acme",
description: "Test",
author: "Acme",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [{ driverKey: "fake-plugin", displayName: "Fake", configSchema: { type: "object" } }],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
agentId: null,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
const payload = (workerManager.call as ReturnType<typeof vi.fn>).mock.calls.find(
([, method]) => method === "environmentAcquireLease",
)?.[2] as Record<string, unknown>;
expect(payload).toBeDefined();
expect(payload.agentId).toBeUndefined();
expect("agentId" in payload).toBe(false);
});
it("sandbox-provider acquireLease: forwards agentId when present", async () => {
const pluginId = randomUUID();
const workerManager = {
isRunning: vi.fn((id: string) => id === pluginId),
call: vi.fn(async (_pluginId: string, method: string) => {
if (method === "environmentAcquireLease") {
return { providerLeaseId: "sandbox-agent-1", metadata: { reuseLease: false } };
}
throw new Error(`Unexpected plugin method: ${method}`);
}),
} as unknown as PluginWorkerManager;
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
const { companyId, agentId, environment, runId } = await seedEnvironment({
driver: "sandbox",
name: "Sandbox agentId fwd",
config: {
provider: "fake-plugin",
image: "fake:test",
timeoutMs: 30_000,
reuseLease: false,
},
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "acme.sandbox",
packageName: "@acme/paperclip-sandbox",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "acme.sandbox",
apiVersion: 1,
version: "1.0.0",
displayName: "Acme Sandbox",
description: "Test",
author: "Acme",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [{
driverKey: "fake-plugin",
kind: "sandbox_provider",
displayName: "Fake",
configSchema: { type: "object" },
}],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
agentId,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
expect(workerManager.call).toHaveBeenCalledWith(
pluginId,
"environmentAcquireLease",
expect.objectContaining({ agentId }),
expect.any(Number),
);
});
it("sandbox-provider resumeLease: forwards agentId when present", async () => {
const pluginId = randomUUID();
const calls: { method: string; params: Record<string, unknown> }[] = [];
const workerManager = {
isRunning: vi.fn((id: string) => id === pluginId),
call: vi.fn(async (_pluginId: string, method: string, params: Record<string, unknown>) => {
calls.push({ method, params });
if (method === "environmentAcquireLease") {
return { providerLeaseId: "sandbox-resume-1", metadata: { reuseLease: true } };
}
if (method === "environmentResumeLease") {
return { providerLeaseId: "sandbox-resume-1", metadata: { reuseLease: true } };
}
throw new Error(`Unexpected plugin method: ${method}`);
}),
} as unknown as PluginWorkerManager;
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
const { companyId, agentId, environment, runId } = await seedEnvironment({
driver: "sandbox",
name: "Sandbox agentId resume",
config: {
provider: "fake-plugin",
image: "fake:test",
timeoutMs: 30_000,
reuseLease: true,
},
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "acme.sandbox",
packageName: "@acme/paperclip-sandbox",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "acme.sandbox",
apiVersion: 1,
version: "1.0.0",
displayName: "Acme Sandbox",
description: "Test",
author: "Acme",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [{
driverKey: "fake-plugin",
kind: "sandbox_provider",
displayName: "Fake",
configSchema: { type: "object" },
}],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
// First acquire seeds a reusable lease row in DB
await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
agentId,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
// Second acquire on the same environment + reuseLease=true exercises the
// resume path (host's matcher finds the reusable lease, plugin's
// resumeLease is invoked).
const newRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: newRunId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
createdAt: new Date(),
updatedAt: new Date(),
} as any);
await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
agentId,
heartbeatRunId: newRunId,
persistedExecutionWorkspace: null,
});
const resumeCall = calls.find((c) => c.method === "environmentResumeLease");
expect(resumeCall).toBeDefined();
expect(resumeCall?.params.agentId).toBe(agentId);
});
});
-410
View File
@@ -1,410 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const listServerRefs = vi.fn();
const cloneFn = vi.fn();
const walkFn = vi.fn();
const readBlobFn = vi.fn();
const resolveRefFn = vi.fn();
const treeFn = vi.fn((args: unknown) => ({ __tree: args }));
vi.mock("isomorphic-git", () => ({
default: {
listServerRefs: (...args: unknown[]) => listServerRefs(...args),
clone: (...args: unknown[]) => cloneFn(...args),
walk: (...args: unknown[]) => walkFn(...args),
readBlob: (...args: unknown[]) => readBlobFn(...args),
resolveRef: (...args: unknown[]) => resolveRefFn(...args),
TREE: (...args: unknown[]) => treeFn(...args),
},
}));
vi.mock("isomorphic-git/http/node", () => ({
default: { request: vi.fn() },
}));
const { parseGitSourceUrl, resolveGitRef, openRepoSnapshot, buildCloneUrl } =
await import("../services/git-source.js");
beforeEach(() => {
listServerRefs.mockReset();
cloneFn.mockReset();
walkFn.mockReset();
readBlobFn.mockReset();
resolveRefFn.mockReset();
treeFn.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("parseGitSourceUrl", () => {
it("parses a bare github repo URL", () => {
expect(parseGitSourceUrl("https://github.com/anthropics/claude-code")).toMatchObject({
cloneUrl: "https://github.com/anthropics/claude-code.git",
hostname: "github.com",
owner: "anthropics",
repo: "claude-code",
ref: null,
basePath: "",
filePath: null,
explicitRef: false,
});
});
it("strips trailing .git from the repo segment", () => {
expect(parseGitSourceUrl("https://example.com/o/r.git")).toMatchObject({
cloneUrl: "https://example.com/o/r.git",
repo: "r",
});
});
it("parses a github tree URL with subpath", () => {
expect(
parseGitSourceUrl("https://github.com/o/r/tree/develop/sub/dir"),
).toMatchObject({
ref: "develop",
basePath: "sub/dir",
filePath: null,
explicitRef: true,
});
});
it("parses a github blob URL as a file path", () => {
expect(
parseGitSourceUrl("https://github.com/o/r/blob/main/path/to/file.md"),
).toMatchObject({
ref: "main",
basePath: "path/to",
filePath: "path/to/file.md",
explicitRef: true,
});
});
it("parses a gitea src/branch URL with subpath", () => {
expect(
parseGitSourceUrl("https://git.example.com/o/r/src/branch/main/skills"),
).toMatchObject({
cloneUrl: "https://git.example.com/o/r.git",
ref: "main",
basePath: "skills",
filePath: null,
explicitRef: true,
});
});
it("parses a gitea src/tag URL", () => {
expect(
parseGitSourceUrl("https://git.example.com/o/r/src/tag/v1.2.3"),
).toMatchObject({
ref: "v1.2.3",
basePath: "",
explicitRef: true,
});
});
it("parses a gitea src/commit URL with file", () => {
expect(
parseGitSourceUrl("https://git.example.com/o/r/src/commit/abc123/dir/SKILL.md"),
).toMatchObject({
ref: "abc123",
basePath: "dir",
filePath: "dir/SKILL.md",
});
});
it("parses a gitlab tree URL", () => {
expect(
parseGitSourceUrl("https://gitlab.com/group/proj/-/tree/main/sub"),
).toMatchObject({
cloneUrl: "https://gitlab.com/group/proj.git",
ref: "main",
basePath: "sub",
explicitRef: true,
});
});
it("parses a gitlab blob URL", () => {
expect(
parseGitSourceUrl("https://gitlab.com/group/proj/-/blob/main/sub/file.md"),
).toMatchObject({
ref: "main",
filePath: "sub/file.md",
basePath: "sub",
});
});
it("rejects non-https URLs", () => {
expect(() => parseGitSourceUrl("http://github.com/o/r")).toThrow(/HTTPS/);
});
it("rejects URLs without owner/repo", () => {
expect(() => parseGitSourceUrl("https://github.com/o")).toThrow();
});
it("rejects malformed URLs", () => {
expect(() => parseGitSourceUrl("not a url")).toThrow();
});
it("parses a query-string URL with ?ref= and ?path=", () => {
expect(
parseGitSourceUrl("https://github.com/o/r?ref=feature%2Fdemo&path=subdir"),
).toMatchObject({
cloneUrl: "https://github.com/o/r.git",
ref: "feature/demo",
basePath: "subdir",
filePath: null,
explicitRef: true,
});
});
it("parses a query-string URL with only ?ref=", () => {
expect(parseGitSourceUrl("https://github.com/o/r?ref=develop")).toMatchObject({
ref: "develop",
basePath: "",
explicitRef: true,
});
});
it("parses a query-string URL with only ?path=", () => {
expect(parseGitSourceUrl("https://github.com/o/r?path=sub")).toMatchObject({
ref: null,
basePath: "sub",
explicitRef: false,
});
});
it("query-string parsing takes precedence over path-style segments", () => {
expect(
parseGitSourceUrl("https://github.com/o/r/tree/main/old?ref=newref&path=newpath"),
).toMatchObject({
ref: "newref",
basePath: "newpath",
});
});
});
describe("buildCloneUrl", () => {
it("produces a .git suffix URL on the given host", () => {
expect(buildCloneUrl("git.example.com", "o", "r")).toBe(
"https://git.example.com/o/r.git",
);
});
});
describe("resolveGitRef", () => {
it("passes through a 40-hex SHA without hitting the network", async () => {
const parsed = parseGitSourceUrl(
"https://github.com/o/r/tree/0123456789abcdef0123456789abcdef01234567",
);
const result = await resolveGitRef(parsed);
expect(result).toEqual({
pinnedSha: "0123456789abcdef0123456789abcdef01234567",
trackingRef: "0123456789abcdef0123456789abcdef01234567",
});
expect(listServerRefs).not.toHaveBeenCalled();
});
it("returns default branch via HEAD symref when ref is absent", async () => {
listServerRefs.mockResolvedValue([
{ ref: "HEAD", oid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", target: "refs/heads/main" },
{ ref: "refs/heads/main", oid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
{ ref: "refs/heads/chore", oid: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" },
]);
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
const result = await resolveGitRef(parsed);
expect(result).toEqual({
pinnedSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
trackingRef: "main",
});
expect(listServerRefs).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://git.example.com/o/r.git",
symrefs: true,
protocolVersion: 2,
}),
);
});
it("resolves a named branch to its SHA", async () => {
listServerRefs.mockResolvedValue([
{ ref: "HEAD", oid: "1111111111111111111111111111111111111111", target: "refs/heads/main" },
{ ref: "refs/heads/main", oid: "1111111111111111111111111111111111111111" },
{ ref: "refs/heads/develop", oid: "2222222222222222222222222222222222222222" },
]);
const parsed = parseGitSourceUrl("https://git.example.com/o/r/src/branch/develop");
const result = await resolveGitRef(parsed);
expect(result).toEqual({
pinnedSha: "2222222222222222222222222222222222222222",
trackingRef: "develop",
});
});
it("prefers a peeled annotated tag over the tag object", async () => {
listServerRefs.mockResolvedValue([
{ ref: "refs/tags/v1.0", oid: "tttttttttttttttttttttttttttttttttttttttt" },
{ ref: "refs/tags/v1.0^{}", oid: "cccccccccccccccccccccccccccccccccccccccc" },
]);
const parsed = parseGitSourceUrl("https://git.example.com/o/r/src/tag/v1.0");
const result = await resolveGitRef(parsed);
expect(result.pinnedSha).toBe("cccccccccccccccccccccccccccccccccccccccc");
expect(result.trackingRef).toBe("v1.0");
});
it("resolves a lightweight tag when no peeled entry exists", async () => {
listServerRefs.mockResolvedValue([
{ ref: "refs/tags/v2.0", oid: "dddddddddddddddddddddddddddddddddddddddd" },
]);
const parsed = parseGitSourceUrl("https://git.example.com/o/r/src/tag/v2.0");
const result = await resolveGitRef(parsed);
expect(result.pinnedSha).toBe("dddddddddddddddddddddddddddddddddddddddd");
});
it("throws when an explicit ref does not exist", async () => {
listServerRefs.mockResolvedValue([
{ ref: "HEAD", oid: "9999999999999999999999999999999999999999", target: "refs/heads/main" },
{ ref: "refs/heads/main", oid: "9999999999999999999999999999999999999999" },
]);
const parsed = parseGitSourceUrl("https://git.example.com/o/r/src/branch/missing");
await expect(resolveGitRef(parsed)).rejects.toThrow(/Ref 'missing' not found/);
});
it("translates network errors into a user-facing message", async () => {
listServerRefs.mockRejectedValue(new Error("ENOTFOUND git.invalid"));
const parsed = parseGitSourceUrl("https://git.invalid/o/r");
await expect(resolveGitRef(parsed)).rejects.toThrow(/could not connect/i);
});
it("translates 401 errors into an auth message", async () => {
listServerRefs.mockRejectedValue(new Error("HTTP Error: 401 Unauthorized"));
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
await expect(resolveGitRef(parsed)).rejects.toThrow(/authentication/i);
});
it("translates 404 errors into a repo-not-found message", async () => {
listServerRefs.mockRejectedValue(new Error("HTTP Error: 404 Not Found"));
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
await expect(resolveGitRef(parsed)).rejects.toThrow(/repository not found/i);
});
it("sends an onAuth callback when a token is supplied", async () => {
listServerRefs.mockResolvedValue([
{ ref: "HEAD", oid: "1111111111111111111111111111111111111111", target: "refs/heads/main" },
{ ref: "refs/heads/main", oid: "1111111111111111111111111111111111111111" },
]);
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
await resolveGitRef(parsed, "tok_abc");
const callArgs = listServerRefs.mock.calls[0]![0] as { onAuth: () => unknown };
expect(typeof callArgs.onAuth).toBe("function");
expect(callArgs.onAuth()).toEqual({ username: "tok_abc", password: "x-oauth-basic" });
});
it("omits onAuth when no token is supplied", async () => {
listServerRefs.mockResolvedValue([
{ ref: "HEAD", oid: "1111111111111111111111111111111111111111", target: "refs/heads/main" },
{ ref: "refs/heads/main", oid: "1111111111111111111111111111111111111111" },
]);
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
await resolveGitRef(parsed);
const callArgs = listServerRefs.mock.calls[0]![0] as { onAuth?: unknown };
expect(callArgs.onAuth).toBeUndefined();
});
});
describe("openRepoSnapshot", () => {
it("clones at the tracking ref and walks the tree at the resolved SHA", async () => {
cloneFn.mockResolvedValue(undefined);
resolveRefFn.mockResolvedValue("ffffffffffffffffffffffffffffffffffffffff");
walkFn.mockImplementation(async ({ map }: { map: (filepath: string, entries: Array<{ type: () => Promise<string> }>) => Promise<void> }) => {
await map(".", [{ type: () => Promise.resolve("tree") }]);
await map("README.md", [{ type: () => Promise.resolve("blob") }]);
await map("skills/x/SKILL.md", [{ type: () => Promise.resolve("blob") }]);
await map("skills/x", [{ type: () => Promise.resolve("tree") }]);
});
readBlobFn.mockResolvedValue({ blob: new TextEncoder().encode("hello") });
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
const snap = await openRepoSnapshot(parsed, "main", "ffffffffffffffffffffffffffffffffffffffff", "tok");
expect(cloneFn).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://git.example.com/o/r.git",
ref: "main",
singleBranch: true,
depth: 1,
noCheckout: true,
}),
);
expect(snap.sha).toBe("ffffffffffffffffffffffffffffffffffffffff");
const files = await snap.listFiles();
expect(files).toEqual(["README.md", "skills/x/SKILL.md"]);
const content = await snap.readFile("README.md");
expect(content).toBe("hello");
expect(readBlobFn).toHaveBeenCalledWith(
expect.objectContaining({
oid: "ffffffffffffffffffffffffffffffffffffffff",
filepath: "README.md",
}),
);
});
it("falls back to the expected SHA as ref when no tracking ref is known", async () => {
cloneFn.mockResolvedValue(undefined);
resolveRefFn.mockResolvedValue("abc1234567890abc1234567890abc1234567890a");
walkFn.mockImplementation(async () => {});
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
await openRepoSnapshot(parsed, null, "abc1234567890abc1234567890abc1234567890a");
expect(cloneFn).toHaveBeenCalledWith(
expect.objectContaining({ ref: "abc1234567890abc1234567890abc1234567890a" }),
);
});
it("surfaces a 404 from clone as repository-not-found", async () => {
cloneFn.mockRejectedValue(new Error("HTTP Error: 404 Not Found"));
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
await expect(
openRepoSnapshot(parsed, "main", "1111111111111111111111111111111111111111"),
).rejects.toThrow(/repository not found/i);
});
it("readBinary returns the raw blob bytes", async () => {
cloneFn.mockResolvedValue(undefined);
resolveRefFn.mockResolvedValue("ffffffffffffffffffffffffffffffffffffffff");
walkFn.mockImplementation(async () => {});
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
readBlobFn.mockResolvedValue({ blob: bytes });
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
const snap = await openRepoSnapshot(parsed, "main", "ffffffffffffffffffffffffffffffffffffffff");
const result = await snap.readBinary("logo.png");
expect(result).toBe(bytes);
});
it("readFileOptional returns null on NotFoundError", async () => {
cloneFn.mockResolvedValue(undefined);
resolveRefFn.mockResolvedValue("ffffffffffffffffffffffffffffffffffffffff");
walkFn.mockImplementation(async () => {});
const err = Object.assign(new Error("missing"), { code: "NotFoundError" });
readBlobFn.mockRejectedValue(err);
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
const snap = await openRepoSnapshot(parsed, "main", "ffffffffffffffffffffffffffffffffffffffff");
const result = await snap.readFileOptional("missing.md");
expect(result).toBeNull();
});
it("readFileOptional rethrows non-NotFound errors", async () => {
cloneFn.mockResolvedValue(undefined);
resolveRefFn.mockResolvedValue("ffffffffffffffffffffffffffffffffffffffff");
walkFn.mockImplementation(async () => {});
readBlobFn.mockRejectedValue(new Error("disk explosion"));
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
const snap = await openRepoSnapshot(parsed, "main", "ffffffffffffffffffffffffffffffffffffffff");
await expect(snap.readFileOptional("any.md")).rejects.toThrow(/disk explosion/);
});
});
+6 -1
View File
@@ -1218,9 +1218,13 @@ export function agentRoutes(
companyId: string,
adapterType: string,
config: Record<string, unknown>,
options: {
materializeMissing?: boolean;
} = {},
) {
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType),
materializeMissing: options.materializeMissing
?? shouldMaterializeRuntimeSkillsForAdapter(adapterType),
});
return {
...config,
@@ -1487,6 +1491,7 @@ export function agentRoutes(
agent.companyId,
agent.adapterType,
runtimeConfig,
{ materializeMissing: false },
);
const snapshot = await adapter.listSkills({
agentId: agent.id,
+166 -39
View File
@@ -1,17 +1,21 @@
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
catalogSkillListQuerySchema,
companySkillCreateSchema,
companySkillFileUpdateSchema,
companySkillImportSchema,
companySkillUpdateAuthSchema,
companySkillInstallCatalogSchema,
companySkillInstallUpdateSchema,
companySkillProjectScanRequestSchema,
companySkillResetSchema,
} from "@paperclipai/shared";
import { trackSkillImported } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
import { accessService, agentService, companySkillService, logActivity } from "../services/index.js";
import { getCatalogSkillOrThrow, listCatalogSkills, readCatalogSkillFile } from "../services/skills-catalog.js";
import { forbidden } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js";
import { getTelemetryClient } from "../telemetry.js";
type SkillTelemetryInput = {
@@ -33,6 +37,12 @@ export function companySkillRoutes(db: Db) {
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
}
function asString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function deriveTrackedSkillRef(skill: SkillTelemetryInput): string | null {
if (skill.sourceType === "skills_sh") {
return skill.key;
@@ -40,9 +50,19 @@ export function companySkillRoutes(db: Db) {
if (skill.sourceType !== "github") {
return null;
}
const hostname = asString(skill.metadata?.hostname);
if (hostname !== "github.com") {
return null;
}
return skill.key;
}
function firstQueryString(value: unknown): string | undefined {
if (typeof value === "string") return value;
if (Array.isArray(value) && typeof value[0] === "string") return value[0];
return undefined;
}
async function assertCanMutateCompanySkills(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
@@ -72,6 +92,29 @@ export function companySkillRoutes(db: Db) {
throw forbidden("Missing permission: can create agents");
}
router.get("/skills/catalog", async (req, res) => {
assertAuthenticated(req);
const query = catalogSkillListQuerySchema.parse({
kind: firstQueryString(req.query.kind),
category: firstQueryString(req.query.category),
q: firstQueryString(req.query.q),
});
res.json(listCatalogSkills(query));
});
router.get("/skills/catalog/:catalogId/files", async (req, res) => {
assertAuthenticated(req);
const catalogRef = firstQueryString(req.query.ref) ?? (req.params.catalogId as string);
const relativePath = firstQueryString(req.query.path) ?? "SKILL.md";
res.json(await readCatalogSkillFile(catalogRef, relativePath));
});
router.get("/skills/catalog/:catalogId", async (req, res) => {
assertAuthenticated(req);
const catalogRef = firstQueryString(req.query.ref) ?? (req.params.catalogId as string);
res.json(getCatalogSkillOrThrow(catalogRef));
});
router.get("/companies/:companyId/skills", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -185,8 +228,7 @@ export function companySkillRoutes(db: Db) {
const companyId = req.params.companyId as string;
await assertCanMutateCompanySkills(req, companyId);
const source = String(req.body.source ?? "");
const authToken = typeof req.body.authToken === "string" ? req.body.authToken.trim() : undefined;
const result = await svc.importFromSource(companyId, source, authToken || undefined);
const result = await svc.importFromSource(companyId, source);
const actor = getActorInfo(req);
await logActivity(db, {
@@ -219,6 +261,38 @@ export function companySkillRoutes(db: Db) {
},
);
router.post(
"/companies/:companyId/skills/install-catalog",
validate(companySkillInstallCatalogSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.installFromCatalog(companyId, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: result.action === "created" ? "company.skill_catalog_installed" : "company.skill_catalog_updated",
entityType: "company_skill",
entityId: result.skill.id,
details: {
action: result.action,
catalogId: result.catalogSkill.id,
catalogKey: result.catalogSkill.key,
slug: result.skill.slug,
originHash: result.catalogSkill.contentHash,
warningCount: result.warnings.length,
},
});
res.status(result.action === "created" ? 201 : 200).json(result);
},
);
router.post(
"/companies/:companyId/skills/scan-projects",
validate(companySkillProjectScanRequestSchema),
@@ -281,44 +355,13 @@ export function companySkillRoutes(db: Db) {
res.json(result);
});
router.post("/companies/:companyId/skills/:skillId/install-update", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.installUpdate(companyId, skillId);
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_update_installed",
entityType: "company_skill",
entityId: result.id,
details: {
slug: result.slug,
sourceRef: result.sourceRef,
},
});
res.json(result);
});
router.patch(
"/companies/:companyId/skills/:skillId/auth",
validate(companySkillUpdateAuthSchema),
router.post(
"/companies/:companyId/skills/:skillId/audit",
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const authToken = req.body.authToken as string | null;
const result = await svc.updateSkillAuth(companyId, skillId, authToken);
const result = await svc.auditSkill(companyId, skillId);
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
@@ -331,11 +374,95 @@ export function companySkillRoutes(db: Db) {
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: authToken ? "company.skill_auth_updated" : "company.skill_auth_removed",
action: "company.skill_audited",
entityType: "company_skill",
entityId: skillId,
details: {
verdict: result.verdict,
codes: result.codes,
installedHash: result.installedHash,
originHash: result.originHash,
scanVersion: result.scanVersion,
},
});
res.json(result);
},
);
router.post(
"/companies/:companyId/skills/:skillId/install-update",
validate(companySkillInstallUpdateSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const before = await svc.getById(companyId, skillId);
const result = await svc.installUpdate(companyId, skillId, req.body);
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_update_installed",
entityType: "company_skill",
entityId: result.id,
details: {
slug: result.slug,
previousOriginHash: before?.metadata?.originHash ?? before?.sourceRef ?? null,
previousOriginVersion: before?.metadata?.originVersion ?? null,
newOriginHash: result.metadata?.originHash ?? result.sourceRef,
newOriginVersion: result.metadata?.originVersion ?? null,
driftDetected: Boolean(before?.metadata?.userModifiedAt),
force: Boolean(req.body.force),
auditVerdict: result.metadata?.auditVerdict ?? null,
},
});
res.json(result);
},
);
router.post(
"/companies/:companyId/skills/:skillId/reset",
validate(companySkillResetSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const before = await svc.getById(companyId, skillId);
const result = await svc.resetSkill(companyId, skillId, req.body);
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_reset",
entityType: "company_skill",
entityId: result.id,
details: {
slug: result.slug,
previousOriginHash: before?.metadata?.originHash ?? before?.sourceRef ?? null,
previousOriginVersion: before?.metadata?.originVersion ?? null,
newOriginHash: result.metadata?.originHash ?? result.sourceRef,
newOriginVersion: result.metadata?.originVersion ?? null,
driftDetected: Boolean(before?.metadata?.userModifiedAt),
force: Boolean(req.body.force),
auditVerdict: result.metadata?.auditVerdict ?? null,
},
});
+65
View File
@@ -0,0 +1,65 @@
export const PORTABLE_CATALOG_PROVENANCE_STRING_KEYS = [
"sourceRef",
"originHash",
"catalogId",
"catalogKey",
"catalogKind",
"catalogCategory",
"catalogPath",
"packageName",
"packageVersion",
"originVersion",
"installedHash",
"userModifiedAt",
"updateHoldReason",
"auditVerdict",
"auditScannedAt",
"auditScanVersion",
] as const;
function asCatalogString(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function readCatalogStringList(value: unknown) {
if (!Array.isArray(value)) return null;
const entries = value.map((entry) => asCatalogString(entry)).filter((entry): entry is string => Boolean(entry));
return entries.length === value.length ? entries : null;
}
function isCatalogRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function readPortableCatalogProvenance(
metadata: Record<string, unknown> | null,
canonicalKey: string | null = null,
) {
const paperclip = isCatalogRecord(metadata?.paperclip) ? metadata.paperclip : null;
const catalog = isCatalogRecord(paperclip?.catalog) ? paperclip.catalog : null;
if (!catalog) return null;
const sourceRef = asCatalogString(catalog.sourceRef) ?? asCatalogString(catalog.originHash);
const normalized: Record<string, unknown> = {
...(canonicalKey ? { skillKey: canonicalKey } : {}),
sourceKind: "catalog",
};
const catalogSkillKey = asCatalogString(catalog.skillKey);
if (!canonicalKey && catalogSkillKey) normalized.skillKey = catalogSkillKey;
for (const key of PORTABLE_CATALOG_PROVENANCE_STRING_KEYS) {
if (key === "sourceRef") continue;
const value = asCatalogString(catalog[key]);
if (value) normalized[key] = value;
}
if (sourceRef && !normalized.originHash) normalized.originHash = sourceRef;
const auditCodes = readCatalogStringList(catalog.auditCodes);
if (auditCodes) normalized.auditCodes = auditCodes;
return {
sourceRef,
metadata: normalized,
};
}
+180 -341
View File
@@ -27,11 +27,9 @@ import type {
CompanyPortabilityIssueManifestEntry,
CompanyPortabilitySidebarOrder,
CompanyPortabilitySkillManifestEntry,
CompanyPortabilitySecretEntry,
CompanySkill,
AgentEnvConfig,
RoutineVariable,
SecretProvider,
} from "@paperclipai/shared";
import {
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
@@ -56,8 +54,8 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import { requireOpenCodeModelId } from "@paperclipai/adapter-opencode-local/server";
import { findServerAdapter } from "../adapters/index.js";
import { forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { openRepoSnapshot, parseGitSourceUrl, resolveGitRef, type RepoSnapshot } from "./git-source.js";
import { forbidden, notFound, unprocessable } from "../errors.js";
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
import type { StorageService } from "../storage/types.js";
import { accessService } from "./access.js";
import { agentService } from "./agents.js";
@@ -72,6 +70,12 @@ import { issueService } from "./issues.js";
import { projectService } from "./projects.js";
import { routineService } from "./routines.js";
import { secretService } from "./secrets.js";
import {
PORTABLE_CATALOG_PROVENANCE_STRING_KEYS,
readCatalogStringList,
readPortableCatalogProvenance,
} from "./catalog-provenance.js";
import { normalizePortablePath } from "./portable-path.js";
/** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */
function buildOrgTreeFromManifest(agents: CompanyPortabilityManifest["agents"]): OrgNode[] {
@@ -230,6 +234,28 @@ function readSkillSourceKind(skill: CompanySkill) {
return asString(metadata?.sourceKind);
}
function buildPortableCatalogProvenance(skill: CompanySkill) {
if (skill.sourceType !== "catalog") return null;
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
const provenance: Record<string, unknown> = {
skillKey: skill.key,
};
const sourceRef = asString(skill.sourceRef) ?? asString(metadata?.originHash);
if (sourceRef) provenance.sourceRef = sourceRef;
for (const key of PORTABLE_CATALOG_PROVENANCE_STRING_KEYS) {
if (key === "sourceRef") continue;
const value = asString(metadata?.[key]);
if (value) provenance[key] = value;
}
const auditCodes = readCatalogStringList(metadata?.auditCodes);
if (auditCodes) provenance.auditCodes = auditCodes;
return Object.keys(provenance).length > 1 ? provenance : null;
}
function deriveLocalExportNamespace(skill: CompanySkill, slug: string) {
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
const candidates = [
@@ -405,7 +431,7 @@ function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null {
return parsed.success ? parsed.data : null;
}
async function extractPortableScopedEnvInputs(
function extractPortableScopedEnvInputs(
scope: {
label: string;
warningPrefix: string;
@@ -414,11 +440,7 @@ async function extractPortableScopedEnvInputs(
},
envValue: unknown,
warnings: string[],
secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise<string> },
secretEntries: CompanyPortabilitySecretEntry[],
includeSecrets: boolean,
companyId: string,
): Promise<CompanyPortabilityEnvInput[]> {
): CompanyPortabilityEnvInput[] {
if (!isPlainRecord(envValue)) return [];
const env = envValue as Record<string, unknown>;
const inputs: CompanyPortabilityEnvInput[] = [];
@@ -430,7 +452,6 @@ async function extractPortableScopedEnvInputs(
}
if (isPlainRecord(binding) && binding.type === "secret_ref") {
const secret = await secrets.getById(String(binding.secretId));
inputs.push({
key,
description: `Provide ${key} for ${scope.label}`,
@@ -440,33 +461,7 @@ async function extractPortableScopedEnvInputs(
requirement: "optional",
defaultValue: "",
portability: "portable",
secretName: secret?.name ?? null,
secretProvider: secret?.provider ?? null,
});
if (includeSecrets && secret && binding.secretId) {
const alreadyExported = secretEntries.some((e) => e.name === secret.name);
if (!alreadyExported) {
try {
const resolvedValue = await secrets.resolveSecretValue(companyId, String(binding.secretId), "latest");
secretEntries.push({
name: secret.name,
provider: secret.provider as SecretProvider,
description: secret.description,
latestVersion: secret.latestVersion,
currentValue: resolvedValue,
});
} catch {
secretEntries.push({
name: secret.name,
provider: secret.provider as SecretProvider,
description: secret.description,
latestVersion: secret.latestVersion,
currentValue: `<decryption-key-missing:${secret.name}>`,
});
warnings.push(`Secret "${secret.name}" could not be decrypted during export. Placeholder written.`);
}
}
}
continue;
}
@@ -476,6 +471,9 @@ async function extractPortableScopedEnvInputs(
const portability = defaultValue && isAbsoluteCommand(defaultValue)
? "system_dependent"
: "portable";
if (portability === "system_dependent") {
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
}
inputs.push({
key,
description: `Optional default for ${key} on ${scope.label}`,
@@ -491,6 +489,9 @@ async function extractPortableScopedEnvInputs(
if (typeof binding === "string") {
const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable";
if (portability === "system_dependent") {
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
}
inputs.push({
key,
description: `Optional default for ${key} on ${scope.label}`,
@@ -598,14 +599,11 @@ type AgentLike = {
};
type EnvInputRecord = {
type?: "secret_ref" | "plain";
kind: "secret" | "plain";
requirement: "required" | "optional";
default?: string | null;
description?: string | null;
portability?: "portable" | "system_dependent";
secretName?: string | null;
secretProvider?: string | null;
};
const COMPANY_LOGO_CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
@@ -1445,20 +1443,6 @@ function normalizeInclude(input?: Partial<CompanyPortabilityInclude>): CompanyPo
};
}
function normalizePortablePath(input: string) {
const normalized = input.replace(/\\/g, "/").replace(/^\.\/+/, "");
const parts: string[] = [];
for (const segment of normalized.split("/")) {
if (!segment || segment === ".") continue;
if (segment === "..") {
if (parts.length > 0) parts.pop();
continue;
}
parts.push(segment);
}
return parts.join("/");
}
function resolvePortablePath(fromPath: string, targetPath: string) {
const baseDir = path.posix.dirname(fromPath.replace(/\\/g, "/"));
return normalizePortablePath(path.posix.join(baseDir, targetPath.replace(/\\/g, "/")));
@@ -1747,15 +1731,11 @@ function isAbsoluteCommand(value: string) {
return path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value);
}
async function extractPortableEnvInputs(
function extractPortableEnvInputs(
agentSlug: string,
envValue: unknown,
warnings: string[],
secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise<string> },
secretEntries: CompanyPortabilitySecretEntry[],
includeSecrets: boolean,
companyId: string,
): Promise<CompanyPortabilityEnvInput[]> {
): CompanyPortabilityEnvInput[] {
return extractPortableScopedEnvInputs(
{
label: `agent ${agentSlug}`,
@@ -1765,22 +1745,14 @@ async function extractPortableEnvInputs(
},
envValue,
warnings,
secrets,
secretEntries,
includeSecrets,
companyId,
);
}
async function extractPortableProjectEnvInputs(
function extractPortableProjectEnvInputs(
projectSlug: string,
envValue: unknown,
warnings: string[],
secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise<string> },
secretEntries: CompanyPortabilitySecretEntry[],
includeSecrets: boolean,
companyId: string,
): Promise<CompanyPortabilityEnvInput[]> {
): CompanyPortabilityEnvInput[] {
return extractPortableScopedEnvInputs(
{
label: `project ${projectSlug}`,
@@ -1790,10 +1762,6 @@ async function extractPortableProjectEnvInputs(
},
envValue,
warnings,
secrets,
secretEntries,
includeSecrets,
companyId,
);
}
@@ -2172,12 +2140,14 @@ async function withSkillSourceMetadata(skill: CompanySkill, markdown: string) {
if (sourceEntry) {
metadata.sources = [...existingSources, sourceEntry];
}
const catalogProvenance = buildPortableCatalogProvenance(skill);
metadata.skillKey = skill.key;
metadata.paperclipSkillKey = skill.key;
metadata.paperclip = {
...(isPlainRecord(metadata.paperclip) ? metadata.paperclip : {}),
skillKey: skill.key,
slug: skill.slug,
...(catalogProvenance ? { catalog: catalogProvenance } : {}),
};
const frontmatter = {
...parsed.frontmatter,
@@ -2339,6 +2309,42 @@ function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
};
}
async function fetchText(url: string) {
const response = await ghFetch(url);
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
return response.text();
}
async function fetchOptionalText(url: string) {
const response = await ghFetch(url);
if (response.status === 404) return null;
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
return response.text();
}
async function fetchBinary(url: string) {
const response = await ghFetch(url);
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await ghFetch(url, {
headers: {
accept: "application/vnd.github+json",
},
});
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
return response.json() as Promise<T>;
}
function dedupeEnvInputs(values: CompanyPortabilityManifest["envInputs"]) {
const seen = new Set<string>();
@@ -2362,13 +2368,6 @@ function buildEnvInputMap(inputs: CompanyPortabilityEnvInput[]) {
if (input.defaultValue !== null) entry.default = input.defaultValue;
if (input.description) entry.description = input.description;
if (input.portability === "system_dependent") entry.portability = "system_dependent";
if (input.secretName) {
entry.secretName = input.secretName;
entry.type = "secret_ref";
} else {
entry.type = "plain";
}
if (input.secretProvider) entry.secretProvider = input.secretProvider;
env[input.key] = entry;
}
return env;
@@ -2413,9 +2412,6 @@ function readAgentEnvInputs(
requirement: record.requirement === "required" ? "required" : "optional",
defaultValue: typeof record.default === "string" ? record.default : null,
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
secretName: record.secretName ?? null,
secretProvider: record.secretProvider ?? null,
type: record.type,
}];
});
}
@@ -2440,9 +2436,6 @@ function readProjectEnvInputs(
requirement: record.requirement === "required" ? "required" : "optional",
defaultValue: typeof record.default === "string" ? record.default : null,
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
secretName: record.secretName ?? null,
secretProvider: record.secretProvider ?? null,
type: record.type,
}];
});
}
@@ -2489,7 +2482,6 @@ function buildManifestFromPackageFiles(
const paperclipProjects = isPlainRecord(paperclipExtension.projects) ? paperclipExtension.projects : {};
const paperclipTasks = isPlainRecord(paperclipExtension.tasks) ? paperclipExtension.tasks : {};
const paperclipRoutines = isPlainRecord(paperclipExtension.routines) ? paperclipExtension.routines : {};
const paperclipSecrets = Array.isArray(paperclipExtension.secrets) ? paperclipExtension.secrets : [];
const companyName =
asString(companyFrontmatter.name)
?? opts?.sourceLabel?.companyName
@@ -2573,7 +2565,6 @@ function buildManifestFromPackageFiles(
projects: [],
issues: [],
envInputs: [],
secrets: paperclipSecrets.length > 0 ? paperclipSecrets : undefined,
};
const warnings: string[] = [];
@@ -2693,10 +2684,17 @@ function buildManifestFromPackageFiles(
normalizedMetadata = {
sourceKind: "url",
};
} else if (metadata) {
normalizedMetadata = {
sourceKind: "catalog",
};
} else {
const catalogProvenance = readPortableCatalogProvenance(metadata);
if (catalogProvenance) {
sourceType = "catalog";
sourceRef = catalogProvenance.sourceRef;
normalizedMetadata = catalogProvenance.metadata;
} else if (metadata) {
normalizedMetadata = {
sourceKind: "catalog",
};
}
}
const key = deriveManifestSkillKey(frontmatter, slug, normalizedMetadata, sourceType, sourceLocator);
@@ -2828,37 +2826,52 @@ function normalizeGitHubSourcePath(value: string | null | undefined) {
export function parseGitHubSourceUrl(rawUrl: string) {
const url = new URL(rawUrl);
// Handle the portability-specific companyPath query param before delegating,
// since git-source has no notion of it.
const queryCompanyPath = normalizeGitHubSourcePath(url.searchParams.get("companyPath"));
const parsed = parseGitSourceUrl(rawUrl);
let companyPath: string;
let basePath = parsed.basePath;
if (queryCompanyPath) {
companyPath = queryCompanyPath;
if (!basePath) {
const derived = path.posix.dirname(companyPath);
basePath = derived === "." ? "" : derived;
}
} else if (parsed.filePath) {
// blob-style URL pointed directly at a file
companyPath = parsed.filePath;
} else if (basePath) {
companyPath = `${basePath}/COMPANY.md`;
} else {
companyPath = "COMPANY.md";
if (url.protocol !== "https:") {
throw unprocessable("GitHub source URL must use HTTPS");
}
return {
hostname: parsed.hostname,
owner: parsed.owner,
repo: parsed.repo,
ref: parsed.ref ?? "main",
basePath,
companyPath,
};
const hostname = url.hostname;
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length < 2) {
throw unprocessable("Invalid GitHub URL");
}
const owner = parts[0]!;
const repo = parts[1]!.replace(/\.git$/i, "");
const queryRef = url.searchParams.get("ref")?.trim();
const queryPath = normalizeGitHubSourcePath(url.searchParams.get("path"));
const queryCompanyPath = normalizeGitHubSourcePath(url.searchParams.get("companyPath"));
if (queryRef || queryPath || queryCompanyPath) {
const companyPath = queryCompanyPath || [queryPath, "COMPANY.md"].filter(Boolean).join("/") || "COMPANY.md";
let basePath = queryPath;
if (!basePath && companyPath !== "COMPANY.md") {
basePath = path.posix.dirname(companyPath);
if (basePath === ".") basePath = "";
}
return {
hostname,
owner,
repo,
ref: queryRef || "main",
basePath,
companyPath,
};
}
let ref = "main";
let basePath = "";
let companyPath = "COMPANY.md";
if (parts[2] === "tree") {
ref = parts[3] ?? "main";
basePath = parts.slice(4).join("/");
} else if (parts[2] === "blob") {
ref = parts[3] ?? "main";
const blobPath = parts.slice(4).join("/");
if (!blobPath) {
throw unprocessable("Invalid GitHub blob URL");
}
companyPath = blobPath;
basePath = path.posix.dirname(blobPath);
if (basePath === ".") basePath = "";
}
return { hostname, owner, repo, ref, basePath, companyPath };
}
@@ -2962,38 +2975,30 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
);
}
const sourceUrl = source.url;
const parsed = parseGitHubSourceUrl(sourceUrl);
const parsed = parseGitHubSourceUrl(source.url);
let ref = parsed.ref;
const warnings: string[] = [];
const companyRelativePath = parsed.companyPath === "COMPANY.md"
? [parsed.basePath, "COMPANY.md"].filter(Boolean).join("/")
: parsed.companyPath;
async function openSnapshot(refName: string): Promise<RepoSnapshot> {
const ps = parseGitSourceUrl(sourceUrl);
const wanted = { ...ps, ref: refName, explicitRef: true };
const resolved = await resolveGitRef(wanted);
return openRepoSnapshot(wanted, resolved.trackingRef, resolved.pinnedSha);
}
let ref = parsed.ref;
let snapshot: RepoSnapshot;
let companyMarkdown: string | null = null;
try {
snapshot = await openSnapshot(ref);
companyMarkdown = await snapshot.readFileOptional(companyRelativePath);
companyMarkdown = await fetchOptionalText(
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, companyRelativePath),
);
} catch (err) {
if (ref === "main") {
ref = "master";
warnings.push("Git ref main not found; falling back to master.");
snapshot = await openSnapshot(ref);
companyMarkdown = await snapshot.readFileOptional(companyRelativePath);
warnings.push("GitHub ref main not found; falling back to master.");
companyMarkdown = await fetchOptionalText(
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, companyRelativePath),
);
} else {
throw err;
}
}
if (!companyMarkdown) {
throw unprocessable("Git company package is missing COMPANY.md");
throw unprocessable("GitHub company package is missing COMPANY.md");
}
const companyPath = parsed.companyPath === "COMPANY.md"
@@ -3002,22 +3007,31 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const files: Record<string, CompanyPortabilityFileEntry> = {
[companyPath]: companyMarkdown,
};
const apiBase = gitHubApiBase(parsed.hostname);
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
`${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
).catch(() => ({ tree: [] }));
const basePrefix = parsed.basePath ? `${parsed.basePath.replace(/^\/+|\/+$/g, "")}/` : "";
const allPaths = await snapshot.listFiles();
const candidatePaths = allPaths.filter((entry) => {
if (basePrefix && !entry.startsWith(basePrefix)) return false;
const relative = basePrefix ? entry.slice(basePrefix.length) : entry;
return (
relative.endsWith(".md") ||
relative.startsWith("skills/") ||
relative === ".paperclip.yaml" ||
relative === ".paperclip.yml"
);
});
const candidatePaths = (tree.tree ?? [])
.filter((entry) => entry.type === "blob")
.map((entry) => entry.path)
.filter((entry): entry is string => typeof entry === "string")
.filter((entry) => {
if (basePrefix && !entry.startsWith(basePrefix)) return false;
const relative = basePrefix ? entry.slice(basePrefix.length) : entry;
return (
relative.endsWith(".md") ||
relative.startsWith("skills/") ||
relative === ".paperclip.yaml" ||
relative === ".paperclip.yml"
);
});
for (const repoPath of candidatePaths) {
const relativePath = basePrefix ? repoPath.slice(basePrefix.length) : repoPath;
if (files[relativePath] !== undefined) continue;
files[normalizePortablePath(relativePath)] = await snapshot.readFile(repoPath);
files[normalizePortablePath(relativePath)] = await fetchText(
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoPath),
);
}
const companyDoc = parseFrontmatterMarkdown(companyMarkdown);
const includeEntries = readIncludeEntries(companyDoc.frontmatter);
@@ -3026,7 +3040,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const relativePath = normalizePortablePath(includeEntry.path);
if (files[relativePath] !== undefined) continue;
if (!(repoPath.endsWith(".md") || repoPath.endsWith(".yaml") || repoPath.endsWith(".yml"))) continue;
files[relativePath] = await snapshot.readFile(repoPath);
files[relativePath] = await fetchText(
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoPath),
);
}
const resolved = buildManifestFromPackageFiles(files);
@@ -3034,13 +3050,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if (companyLogoPath && !resolved.files[companyLogoPath]) {
const repoPath = [parsed.basePath, companyLogoPath].filter(Boolean).join("/");
try {
const binary = await snapshot.readBinary(repoPath);
resolved.files[companyLogoPath] = bufferToPortableBinaryFile(
Buffer.from(binary),
inferContentTypeFromPath(companyLogoPath),
const binary = await fetchBinary(
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoPath),
);
resolved.files[companyLogoPath] = bufferToPortableBinaryFile(binary, inferContentTypeFromPath(companyLogoPath));
} catch (err) {
warnings.push(`Failed to fetch company logo ${companyLogoPath} from git: ${err instanceof Error ? err.message : String(err)}`);
warnings.push(`Failed to fetch company logo ${companyLogoPath} from GitHub: ${err instanceof Error ? err.message : String(err)}`);
}
}
resolved.warnings.unshift(...warnings);
@@ -3067,9 +3082,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const files: Record<string, CompanyPortabilityFileEntry> = {};
const warnings: string[] = [];
const envInputs: CompanyPortabilityManifest["envInputs"] = [];
const secretEntries: CompanyPortabilitySecretEntry[] = [];
const requestedSidebarOrder = normalizePortableSidebarOrder(input.sidebarOrder);
const includeSecrets = input.includeSecrets === true;
const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package";
let companyLogoPath: string | null = null;
@@ -3349,14 +3362,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
warnings.push(...exportedInstructions.warnings);
const envInputsStart = envInputs.length;
const exportedEnvInputs = await extractPortableEnvInputs(
const exportedEnvInputs = extractPortableEnvInputs(
slug,
(agent.adapterConfig as Record<string, unknown>).env,
warnings,
secrets,
secretEntries,
includeSecrets,
companyId,
);
envInputs.push(...exportedEnvInputs);
const adapterDefaultRules = ADAPTER_DEFAULT_RULES_BY_TYPE[agent.adapterType] ?? [];
@@ -3433,7 +3442,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const slug = projectSlugById.get(project.id)!;
const projectPath = `projects/${slug}/PROJECT.md`;
const envInputsStart = envInputs.length;
const exportedEnvInputs = await extractPortableProjectEnvInputs(slug, project.env, warnings, secrets, secretEntries, includeSecrets, companyId);
const exportedEnvInputs = extractPortableProjectEnvInputs(slug, project.env, warnings);
envInputs.push(...exportedEnvInputs);
const projectEnvInputs = dedupeEnvInputs(
envInputs
@@ -3653,20 +3662,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
skills: resolved.manifest.skills.length > 0,
};
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
if (includeSecrets) {
resolved.manifest.secrets = secretEntries.length > 0 ? secretEntries : undefined;
}
resolved.warnings.unshift(...warnings);
// Rebuild the YAML file to include secrets so files stay in sync with manifest
// Only include secrets - other fields should come from the original YAML structure
if (includeSecrets && resolved.manifest.secrets) {
// Parse existing YAML and add secrets to it
const existingYaml = parseYamlFile(readPortableTextFile(finalFiles, paperclipExtensionPath) ?? "") ?? {};
existingYaml.secrets = resolved.manifest.secrets;
finalFiles[paperclipExtensionPath] = buildYamlFile(existingYaml, { preserveEmptyStrings: true });
}
return {
rootPath,
manifest: resolved.manifest,
@@ -4231,7 +4228,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const resultAgents: CompanyPortabilityImportResult["agents"] = [];
const resultProjects: CompanyPortabilityImportResult["projects"] = [];
const importedSlugToAgentId = new Map<string, string>();
const secretNameToId = new Map<string, string>();
const existingSlugToAgentId = new Map<string, string>();
const agentStatusById = new Map<string, string | null | undefined>();
const existingAgents = await agents.list(targetCompany.id);
@@ -4263,35 +4259,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
}
}
// Create secrets in target company and build name->id map
for (const secretEntry of sourceManifest.secrets ?? []) {
if (secretEntry.currentValue.startsWith("<decryption-key-missing:")) {
warnings.push(`Secret "${secretEntry.name}" could not be decrypted in source instance. ` +
`Placeholder written for key. Create a secret with this name and update manually.`);
continue;
}
try {
const created = await secrets.create(targetCompany.id, {
name: secretEntry.name,
provider: secretEntry.provider,
value: secretEntry.currentValue,
description: secretEntry.description,
});
secretNameToId.set(secretEntry.name, created.id);
} catch (err) {
if (err instanceof HttpError && err.status === 409) {
const existing = await secrets.getByName(targetCompany.id, secretEntry.name);
if (existing) {
secretNameToId.set(secretEntry.name, existing.id);
} else {
warnings.push(`Secret "${secretEntry.name}" already exists but could not be resolved by name. Re-add env bindings for this secret manually.`);
}
} else {
warnings.push(`Failed to create secret "${secretEntry.name}": ${err instanceof Error ? err.message : String(err)}`);
}
}
}
if (include.agents) {
for (const planAgent of plan.preview.plan.agentPlans) {
const manifestAgent = plan.selectedAgents.find((agent) => agent.slug === planAgent.slug);
@@ -4348,30 +4315,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
desiredSkills,
mode,
);
// Reconstruct adapterConfig.env from manifest.envInputs for this agent
const agentEnvInputs = (sourceManifest.envInputs ?? []).filter((e) => e.agentSlug === manifestAgent.slug);
if (agentEnvInputs.length > 0) {
const env: Record<string, unknown> = {};
for (const ei of agentEnvInputs) {
if (ei.kind === "secret" && ei.secretName) {
const newSecretId = secretNameToId.get(ei.secretName);
if (newSecretId) {
env[ei.key] = { type: "secret_ref", secretId: newSecretId };
} else {
warnings.push(`Env key "${ei.key}" for agent ${manifestAgent.slug} references secret "${ei.secretName}" which was not included in this package. Re-add manually.`);
}
} else if (ei.kind === "secret" && !ei.secretName) {
warnings.push(`Env key "${ei.key}" for agent ${manifestAgent.slug} could not be reconstructed (sensitive binding without secret reference). Re-add manually.`);
} else if (ei.kind === "plain" && ei.defaultValue !== null) {
env[ei.key] = { type: "plain", value: ei.defaultValue };
}
}
if (Object.keys(env).length > 0) {
normalizedAdapter.adapterConfig.env = await secrets.normalizeEnvBindingsForPersistence(targetCompany.id, env as any, { strictMode: strictSecretsMode });
}
}
const patch = {
name: planAgent.plannedName,
role: manifestAgent.role,
@@ -4422,9 +4365,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
continue;
}
const createdStatus = "idle";
let created = await agents.create(targetCompany.id, {
...patch,
status: "idle",
status: createdStatus,
});
await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active");
await access.setPrincipalPermission(
@@ -4444,7 +4388,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
} catch (err) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
agentStatusById.set(created.id, created.status ?? "idle");
agentStatusById.set(created.id, created.status ?? createdStatus);
importedSlugToAgentId.set(planAgent.slug, created.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
resultAgents.push({
@@ -4493,26 +4437,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
?? null
: null;
const projectWorkspaceIdByKey = new Map<string, string>();
// Build project env from manifest.envInputs filtered by this project
const projectEnvInputs = (sourceManifest.envInputs ?? []).filter((e) => e.projectSlug === planProject.slug);
const reconstructedProjectEnv: Record<string, unknown> = {};
for (const ei of projectEnvInputs) {
if (ei.kind === "secret" && ei.secretName) {
const newSecretId = secretNameToId.get(ei.secretName);
if (newSecretId) {
reconstructedProjectEnv[ei.key] = { type: "secret_ref", secretId: newSecretId };
} else {
warnings.push(`Env key "${ei.key}" for project ${planProject.slug} references secret "${ei.secretName}" which was not included in this package. Re-add manually.`);
}
} else if (ei.kind === "secret" && !ei.secretName) {
warnings.push(`Env key "${ei.key}" for project ${planProject.slug} could not be reconstructed (sensitive binding without secret reference). Re-add manually.`);
} else if (ei.kind === "plain" && ei.defaultValue !== null) {
reconstructedProjectEnv[ei.key] = { type: "plain", value: ei.defaultValue };
}
}
const projectEnvConfig = Object.keys(reconstructedProjectEnv).length > 0
? await secrets.normalizeEnvBindingsForPersistence(targetCompany.id, reconstructedProjectEnv as any, { strictMode: strictSecretsMode })
: null;
const projectPatch = {
name: planProject.plannedName,
description: manifestProject.description,
@@ -4522,7 +4446,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any)
? manifestProject.status as typeof PROJECT_STATUSES[number]
: "backlog",
env: projectEnvConfig ?? undefined,
env: manifestProject.env,
executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy),
};
@@ -4601,91 +4525,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
}
}
// Remap secret_ref bindings in imported agent/project records to target company secret IDs
for (const envInput of sourceManifest.envInputs ?? []) {
if (envInput.kind !== "secret" || !envInput.secretName) continue;
const newSecretId = secretNameToId.get(envInput.secretName);
if (!newSecretId) {
// secret wasn't created (decryption failure or error) — it's already a placeholder in the env
continue;
}
if (envInput.agentSlug) {
const agentId = importedSlugToAgentId.get(envInput.agentSlug);
if (agentId) {
const agent = await agents.getById(agentId);
if (agent) {
const adapterConfig = agent.adapterConfig as Record<string, unknown>;
const env = adapterConfig.env as Record<string, unknown> | undefined;
let mutated = false;
if (env && typeof env[envInput.key] === "object" && env[envInput.key] !== null) {
const binding = env[envInput.key] as Record<string, unknown>;
if (binding.type === "secret_ref" && binding.secretId !== newSecretId) {
binding.secretId = newSecretId;
mutated = true;
}
}
if (mutated) await agents.update(agentId, { adapterConfig });
}
}
} else if (envInput.projectSlug) {
const projectId = importedSlugToProjectId.get(envInput.projectSlug);
if (projectId) {
const project = await projects.getById(projectId);
if (project && project.env && typeof project.env === "object") {
const env = project.env as Record<string, unknown>;
let mutated = false;
if (typeof env[envInput.key] === "object" && env[envInput.key] !== null) {
const binding = env[envInput.key] as Record<string, unknown>;
if (binding.type === "secret_ref" && binding.secretId !== newSecretId) {
binding.secretId = newSecretId;
mutated = true;
}
}
if (mutated) await projects.update(projectId, { env: env as import("@paperclipai/shared").AgentEnvConfig });
}
}
}
}
// Note: the legacy secret remapping below is kept as a safety net for
// agents/projects that were created/updated before this code existed.
// It can be removed once the inline reconstruction above is stable.
// Reconstruct plain env bindings and fill in missing env keys on imported agents/projects
for (const envInput of sourceManifest.envInputs ?? []) {
if (envInput.kind !== "plain" && !(envInput.kind === "secret" && !envInput.secretName)) continue;
if (!envInput.defaultValue && envInput.kind === "plain") continue;
if (envInput.agentSlug) {
const agentId = importedSlugToAgentId.get(envInput.agentSlug);
if (!agentId) continue;
const agent = await agents.getById(agentId);
if (!agent) continue;
const adapterConfig = agent.adapterConfig as Record<string, unknown>;
const env = (adapterConfig.env as Record<string, unknown>) ?? {};
let mutated = false;
if (!env[envInput.key] && envInput.kind === "plain") {
env[envInput.key] = { type: "plain", value: envInput.defaultValue ?? "" };
mutated = true;
}
if (mutated) {
adapterConfig.env = env;
await agents.update(agentId, { adapterConfig });
}
} else if (envInput.projectSlug) {
const projectId = importedSlugToProjectId.get(envInput.projectSlug);
if (!projectId) continue;
const project = await projects.getById(projectId);
if (!project) continue;
const env = (project.env as Record<string, unknown>) ?? {};
let mutated = false;
if (!env[envInput.key] && envInput.kind === "plain") {
env[envInput.key] = { type: "plain", value: envInput.defaultValue ?? "" };
mutated = true;
}
if (mutated) await projects.update(projectId, { env: env as import("@paperclipai/shared").AgentEnvConfig });
}
}
if (include.issues) {
const routines = routineService(db);
for (const manifestIssue of sourceManifest.issues) {
File diff suppressed because it is too large Load Diff
-282
View File
@@ -1,282 +0,0 @@
import path from "path";
import git from "isomorphic-git";
import http from "isomorphic-git/http/node";
import { Volume, createFsFromVolume } from "memfs";
import { unprocessable } from "../errors.js";
export type ParsedGitSource = {
cloneUrl: string;
hostname: string;
owner: string;
repo: string;
ref: string | null;
basePath: string;
filePath: string | null;
explicitRef: boolean;
};
export type RefResolution = {
pinnedSha: string;
trackingRef: string | null;
};
export type RepoSnapshot = {
sha: string;
listFiles(): Promise<string[]>;
readFile(repoPath: string): Promise<string>;
readFileOptional(repoPath: string): Promise<string | null>;
readBinary(repoPath: string): Promise<Uint8Array>;
};
const SHA_REGEX = /^[0-9a-f]{40}$/i;
export function buildCloneUrl(hostname: string, owner: string, repo: string): string {
return `https://${hostname}/${owner}/${repo}.git`;
}
export function parseGitSourceUrl(rawUrl: string): ParsedGitSource {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw unprocessable("Invalid git source URL");
}
if (url.protocol !== "https:") {
throw unprocessable("Source URL must use HTTPS");
}
const segments = url.pathname.split("/").filter(Boolean);
if (segments.length < 2) {
throw unprocessable("Source URL must include an owner and repository");
}
const owner = segments[0]!;
const repo = segments[1]!.replace(/\.git$/i, "");
// Query-string shape: /{owner}/{repo}?ref=...&path=...
// Used by company portability URLs. Takes precedence over path-based parsing
// so a URL with both shapes (rare) prefers the explicit query params.
const queryRef = url.searchParams.get("ref")?.trim() ?? null;
const queryPath = url.searchParams.get("path")?.trim() ?? null;
if (queryRef || queryPath) {
const normalizedPath = (queryPath ?? "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
return {
cloneUrl: buildCloneUrl(url.hostname, owner, repo),
hostname: url.hostname,
owner,
repo,
ref: queryRef || null,
basePath: normalizedPath,
filePath: null,
explicitRef: Boolean(queryRef),
};
}
let ref: string | null = null;
let basePath = "";
let filePath: string | null = null;
let explicitRef = false;
let tail: string[] = [];
// Recognise common host-specific URL shapes so users can paste a tree/blob link.
if (segments[2] === "tree" || segments[2] === "blob") {
// github.com style
ref = segments[3] ?? null;
tail = segments.slice(4);
explicitRef = ref !== null;
} else if (segments[2] === "src" && (segments[3] === "branch" || segments[3] === "commit" || segments[3] === "tag")) {
// gitea / forgejo style
ref = segments[4] ?? null;
tail = segments.slice(5);
explicitRef = ref !== null;
} else if (segments[2] === "-" && (segments[3] === "tree" || segments[3] === "blob")) {
// gitlab style: /{owner}/{repo}/-/tree/{ref}/{path}
ref = segments[4] ?? null;
tail = segments.slice(5);
explicitRef = ref !== null;
} else if (segments[2] === "src" && segments.length >= 4) {
// bitbucket style: /{owner}/{repo}/src/{ref}/{path}
ref = segments[3] ?? null;
tail = segments.slice(4);
explicitRef = ref !== null;
}
if (segments[2] === "blob" || (segments[2] === "-" && segments[3] === "blob")) {
const joined = tail.join("/");
filePath = joined || null;
basePath = filePath ? path.posix.dirname(filePath) : "";
if (basePath === ".") basePath = "";
} else if (tail.length > 0) {
const joined = tail.join("/");
// Heuristic: if the last segment looks like a file (has an extension), treat as file
const last = tail[tail.length - 1]!;
if (/\.[A-Za-z0-9]+$/.test(last)) {
filePath = joined;
basePath = path.posix.dirname(joined);
if (basePath === ".") basePath = "";
} else {
basePath = joined;
}
}
return {
cloneUrl: buildCloneUrl(url.hostname, owner, repo),
hostname: url.hostname,
owner,
repo,
ref,
basePath,
filePath,
explicitRef,
};
}
function buildAuthCallback(authToken: string | undefined) {
if (!authToken) return undefined;
// Universal pattern: token-as-username works for GitHub PATs (classic and fine-grained),
// GitLab project/personal access tokens, Gitea/Forgejo tokens, and Bitbucket app passwords
// when used over the git smart-HTTP protocol.
return () => ({ username: authToken, password: "x-oauth-basic" });
}
async function withGitErrors<T>(label: string, fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (/HTTP Error: 401/i.test(message)) {
throw unprocessable(`${label}: authentication required or token rejected`);
}
if (/HTTP Error: 403/i.test(message)) {
throw unprocessable(`${label}: access forbidden`);
}
if (/HTTP Error: 404/i.test(message) || /repository not found/i.test(message)) {
throw unprocessable(`${label}: repository not found`);
}
if (/ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT/i.test(message)) {
throw unprocessable(`${label}: could not connect to host`);
}
throw unprocessable(`${label}: ${message}`);
}
}
export async function resolveGitRef(
parsed: ParsedGitSource,
authToken?: string,
): Promise<RefResolution> {
const onAuth = buildAuthCallback(authToken);
if (parsed.ref && SHA_REGEX.test(parsed.ref.trim())) {
return {
pinnedSha: parsed.ref.trim().toLowerCase(),
trackingRef: parsed.explicitRef ? parsed.ref.trim() : null,
};
}
const refs = await withGitErrors(`Resolve refs for ${parsed.cloneUrl}`, () =>
git.listServerRefs({
http,
url: parsed.cloneUrl,
onAuth,
symrefs: true,
protocolVersion: 2,
}),
);
const findExact = (fullRef: string) => refs.find((r) => r.ref === fullRef);
if (!parsed.ref) {
const head = refs.find((r) => r.ref === "HEAD");
if (!head?.oid) {
throw unprocessable(`Could not determine default branch for ${parsed.cloneUrl}`);
}
const target = head.target?.replace(/^refs\/heads\//, "") ?? null;
return { pinnedSha: head.oid, trackingRef: target };
}
const wanted = parsed.ref.replace(/^refs\/(heads|tags)\//, "");
const branch = findExact(`refs/heads/${wanted}`);
if (branch?.oid) return { pinnedSha: branch.oid, trackingRef: wanted };
// Prefer the peeled (annotated) tag oid when present, else the tag object oid.
const peeled = findExact(`refs/tags/${wanted}^{}`);
if (peeled?.oid) return { pinnedSha: peeled.oid, trackingRef: wanted };
const tag = findExact(`refs/tags/${wanted}`);
if (tag?.oid) return { pinnedSha: tag.oid, trackingRef: wanted };
throw unprocessable(`Ref '${parsed.ref}' not found in ${parsed.cloneUrl}`);
}
export async function openRepoSnapshot(
parsed: ParsedGitSource,
trackingRef: string | null,
expectedSha: string,
authToken?: string,
): Promise<RepoSnapshot> {
const volume = new Volume();
const fs = createFsFromVolume(volume) as unknown as Parameters<typeof git.clone>[0]["fs"];
const dir = "/repo";
const onAuth = buildAuthCallback(authToken);
await withGitErrors(`Clone ${parsed.cloneUrl}`, async () => {
await git.clone({
fs,
http,
dir,
url: parsed.cloneUrl,
ref: trackingRef ?? expectedSha,
singleBranch: true,
depth: 1,
noCheckout: true,
onAuth,
});
});
// Re-resolve to the actual commit cloned. If upstream moved between resolveGitRef and
// clone, we trust what we cloned (snapshot is self-consistent).
const sha = await git.resolveRef({ fs, dir, ref: "HEAD" });
async function listFiles(): Promise<string[]> {
const out: string[] = [];
await git.walk({
fs,
dir,
trees: [git.TREE({ ref: sha })],
map: async (filepath, entries) => {
if (filepath === ".") return;
const entry = entries?.[0];
if (!entry) return;
const type = await entry.type();
if (type === "blob") {
out.push(filepath);
}
},
});
return out;
}
async function readBinary(repoPath: string): Promise<Uint8Array> {
const normalized = repoPath.replace(/^\/+/, "");
const { blob } = await git.readBlob({ fs, dir, oid: sha, filepath: normalized });
return blob;
}
async function readFile(repoPath: string): Promise<string> {
const blob = await readBinary(repoPath);
return new TextDecoder("utf-8").decode(blob);
}
async function readFileOptional(repoPath: string): Promise<string | null> {
try {
return await readFile(repoPath);
} catch (err) {
// isomorphic-git throws NotFoundError when the path is missing from the tree.
const name = (err as { code?: string; name?: string } | null)?.code
?? (err as { name?: string } | null)?.name
?? "";
if (/NotFound/i.test(name)) return null;
throw err;
}
}
return { sha, listFiles, readFile, readFileOptional, readBinary };
}
+25
View File
@@ -0,0 +1,25 @@
import { unprocessable } from "../errors.js";
function isGitHubDotCom(hostname: string) {
const h = hostname.toLowerCase();
return h === "github.com" || h === "www.github.com";
}
export function gitHubApiBase(hostname: string) {
return isGitHubDotCom(hostname) ? "https://api.github.com" : `https://${hostname}/api/v3`;
}
export function resolveRawGitHubUrl(hostname: string, owner: string, repo: string, ref: string, filePath: string) {
const p = filePath.replace(/^\/+/, "");
return isGitHubDotCom(hostname)
? `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${p}`
: `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`;
}
export async function ghFetch(url: string, init?: RequestInit): Promise<Response> {
try {
return await fetch(url, init);
} catch {
throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`);
}
}
+103 -2
View File
@@ -29,6 +29,8 @@ import {
activityLog,
approvals,
companySkills as companySkillsTable,
documentAnnotationComments,
documentAnnotationThreads,
documentRevisions,
issueDocuments,
heartbeatRunEvents,
@@ -87,6 +89,7 @@ import { logActivity, publishPluginDomainEvent, type LogActivityInput } from "./
import {
buildWorkspaceReadyComment,
cleanupExecutionWorkspaceArtifacts,
ensurePersistedExecutionWorkspaceAvailable,
ensureRuntimeServicesForRun,
persistAdapterManagedRuntimeServices,
realizeExecutionWorkspace,
@@ -594,6 +597,8 @@ export function mergeExecutionWorkspaceMetadataForPersistence(input: {
createdByRuntime: boolean;
configSnapshot: Record<string, unknown> | null;
shouldReuseExisting: boolean;
baseRef: string | null | undefined;
baseRefSha: string | null | undefined;
}) {
const base = {
...(input.existingMetadata ?? {}),
@@ -601,6 +606,17 @@ export function mergeExecutionWorkspaceMetadataForPersistence(input: {
createdByRuntime: input.createdByRuntime,
} as Record<string, unknown>;
const existingSnapshot = parseObject(base.baseRefSnapshot);
if (
typeof existingSnapshot.resolvedSha !== "string"
&& input.baseRefSha
) {
base.baseRefSnapshot = {
baseRef: input.baseRef ?? null,
resolvedSha: input.baseRefSha,
};
}
if (input.shouldReuseExisting || !input.configSnapshot) {
return base;
}
@@ -624,6 +640,8 @@ export function buildRealizedExecutionWorkspaceFromPersisted(input: {
}
const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary";
const baseRefSnapshot = parseObject(input.workspace.metadata?.baseRefSnapshot);
const baseRefSha = typeof baseRefSnapshot.resolvedSha === "string" ? baseRefSnapshot.resolvedSha : null;
return {
baseCwd: input.base.baseCwd,
source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session",
@@ -637,6 +655,7 @@ export function buildRealizedExecutionWorkspaceFromPersisted(input: {
worktreePath: strategy === "git_worktree" ? (readNonEmptyString(input.workspace.providerRef) ?? cwd) : null,
warnings: [],
created: false,
baseRefSha,
};
}
@@ -1964,6 +1983,7 @@ async function buildPaperclipWakePayload(input: {
}) {
const executionStage = parseObject(input.contextSnapshot.executionStage);
const commentIds = extractWakeCommentIds(input.contextSnapshot);
const annotationCommentId = readNonEmptyString(input.contextSnapshot.annotationCommentId);
const issueId = readNonEmptyString(input.contextSnapshot.issueId);
const continuationSummary = input.continuationSummary ?? null;
const issueSummary =
@@ -2054,6 +2074,57 @@ async function buildPaperclipWakePayload(input: {
});
}
const annotationDeltas = annotationCommentId
? await input.db
.select({
id: documentAnnotationComments.id,
issueId: documentAnnotationComments.issueId,
threadId: documentAnnotationComments.threadId,
body: documentAnnotationComments.body,
authorType: documentAnnotationComments.authorType,
authorAgentId: documentAnnotationComments.authorAgentId,
authorUserId: documentAnnotationComments.authorUserId,
createdAt: documentAnnotationComments.createdAt,
documentKey: documentAnnotationThreads.documentKey,
status: documentAnnotationThreads.status,
anchorState: documentAnnotationThreads.anchorState,
anchorConfidence: documentAnnotationThreads.anchorConfidence,
currentRevisionNumber: documentAnnotationThreads.currentRevisionNumber,
selectedText: documentAnnotationThreads.selectedText,
prefixText: documentAnnotationThreads.prefixText,
suffixText: documentAnnotationThreads.suffixText,
})
.from(documentAnnotationComments)
.innerJoin(documentAnnotationThreads, eq(documentAnnotationComments.threadId, documentAnnotationThreads.id))
.where(and(
eq(documentAnnotationComments.companyId, input.companyId),
eq(documentAnnotationComments.id, annotationCommentId),
))
.then((rows) => rows.map((row) => ({
id: row.id,
issueId: row.issueId,
threadId: row.threadId,
documentKey: row.documentKey,
revisionNumber: row.currentRevisionNumber,
quote: row.selectedText,
prefix: row.prefixText,
suffix: row.suffixText,
threadStatus: row.status,
anchorState: row.anchorState,
anchorConfidence: row.anchorConfidence,
body: row.body.length > MAX_INLINE_WAKE_COMMENT_BODY_CHARS
? row.body.slice(0, MAX_INLINE_WAKE_COMMENT_BODY_CHARS)
: row.body,
bodyTruncated: row.body.length > MAX_INLINE_WAKE_COMMENT_BODY_CHARS,
createdAt: row.createdAt.toISOString(),
author: row.authorAgentId
? { type: "agent", id: row.authorAgentId }
: row.authorUserId
? { type: "user", id: row.authorUserId }
: { type: row.authorType, id: null },
})))
: [];
return {
reason: readNonEmptyString(input.contextSnapshot.wakeReason),
issue: issueSummary
@@ -2111,6 +2182,7 @@ async function buildPaperclipWakePayload(input: {
commentIds,
latestCommentId: commentIds[commentIds.length - 1] ?? null,
comments,
annotationDeltas,
commentWindow: {
requestedCount: commentIds.length,
includedCount: comments.length,
@@ -4063,7 +4135,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
continuationAttempt: decision.nextAttempt,
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, continuationRun.id));
.where(eq(heartbeatRuns.id, run.id));
}
}
@@ -7229,7 +7301,34 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
repoRef: resolvedWorkspace.repoRef,
} satisfies ExecutionWorkspaceInput;
const reusedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
? buildRealizedExecutionWorkspaceFromPersisted({
? await ensurePersistedExecutionWorkspaceAvailable({
base: executionWorkspaceBase,
workspace: {
mode: existingExecutionWorkspace.mode,
strategyType: existingExecutionWorkspace.strategyType,
cwd: existingExecutionWorkspace.cwd,
providerRef: existingExecutionWorkspace.providerRef,
projectId: existingExecutionWorkspace.projectId,
projectWorkspaceId: existingExecutionWorkspace.projectWorkspaceId,
repoUrl: existingExecutionWorkspace.repoUrl,
baseRef: existingExecutionWorkspace.baseRef,
branchName: existingExecutionWorkspace.branchName,
metadata: existingExecutionWorkspace.metadata as Record<string, unknown> | null,
config: {
provisionCommand:
existingExecutionWorkspace.config?.provisionCommand
?? projectExecutionWorkspacePolicy?.workspaceStrategy?.provisionCommand
?? null,
},
},
issue: issueRef,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
recorder: workspaceOperationRecorder,
}) ?? buildRealizedExecutionWorkspaceFromPersisted({
base: executionWorkspaceBase,
workspace: existingExecutionWorkspace,
})
@@ -7254,6 +7353,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
createdByRuntime: executionWorkspace.created,
configSnapshot,
shouldReuseExisting,
baseRef: executionWorkspace.repoRef,
baseRefSha: executionWorkspace.baseRefSha ?? null,
});
try {
persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
+12
View File
@@ -0,0 +1,12 @@
export function normalizePortablePath(input: string) {
const parts: string[] = [];
for (const segment of input.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, "").split("/")) {
if (!segment || segment === ".") continue;
if (segment === "..") {
if (parts.length > 0) parts.pop();
continue;
}
parts.push(segment);
}
return parts.join("/");
}
+201
View File
@@ -0,0 +1,201 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type {
CatalogSkill,
CatalogSkillFileDetail,
CatalogSkillListQuery,
} from "@paperclipai/shared";
import { HttpError, conflict, notFound } from "../errors.js";
import { normalizePortablePath } from "./portable-path.js";
interface CatalogManifestFile {
packageName: string;
packageVersion: string;
skills: CatalogSkill[];
}
const serviceDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(serviceDir, "../../..");
const catalogPackageRoot = path.join(repoRoot, "packages/skills-catalog");
const catalogManifestPath = path.join(catalogPackageRoot, "generated/catalog.json");
let cachedCatalogManifest: {
manifest: CatalogManifestFile;
mtimeMs: number;
size: number;
} | null = null;
function loadCatalogManifest(): CatalogManifestFile {
if (!existsSync(catalogManifestPath)) {
throw new Error(
`Skills catalog manifest not found at ${catalogManifestPath}. Run pnpm --filter @paperclipai/skills-catalog build:manifest.`,
);
}
return JSON.parse(readFileSync(catalogManifestPath, "utf8")) as CatalogManifestFile;
}
function getCatalogManifest() {
if (!existsSync(catalogManifestPath)) {
throw new Error(
`Skills catalog manifest not found at ${catalogManifestPath}. Run pnpm --filter @paperclipai/skills-catalog build:manifest.`,
);
}
const stats = statSync(catalogManifestPath);
if (
cachedCatalogManifest &&
cachedCatalogManifest.mtimeMs === stats.mtimeMs &&
cachedCatalogManifest.size === stats.size
) {
return cachedCatalogManifest.manifest;
}
const manifest = loadCatalogManifest();
cachedCatalogManifest = {
manifest,
mtimeMs: stats.mtimeMs,
size: stats.size,
};
return manifest;
}
function getCatalogSkills() {
const catalogManifest = getCatalogManifest();
return catalogManifest.skills.map((skill) => ({
...skill,
packageName: catalogManifest.packageName,
packageVersion: catalogManifest.packageVersion,
}));
}
function isMarkdownPath(filePath: string) {
const fileName = path.posix.basename(filePath).toLowerCase();
return fileName === "skill.md" || fileName.endsWith(".md");
}
function inferLanguageFromPath(filePath: string) {
const fileName = path.posix.basename(filePath).toLowerCase();
if (fileName === "skill.md" || fileName.endsWith(".md")) return "markdown";
if (fileName.endsWith(".ts")) return "typescript";
if (fileName.endsWith(".tsx")) return "tsx";
if (fileName.endsWith(".js")) return "javascript";
if (fileName.endsWith(".jsx")) return "jsx";
if (fileName.endsWith(".json")) return "json";
if (fileName.endsWith(".yml") || fileName.endsWith(".yaml")) return "yaml";
if (fileName.endsWith(".sh")) return "bash";
if (fileName.endsWith(".py")) return "python";
if (fileName.endsWith(".html")) return "html";
if (fileName.endsWith(".css")) return "css";
return null;
}
function resolveCatalogPackageRoot() {
return catalogPackageRoot;
}
function searchText(skill: CatalogSkill) {
return [
skill.id,
skill.key,
skill.slug,
skill.name,
skill.description,
skill.category,
skill.kind,
...skill.recommendedForRoles,
...skill.tags,
].join("\n").toLowerCase();
}
export function listCatalogSkills(query: CatalogSkillListQuery = {}): CatalogSkill[] {
const normalizedQuery = query.q?.trim().toLowerCase() ?? "";
return getCatalogSkills()
.filter((skill) => !query.kind || skill.kind === query.kind)
.filter((skill) => !query.category || skill.category === query.category)
.filter((skill) => !normalizedQuery || searchText(skill).includes(normalizedQuery))
.sort((left, right) => left.name.localeCompare(right.name) || left.key.localeCompare(right.key));
}
export function resolveCatalogSkillReference(reference: string): { skill: CatalogSkill | null; ambiguous: boolean } {
const trimmed = reference.trim();
if (!trimmed) return { skill: null, ambiguous: false };
const catalogSkills = getCatalogSkills();
const exact = catalogSkills.find((skill) => skill.id === trimmed || skill.key === trimmed);
if (exact) return { skill: exact, ambiguous: false };
const slugMatches = catalogSkills.filter((skill) => skill.slug === trimmed);
if (slugMatches.length === 1) return { skill: slugMatches[0]!, ambiguous: false };
if (slugMatches.length > 1) return { skill: null, ambiguous: true };
return { skill: null, ambiguous: false };
}
export function getCatalogSkillOrThrow(reference: string): CatalogSkill {
const result = resolveCatalogSkillReference(reference);
if (result.ambiguous) {
throw conflict(`Catalog skill slug "${reference}" is ambiguous. Use an id or key.`);
}
if (!result.skill) {
throw notFound("Catalog skill not found");
}
return result.skill;
}
export async function readCatalogSkillFile(
reference: string,
relativePath = "SKILL.md",
): Promise<CatalogSkillFileDetail> {
const skill = getCatalogSkillOrThrow(reference);
const normalizedPath = normalizePortablePath(relativePath || "SKILL.md");
const fileEntry = skill.files.find((entry) => entry.path === normalizedPath);
if (!fileEntry) {
throw notFound("Catalog skill file not found");
}
const packageRoot = resolveCatalogPackageRoot();
const absolutePath = path.resolve(packageRoot, skill.path, normalizedPath);
const skillRoot = path.resolve(packageRoot, skill.path);
if (absolutePath !== skillRoot && !absolutePath.startsWith(`${skillRoot}${path.sep}`)) {
throw notFound("Catalog skill file not found");
}
if (fileEntry.kind === "asset") {
throw new HttpError(415, "Catalog asset previews are not supported.");
}
const content = await fs.readFile(absolutePath, "utf8");
return {
catalogSkillId: skill.id,
path: normalizedPath,
kind: fileEntry.kind,
content,
language: inferLanguageFromPath(normalizedPath),
markdown: isMarkdownPath(normalizedPath),
};
}
export async function copyCatalogSkillFile(reference: string, relativePath: string, targetPath: string): Promise<void> {
const skill = getCatalogSkillOrThrow(reference);
const normalizedPath = normalizePortablePath(relativePath || "SKILL.md");
const fileEntry = skill.files.find((entry) => entry.path === normalizedPath);
if (!fileEntry) {
throw notFound("Catalog skill file not found");
}
const packageRoot = resolveCatalogPackageRoot();
const absolutePath = path.resolve(packageRoot, skill.path, normalizedPath);
const skillRoot = path.resolve(packageRoot, skill.path);
if (absolutePath !== skillRoot && !absolutePath.startsWith(`${skillRoot}${path.sep}`)) {
throw notFound("Catalog skill file not found");
}
await fs.copyFile(absolutePath, targetPath);
}
export function getCatalogPackageMetadata() {
const catalogManifest = getCatalogManifest();
return {
packageName: catalogManifest.packageName,
packageVersion: catalogManifest.packageVersion,
};
}
+165 -8
View File
@@ -67,6 +67,7 @@ export interface RealizedExecutionWorkspace extends ExecutionWorkspaceInput {
worktreePath: string | null;
warnings: string[];
created: boolean;
baseRefSha?: string | null;
}
export interface RuntimeServiceRef {
@@ -524,11 +525,110 @@ async function runGit(args: string[], cwd: string): Promise<string> {
return proc.stdout.trim();
}
function formatShortSha(value: string | null | undefined) {
return value ? value.slice(0, 12) : "unknown";
}
function gitErrorIncludes(error: unknown, needle: string) {
const message = error instanceof Error ? error.message : String(error);
return message.toLowerCase().includes(needle.toLowerCase());
}
function parseRemoteTrackingRef(ref: string): { remote: string; branch: string } | null {
const trimmed = ref.trim();
const refsRemotesPrefix = "refs/remotes/";
const normalized = trimmed.startsWith(refsRemotesPrefix)
? trimmed.slice(refsRemotesPrefix.length)
: trimmed;
const slashIndex = normalized.indexOf("/");
if (slashIndex <= 0 || slashIndex === normalized.length - 1) return null;
const remote = normalized.slice(0, slashIndex);
const branch = normalized.slice(slashIndex + 1);
if (!/^[A-Za-z0-9._-]+$/.test(remote)) return null;
return { remote, branch };
}
async function refreshRemoteTrackingBaseRef(repoRoot: string, baseRef: string): Promise<string[]> {
const remoteTracking = parseRemoteTrackingRef(baseRef);
if (!remoteTracking) return [];
const remoteExists = await runGit(["remote", "get-url", remoteTracking.remote], repoRoot)
.then(() => true)
.catch(() => false);
if (!remoteExists) return [];
try {
await runGit([
"fetch",
"--prune",
remoteTracking.remote,
`+refs/heads/${remoteTracking.branch}:refs/remotes/${remoteTracking.remote}/${remoteTracking.branch}`,
], repoRoot);
return [];
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return [`Could not refresh base ref ${baseRef} before preparing the execution workspace: ${message}`];
}
}
async function resolveBaseRefSha(repoRoot: string, baseRef: string): Promise<string | null> {
return await runGit(["rev-parse", "--verify", `${baseRef}^{commit}`], repoRoot).catch(() => null);
}
function readRecordedBaseRefSha(metadata: Record<string, unknown> | null | undefined): string | null {
const snapshot = parseObject(metadata?.baseRefSnapshot);
const resolvedSha = snapshot.resolvedSha;
return typeof resolvedSha === "string" && resolvedSha.trim().length > 0 ? resolvedSha.trim() : null;
}
export async function inspectExecutionWorkspaceBaseDrift(input: {
repoRoot: string;
worktreePath: string;
branchName: string | null;
baseRef: string | null;
recordedBaseRefSha?: string | null;
skipRefresh?: boolean;
}): Promise<{
warnings: string[];
currentBaseRefSha: string | null;
branchBaseRefSha: string | null;
}> {
const baseRef = input.baseRef?.trim();
if (!baseRef) {
return { warnings: [], currentBaseRefSha: null, branchBaseRefSha: null };
}
const warnings = input.skipRefresh ? [] : await refreshRemoteTrackingBaseRef(input.repoRoot, baseRef);
const currentBaseRefSha = await resolveBaseRefSha(input.repoRoot, baseRef);
if (!currentBaseRefSha) {
warnings.push(`Could not resolve base ref ${baseRef} while checking execution workspace freshness.`);
return { warnings, currentBaseRefSha: null, branchBaseRefSha: null };
}
const branchBaseRefSha = await runGit(["merge-base", "HEAD", baseRef], input.worktreePath).catch(() => null);
if (!branchBaseRefSha) {
warnings.push(`Could not compare execution workspace ${input.branchName ?? "branch"} against base ref ${baseRef}.`);
return { warnings, currentBaseRefSha, branchBaseRefSha: null };
}
if (branchBaseRefSha !== currentBaseRefSha) {
const behindCountRaw = await runGit(["rev-list", "--count", `HEAD..${baseRef}`], input.worktreePath).catch(() => "");
const behindCount = Number.parseInt(behindCountRaw, 10);
const behindText = Number.isFinite(behindCount) && behindCount > 0
? `${behindCount} commit${behindCount === 1 ? "" : "s"}`
: "newer commits";
const recordedText = input.recordedBaseRefSha
? `recorded base ${formatShortSha(input.recordedBaseRefSha)}`
: `merge-base ${formatShortSha(branchBaseRefSha)}`;
warnings.push(
`Execution workspace branch ${input.branchName ? `"${input.branchName}"` : "HEAD"} is behind ${baseRef} by ${behindText}: ${recordedText}, current base ${formatShortSha(currentBaseRefSha)}. Refresh or rebase the workspace before relying on recent base-branch fixes.`,
);
}
return { warnings, currentBaseRefSha, branchBaseRefSha };
}
type GitWorktreeListEntry = {
worktree: string;
branch: string | null;
@@ -591,22 +691,31 @@ async function isGitCheckout(cwd: string): Promise<boolean> {
}
async function detectDefaultBranch(repoRoot: string): Promise<string | null> {
const originMasterRef = "origin/master";
await refreshRemoteTrackingBaseRef(repoRoot, originMasterRef);
if (await resolveBaseRefSha(repoRoot, originMasterRef)) {
return originMasterRef;
}
// Try the explicit remote HEAD first (set by git clone or git remote set-head)
try {
const remoteHead = await runGit(
["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
repoRoot,
);
const branch = remoteHead?.startsWith("origin/") ? remoteHead.slice("origin/".length) : remoteHead;
if (branch) return branch;
if (remoteHead) {
await refreshRemoteTrackingBaseRef(repoRoot, remoteHead);
if (await resolveBaseRefSha(repoRoot, remoteHead)) return remoteHead;
}
} catch {
// Not set — fall through to heuristic
}
// Fallback: check for common default branch names on the remote
for (const candidate of ["main", "master"]) {
for (const candidate of ["origin/master", "origin/main", "main", "master"]) {
try {
await runGit(["rev-parse", "--verify", `refs/remotes/origin/${candidate}`], repoRoot);
await refreshRemoteTrackingBaseRef(repoRoot, candidate);
await runGit(["rev-parse", "--verify", `${candidate}^{commit}`], repoRoot);
return candidate;
} catch {
// Not found — try next
@@ -1003,6 +1112,7 @@ export async function realizeExecutionWorkspace(input: {
worktreePath: null,
warnings: [],
created: false,
baseRefSha: null,
};
}
@@ -1026,10 +1136,20 @@ export async function realizeExecutionWorkspace(input: {
const baseRef = configuredBaseRef
?? await detectDefaultBranch(repoRoot)
?? "HEAD";
const baseRefreshWarnings = await refreshRemoteTrackingBaseRef(repoRoot, baseRef);
const currentBaseRefSha = await resolveBaseRefSha(repoRoot, baseRef);
await fs.mkdir(worktreeParentDir, { recursive: true });
async function reuseExistingWorktree(reusablePath: string) {
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
repoRoot,
worktreePath: reusablePath,
branchName,
baseRef,
recordedBaseRefSha: null,
skipRefresh: true,
});
if (input.recorder) {
await input.recorder.recordOperation({
phase: "worktree_prepare",
@@ -1039,6 +1159,8 @@ export async function realizeExecutionWorkspace(input: {
worktreePath: reusablePath,
branchName,
baseRef,
currentBaseRefSha: baseDrift.currentBaseRefSha,
branchBaseRefSha: baseDrift.branchBaseRefSha,
created: false,
reused: true,
},
@@ -1066,8 +1188,9 @@ export async function realizeExecutionWorkspace(input: {
cwd: reusablePath,
branchName,
worktreePath: reusablePath,
warnings: [],
warnings: [...baseRefreshWarnings, ...baseDrift.warnings],
created: false,
baseRefSha: baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha,
};
}
@@ -1109,6 +1232,7 @@ export async function realizeExecutionWorkspace(input: {
worktreePath,
branchName,
baseRef,
baseRefSha: currentBaseRefSha,
created: true,
},
successMessage: `Created git worktree at ${worktreePath}\n`,
@@ -1128,6 +1252,7 @@ export async function realizeExecutionWorkspace(input: {
worktreePath,
branchName,
baseRef,
baseRefSha: currentBaseRefSha,
created: false,
reusedExistingBranch: true,
},
@@ -1163,8 +1288,9 @@ export async function realizeExecutionWorkspace(input: {
cwd: worktreePath,
branchName,
worktreePath,
warnings: [],
warnings: baseRefreshWarnings,
created: true,
baseRefSha: currentBaseRefSha,
};
}
@@ -1180,6 +1306,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
repoUrl: string | null | undefined;
baseRef: string | null | undefined;
branchName: string | null | undefined;
metadata?: Record<string, unknown> | null;
config?: {
provisionCommand?: string | null;
} | null;
@@ -1205,15 +1332,26 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
worktreePath: strategy === "git_worktree" ? (input.workspace.providerRef ?? cwd) : null,
warnings: [],
created: false,
baseRefSha: readRecordedBaseRefSha(input.workspace.metadata),
};
const provisionCommand = asString(input.workspace.config?.provisionCommand, "").trim();
if (strategy !== "git_worktree") {
return realized;
}
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
const recordedBaseRefSha = readRecordedBaseRefSha(input.workspace.metadata);
if (await directoryExists(cwd)) {
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
repoRoot,
worktreePath: realized.worktreePath ?? cwd,
branchName: realized.branchName,
baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null,
recordedBaseRefSha,
});
realized.warnings = baseDrift.warnings;
realized.baseRefSha = recordedBaseRefSha ?? baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha;
if (provisionCommand) {
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
await provisionExecutionWorktree({
strategy: {
type: "git_worktree",
@@ -1232,7 +1370,6 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
return realized;
}
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
const worktreePath = realized.worktreePath ?? cwd;
const branchName = asString(input.workspace.branchName, "").trim();
if (!branchName) {
@@ -1241,6 +1378,9 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
await runGit(["worktree", "prune"], repoRoot).catch(() => {});
const restoreBaseRef = input.workspace.baseRef ?? input.base.repoRef ?? null;
const restoreRefreshWarnings = restoreBaseRef ? await refreshRemoteTrackingBaseRef(repoRoot, restoreBaseRef) : [];
const restoreCurrentBaseRefSha = restoreBaseRef ? await resolveBaseRefSha(repoRoot, restoreBaseRef) : null;
let created = false;
try {
@@ -1253,6 +1393,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
worktreePath,
branchName,
baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null,
currentBaseRefSha: restoreCurrentBaseRefSha,
created: false,
restored: true,
},
@@ -1268,6 +1409,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
throw error;
}
const baseRef = input.workspace.baseRef ?? await detectDefaultBranch(repoRoot) ?? "HEAD";
const recreatedBaseRefSha = await resolveBaseRefSha(repoRoot, baseRef);
await recordGitOperation(input.recorder, {
phase: "worktree_prepare",
args: ["worktree", "add", "-b", branchName, worktreePath, baseRef],
@@ -1277,6 +1419,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
worktreePath,
branchName,
baseRef,
baseRefSha: recreatedBaseRefSha,
created: true,
restored: true,
},
@@ -1286,6 +1429,15 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
created = true;
}
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
repoRoot,
worktreePath,
branchName,
baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null,
recordedBaseRefSha,
skipRefresh: true,
});
await provisionExecutionWorktree({
strategy: {
type: "git_worktree",
@@ -1305,7 +1457,12 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
...realized,
cwd: worktreePath,
worktreePath,
warnings: [...restoreRefreshWarnings, ...baseDrift.warnings],
created,
baseRefSha:
recordedBaseRefSha
?? (created ? restoreCurrentBaseRefSha : baseDrift.branchBaseRefSha)
?? baseDrift.currentBaseRefSha,
};
}