Merge remote-tracking branch 'origin/master' into codex/sleep-status-card-evaluation

* origin/master:
  fix(evals): support official TestOps job metadata (#18385)
  fix(config): require shared storage for managed cloud (#18350)
  feat(skills): make managed content independent of web-server disk (#18334)
  Add a portable agent question and answer resumption journey (#18346)
  fix(ci): isolate test selection and require current CI CODEOWNERS (#18376)
  fix(evals): capture TestOps registration after watch (#18375)
  fix(runner): allow session reset after confirmed task retirement (#18373)
  fix(evals): batch task search result readback across renders (#18370)
  fix(ui): keep task plugin views available for manual access (#18363)
  test: wait for the evidence expiry worker to settle (#18241)

# Conflicts:
#	server/src/services/company-skills.ts
#	server/src/services/native-runtime/paperclip-runner-tool-authority.ts
This commit is contained in:
Dotta committed 2026-10-30 17:25:31 -05:00
commit d334f59c98
149 files changed
+6624 -1410

No files matched your search

@@ -25,7 +25,7 @@ export const cloudRuntimeConfigSchema = z.object({
backup: databaseBackupConfigSchema.strict(),
storage: storageConfigSchema.extend({
localDisk: storageLocalDiskConfigSchema.strict(),
s3: storageS3ConfigSchema.extend({ endpoint: publicUrl.optional() }).strict(),
s3: storageS3ConfigSchema.extend({ bucket: z.string().trim().min(1), endpoint: publicUrl.optional() }).strict(),
}).strict(),
secrets: secretsConfigSchema.extend({
localEncrypted: secretsLocalEncryptedConfigSchema.strict(),
@@ -5,6 +5,8 @@ import { eq } from "drizzle-orm";
import { instanceSettings, type Db } from "@paperclipai/db";
import { paperclipConfigSchema } from "@paperclipai/shared";
import { resolvePaperclipConfigPathForInstance } from "@paperclipai/shared/home-paths";
import { validateManagedCloudStoragePrerequisites } from "../managed-cloud-prerequisites.js";
import { loadStorageConfig } from "../storage/config.js";
import { isCloudManagedInstance, type CloudInstanceEnv } from "./cloud-instance.js";
import {
parseStoredCloudRuntimeConfig,
@@ -83,11 +85,18 @@ export async function readLegacyCloudRuntimeConfig(env: CloudInstanceEnv = proce
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new Error("Legacy Cloud runtime configuration could not be read safely");
return null;
}
// Refuse an unconfigured legacy bucket before shared-schema defaults can
// turn its omission into an apparently explicit canonical database value.
const legacyStorage = (raw as { storage?: { provider?: unknown; s3?: { bucket?: unknown } } } | null)?.storage;
if (typeof legacyStorage?.s3?.bucket !== "string" || !legacyStorage.s3.bucket.trim()) {
throw new Error("Legacy Cloud runtime configuration requires an explicit nonblank storage.s3.bucket");
}
const parsed = paperclipConfigSchema.safeParse(raw);
if (!parsed.success) throw new Error("Legacy Cloud runtime configuration is invalid");
if (parsed.data.llm?.provider === "openai" && parsed.data.llm.apiKey?.trim() && !env.OPENAI_API_KEY?.trim()) {
throw new Error("Move the legacy OpenAI API key to OPENAI_API_KEY before initializing Cloud runtime configuration");
}
validateManagedCloudStoragePrerequisites(loadStorageConfig(parsed.data, env), env);
return projectCloudRuntimeConfig(parsed.data);
}
@@ -89,6 +89,7 @@ function decision(
export function normalizeSkillPolicySourceType(sourceType: string | null | undefined): SkillPolicySourceType {
switch (sourceType?.trim().toLowerCase()) {
case "managed":
case "local_path":
case "workspace":
case "project_scan":
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -2607,7 +2607,7 @@ export function heartbeatService(
usageJson: sql`coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb) || ${JSON.stringify({ ledgerScope: runLedgerScope })}::jsonb`,
}).where(eq(heartbeatRuns.id, run.id));
const {
continuationSummary, executionContinuation, paperclipWakePayload, safeWakeComments, safeWakeCommentContext,
continuationSummary, executionContinuation, paperclipWakePayload, safeWakeComments, safeWakeCommentContext, pinnedSkillTest,
} = await timePreparation("task_context", () => prepareHeartbeatTaskContext(db, {
run, agent,
task: { issueRef, issueContext, wakeCommentContext, context, taskSession },
@@ -2662,7 +2662,7 @@ export function heartbeatService(
let nativeChatExpectedCwd = selectedNativeChatExpectedCwd;
const executionConfiguration = await timePreparation("execution_configuration", () => prepareHeartbeatExecutionConfiguration(db, {
run, agent,
task: { issueId, context, responsibleUserId, acceptedPlanContinuationWake, acceptedPlanWakeRoutingDecision, taskProjectContext, routineEnvContext },
task: { issueId, context, responsibleUserId, acceptedPlanContinuationWake, acceptedPlanWakeRoutingDecision, taskProjectContext, routineEnvContext, pinnedSkillTest },
config: { sourceWorkspaceConfig, aiBinding },
policy: { trustPreset, useIsolatedTaskDirectory, explicitTaskDirectory, requestedExecutionWorkspaceMode },
environment: { selectedEnvironmentId, selectedEnvironmentForConfig, resolvedInstanceSettings, persistedNativeExecutionInput },
@@ -63,6 +63,8 @@ export interface HeartbeatExecutionConfigurationInput {
acceptedPlanWakeRoutingDecision: Awaited<ReturnType<typeof resolveAcceptedPlanWakeRoutingDecision>> | null;
taskProjectContext: Pick<typeof projects.$inferSelect, "id" | "env"> | null;
routineEnvContext: Awaited<ReturnType<ReturnType<typeof createHeartbeatRunPreparation>["getRoutineEnvForExecutionIssue"]>>;
/** Resolved from the durable Studio test row by task-context, never wake input. */
pinnedSkillTest?: { key: string; versionId: string } | null;
};
config: {
sourceWorkspaceConfig: Record<string, unknown>;
@@ -88,13 +90,18 @@ export interface HeartbeatExecutionConfigurationInput {
export async function prepareHeartbeatExecutionConfiguration(db: Db, input: HeartbeatExecutionConfigurationInput) {
const { run, agent, onFailureSecretsResolved, onManagedAiRuntimeAcquired, onIdentityRedactorPrepared } = input;
const time = createHeartbeatPreparationTiming(run.id);
const { issueId, context, responsibleUserId, acceptedPlanContinuationWake, acceptedPlanWakeRoutingDecision, taskProjectContext, routineEnvContext } = input.task;
const { issueId, context, responsibleUserId, acceptedPlanContinuationWake, acceptedPlanWakeRoutingDecision, taskProjectContext, routineEnvContext, pinnedSkillTest } = input.task;
const { selectedEnvironmentId, selectedEnvironmentForConfig, resolvedInstanceSettings, persistedNativeExecutionInput } = input.environment;
const sourceWorkspaceConfig = agent.adapterType === "paperclip_runner"
? { ...input.config.sourceWorkspaceConfig,
homeMode: bindHeartbeatRunnerHomeMode(parseObject(agent.adapterConfig), persistedNativeExecutionInput).homeMode }
: input.config.sourceWorkspaceConfig;
const preconfiguredHome = agent.adapterType === "paperclip_runner" && sourceWorkspaceConfig.homeMode === "preconfigured";
if (preconfiguredHome && pinnedSkillTest) {
throw new ConfigurationIncompleteFailure("Skill tests require an isolated Runner home to deliver the saved version.", {
configurationIncomplete: { reason: "skill_unavailable", actionUrl: `/agents/${agent.id}/runtime` },
});
}
const initialHarness = agentHarnessType(agent.adapterType, sourceWorkspaceConfig);
const observabilityKind = (harness: string) => harness === "codex_local" ? "codex" as const
: harness === "claude_local" ? "claude" as const
@@ -133,7 +140,7 @@ export async function prepareHeartbeatExecutionConfiguration(db: Db, input: Hear
const skillActor: AuthorizationActor = { type: "agent", agentId: agent.id, companyId: agent.companyId,
runId: run.id, source: "agent_jwt", onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId };
const runScopedMentionedSkillKeys =
preconfiguredHome ? [] : await time("configuration_mentions", () => resolveRunScopedMentionedSkillKeys({
preconfiguredHome || pinnedSkillTest ? [] : await time("configuration_mentions", () => resolveRunScopedMentionedSkillKeys({
db,
companyId: agent.companyId,
issueId,
@@ -141,7 +148,7 @@ export async function prepareHeartbeatExecutionConfiguration(db: Db, input: Hear
actor: skillActor,
}));
const runScopedSkillKeys =
acceptedPlanContinuationWake &&
!pinnedSkillTest && acceptedPlanContinuationWake &&
!acceptedPlanWakeRoutingDecision?.suppressAcceptedContinuation
? [...runScopedMentionedSkillKeys, ACCEPTED_PLAN_CONVERSION_SKILL_KEY]
: runScopedMentionedSkillKeys;
@@ -237,13 +244,22 @@ export async function prepareHeartbeatExecutionConfiguration(db: Db, input: Hear
}
const storedVersions = parseObject(run.contextSnapshot?.skillVersions);
const hasStoredVersions = Object.prototype.hasOwnProperty.call(run.contextSnapshot ?? {}, "skillVersions");
if (hasStoredVersions && (!run.contextSnapshot?.skillVersions || typeof run.contextSnapshot.skillVersions !== "object"
|| Array.isArray(run.contextSnapshot.skillVersions) || Object.values(storedVersions).some(value =>
typeof value !== "string" || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value)))) {
throw new ConfigurationIncompleteFailure("The saved skill selection is incomplete. Recover its exact versions before retrying.", {
configurationIncomplete: { reason: "skill_unavailable", actionUrl: "/skills" },
});
}
const savedShadowedSkills = Array.isArray(run.contextSnapshot?.shadowedSkills)
? run.contextSnapshot.shadowedSkills.flatMap(value => {
const saved = parseObject(value);
return typeof saved.key === "string" && typeof saved.shadowedBy === "string" && Object.hasOwn(storedVersions, saved.shadowedBy)
? [{ key: saved.key, shadowedBy: saved.shadowedBy }] : [];
}) : [];
const selectedConfig = applyRunScopedMentionedSkillKeys(resolvedConfig, runScopedSkillKeys);
const selectedConfig = pinnedSkillTest
? writePaperclipSkillSyncPreference(resolvedConfig, [pinnedSkillTest])
: applyRunScopedMentionedSkillKeys(resolvedConfig, runScopedSkillKeys);
const { config: effectiveResolvedConfig, skillKeys: environmentSkills, shadowedSkills } = hasStoredVersions
? { config: selectedConfig, skillKeys: [], shadowedSkills: savedShadowedSkills }
: await environmentSkillConfig(db, agent.companyId, selectedEnvironmentForConfig, selectedConfig);
@@ -267,40 +283,55 @@ export async function prepareHeartbeatExecutionConfiguration(db: Db, input: Hear
: skillVersionSelectionMap(runtimeSkillPreference.desiredSkillEntries, {
versionPinsEnabled: resolvedInstanceSettings.experimental.enableBetaSkills === true,
});
// Studio always executes its saved revision, independently of assignment
// pins and the Beta skills preference. Recovery keeps the admitted map.
if (!hasStoredVersions && pinnedSkillTest) versionSelections.set(pinnedSkillTest.key, pinnedSkillTest.versionId);
return await withSkillActor(skillActor, () => companySkills.withRuntimeSkillEntries(agent.companyId, {
versionSelections, snapshotLatest: true,
...(hasStoredVersions && run.contextSnapshot?.skillSelectionSnapshot === true
...(hasStoredVersions
? { onlyKeys: new Set(Object.keys(storedVersions)) }
: { selectionConfig: effectiveResolvedConfig, includeLegacy: agent.adapterType !== "paperclip_runner" }),
selectVersions: async entries => {
const available = hasStoredVersions ? entries.filter(entry => Object.prototype.hasOwnProperty.call(storedVersions, entry.key)) : entries;
const availableKeys = new Set(available.map(entry => entry.key));
if (!hasStoredVersions && pinnedSkillTest && (!availableKeys.has(pinnedSkillTest.key)
|| shadowedSkills?.some(skill => skill.key === pinnedSkillTest.key))) {
throw new ConfigurationIncompleteFailure("The skill test's pinned skill is unavailable or no longer permitted.", {
configurationIncomplete: { reason: "skill_unavailable", actionUrl: "/skills" },
});
}
if (hasStoredVersions && Object.keys(storedVersions).some(key => !availableKeys.has(key))) {
throw new ConfigurationIncompleteFailure("A pinned skill is unavailable or no longer permitted.", {
configurationIncomplete: { reason: "skill_unavailable", actionUrl: "/skills" },
});
}
const desiredKeys = hasStoredVersions
? Object.keys(storedVersions)
: agent.adapterType === "paperclip_runner"
? resolvePaperclipDesiredSkillNames(effectiveResolvedConfig, available)
: resolveLegacyPaperclipDesiredSkillNames(effectiveResolvedConfig, available);
const selectedKeys = new Set(desiredKeys.filter(key => availableKeys.has(key)));
const resolved = available.filter(entry => selectedKeys.has(entry.key));
skillDeliveryConfig = writePaperclipSkillSyncPreference(effectiveResolvedConfig,
resolved.map(entry => ({ key: entry.key, versionId: entry.versionId ?? null })));
if (!hasStoredVersions) {
const skillVersions = Object.fromEntries(resolved.map(entry => [entry.key, entry.versionId ?? entry.currentVersionId ?? null]));
context.skillVersions = skillVersions;
context.skillSelectionSnapshot = true;
await db.update(heartbeatRuns).set({ contextSnapshot: sql`coalesce(${heartbeatRuns.contextSnapshot}, '{}'::jsonb) || ${JSON.stringify({ skillVersions, skillSelectionSnapshot: true, ...(shadowedSkills?.length ? { shadowedSkills } : {}) })}::jsonb` }).where(eq(heartbeatRuns.id, run.id));
}
return selectedKeys;
},
}, async entries => {
const available = hasStoredVersions ? entries.filter(entry => Object.prototype.hasOwnProperty.call(storedVersions, entry.key)) : entries;
const availableKeys = new Set(available.map(entry => entry.key));
const desiredKeys = hasStoredVersions && run.contextSnapshot?.skillSelectionSnapshot === true
? Object.keys(storedVersions)
: agent.adapterType === "paperclip_runner"
? resolvePaperclipDesiredSkillNames(effectiveResolvedConfig, available)
: resolveLegacyPaperclipDesiredSkillNames(effectiveResolvedConfig, available);
const selectedKeys = new Set(desiredKeys.filter(key => availableKeys.has(key)));
const resolved = available.filter(entry => selectedKeys.has(entry.key));
// Optional assignments can outlive project membership. Drop inaccessible
// assignments from delivery; explicit task mentions remain errors below.
skillDeliveryConfig = writePaperclipSkillSyncPreference(effectiveResolvedConfig,
resolved.map(entry => ({ key: entry.key, versionId: entry.versionId ?? null })));
for (const key of [...runScopedMentionedSkillKeys.filter(key => !shadowedSkills?.some(skill => skill.key === key)), ...(hasStoredVersions ? [] : environmentSkills)]) {
const entry = resolved.find(candidate => candidate.key === key);
const entry = entries.find(candidate => candidate.key === key);
if (!entry || entry.sourceStatus === "missing") {
throw new ConfigurationIncompleteFailure("A referenced skill is unavailable. Restore its installed version or update the task's skill reference.", {
configurationIncomplete: { reason: "skill_unavailable", skillKey: key, actionUrl: "/skills" },
});
}
}
if (!hasStoredVersions) {
const skillVersions = Object.fromEntries(resolved.map(entry => [entry.key, entry.versionId ?? entry.currentVersionId ?? null]));
context.skillVersions = skillVersions;
context.skillSelectionSnapshot = true;
await db.update(heartbeatRuns).set({ contextSnapshot: sql`coalesce(${heartbeatRuns.contextSnapshot}, '{}'::jsonb) || ${JSON.stringify({ skillVersions, skillSelectionSnapshot: true, ...(shadowedSkills?.length ? { shadowedSkills } : {}) })}::jsonb` }).where(eq(heartbeatRuns.id, run.id));
}
return resolved;
return entries;
}));
} catch (error) {
if (agent.adapterType === "paperclip_runner") {
@@ -2959,6 +2959,7 @@ export function createHeartbeatRunPreparation(db: Db) {
.select({
testRunId: companySkillTestRuns.id,
skillId: companySkillTestRuns.skillId,
skillKey: companySkillsTable.key,
inputId: companySkillTestRuns.inputId,
skillVersionId: companySkillTestRuns.skillVersionId,
outputDocumentKey: companySkillTestRuns.outputDocumentKey,
@@ -2972,8 +2973,13 @@ export function createHeartbeatRunPreparation(db: Db) {
and(
eq(companySkillVersions.id, companySkillTestRuns.skillVersionId),
eq(companySkillVersions.companyId, companySkillTestRuns.companyId),
eq(companySkillVersions.companySkillId, companySkillTestRuns.skillId),
),
)
.innerJoin(companySkillsTable, and(
eq(companySkillsTable.id, companySkillTestRuns.skillId),
eq(companySkillsTable.companyId, companySkillTestRuns.companyId),
))
.where(
and(
eq(companySkillTestRuns.companyId, companyId),
@@ -3002,6 +3008,7 @@ export function createHeartbeatRunPreparation(db: Db) {
return {
testRunId: row.testRunId,
skillId: row.skillId,
skillKey: row.skillKey,
inputId: row.inputId ?? null,
skillVersionId: row.skillVersionId,
revisionNumber: row.revisionNumber,
+11 -3
View File
@@ -15,7 +15,7 @@ import { getTaskPlanContext } from "../task-plan-context.js";
import { caseExecutionMarkdown, getCaseExecutionContext } from "../case-context.js";
import type { TrustPresetResolution } from "../trust-preset-resolver.js";
import { liveVoiceExecutionGuidance } from "../voice/voice-execution-guidance.js";
import { buildPaperclipWakePayload, PAPERCLIP_WAKE_PAYLOAD_KEY, type createHeartbeatRunPreparation } from "./run-preparation.js";
import { buildPaperclipWakePayload, ConfigurationIncompleteFailure, PAPERCLIP_WAKE_PAYLOAD_KEY, type createHeartbeatRunPreparation } from "./run-preparation.js";
import { buildPaperclipTaskMarkdown } from "./task-markdown.js";
import { workspacePreferenceService } from "../workspace-preferences.js";
@@ -92,10 +92,16 @@ export async function prepareHeartbeatTaskContext(db: Db, input: HeartbeatTaskCo
? { kind: "skill_test", issueId: issueRef.id }
: { kind: "standard" };
}
const isSkillTest = parseObject(context.paperclipAgentJwtScope).kind === "skill_test";
const pinnedSkillTestContext =
issueRef?.workMode === "skill_test"
isSkillTest && issueRef
? await getPinnedSkillTestContext(agent.companyId, issueRef.id)
: null;
if (isSkillTest && !pinnedSkillTestContext) {
throw new ConfigurationIncompleteFailure("The skill test's saved version is unavailable.", {
configurationIncomplete: { reason: "skill_unavailable", actionUrl: "/skills" },
});
}
if (pinnedSkillTestContext) {
context.paperclipSkillTest = {
...pinnedSkillTestContext,
@@ -406,5 +412,7 @@ export async function prepareHeartbeatTaskContext(db: Db, input: HeartbeatTaskCo
} else {
delete context.paperclipTurnContext;
}
return { continuationSummary, executionContinuation, paperclipWakePayload, safeWakeComments, safeWakeCommentContext };
const pinnedSkillTest = pinnedSkillTestContext
? { key: pinnedSkillTestContext.skillKey, versionId: pinnedSkillTestContext.skillVersionId } : null;
return { continuationSummary, executionContinuation, paperclipWakePayload, safeWakeComments, safeWakeCommentContext, pinnedSkillTest };
}
@@ -0,0 +1,157 @@
import { and, eq, sql } from "drizzle-orm";
import type { StorageService } from "../storage/index.js";
import { companies, companySkills, companySkillVersions, companySkillRepositorySnapshots, type Db } from "@paperclipai/db";
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { notifySkillVersionRetention } from "./skill-version-retention-notifications.js";
import { skillPackageStore, skillPackageStorageEnabled, skillVersionFileMap, skillVersionMetadataColumns, type StoredSkillVersion } from "./skill-package-store.js";
import { assertSkillSnapshotPath, MAX_SKILL_SNAPSHOT_BYTES, MAX_SKILL_SNAPSHOT_FILES, skillFileBytes, skillSnapshotHash } from "./skill-snapshot.js";
export type SkillContentTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
export type SkillContentRow = typeof companySkills.$inferSelect;
const contentMetadataFields = ["name", "description", "iconUrl", "color", "tagline", "authorName", "homepageUrl", "categories", "metadata"] as const;
export type SkillContentActor = { type: "agent" | "user" | "system"; agentId?: string | null; userId?: string | null };
export type PreparedSkillContent = {
files: CompanySkillVersionFileInventoryEntry[];
packageSnapshotId: string | null;
packageFileMap: (typeof companySkillVersions.$inferInsert)["packageFileMap"];
implicitFileMap: (typeof companySkillVersions.$inferInsert)["packageFileMap"];
contentHash: string;
};
export type SkillContentPublication = {
expectedVersionId?: string | null;
label?: string | null;
reuseCurrentVersion?: boolean;
beforeLock?: (tx: SkillContentTransaction) => Promise<void>;
validate?: (tx: SkillContentTransaction) => Promise<void>;
afterPublish?: (tx: SkillContentTransaction, skill: SkillContentRow, version: StoredSkillVersion) => Promise<void>;
};
export function managedSkillContent(db: Db, packageDb: Db = db, storage?: StorageService) {
const packages = skillPackageStore(packageDb, storage);
async function read(companyId: string, skillId: string, versionId: string) {
const version = await packages.version(companyId, skillId, versionId);
if (!version) throw notFound("Skill version not found");
return { version, files: await packages.versionFiles(version) };
}
async function prepare(companyId: string, files: CompanySkillVersionFileInventoryEntry[], signal?: AbortSignal): Promise<PreparedSkillContent> {
signal?.throwIfAborted();
const paths = new Set<string>();
let bytes = 0;
for (const file of files) {
try { assertSkillSnapshotPath(file.path); } catch { throw unprocessable("Invalid skill file path."); }
if (paths.has(file.path)) throw unprocessable("Duplicate skill file path.");
paths.add(file.path);
bytes += skillFileBytes(file).length;
}
if (files.length > MAX_SKILL_SNAPSHOT_FILES || bytes > MAX_SKILL_SNAPSHOT_BYTES) {
throw unprocessable("Skill exceeds package size limits.");
}
if (!files.some(file => file.path === "SKILL.md" && file.encoding !== "base64")) {
throw unprocessable("A skill must contain a UTF-8 SKILL.md file.");
}
const packageSnapshotId = skillPackageStorageEnabled() ? await packages.prepare(companyId, files, false, signal) : null;
if (packageSnapshotId && !await packages.verifyDurable(companyId, packageSnapshotId, signal)) {
throw unprocessable("Skill package is not durably available.");
}
return { files, packageSnapshotId, contentHash: skillSnapshotHash(files),
packageFileMap: packageSnapshotId ? skillVersionFileMap(files) : null,
implicitFileMap: packageSnapshotId ? skillVersionFileMap((await packages.manifest(companyId, packageSnapshotId, signal)).files) : null };
}
function fileMapIdentity(files: PreparedSkillContent["packageFileMap"]) {
return JSON.stringify(files?.map(file => ({ path: file.path, sourcePath: file.sourcePath,
kind: file.kind, encoding: file.encoding ?? "utf8" })).sort((left, right) => left.path.localeCompare(right.path)));
}
async function append(tx: SkillContentTransaction, skill: SkillContentRow, candidate: PreparedSkillContent,
actor: SkillContentActor | null, options: SkillContentPublication, fields: Partial<typeof companySkills.$inferInsert> = {}) {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillPackages);
await notifySkillVersionRetention(tx, skill.companyId);
if (candidate.packageSnapshotId) {
const [receipt] = await tx.select({ storage: companySkillRepositorySnapshots.storage })
.from(companySkillRepositorySnapshots).where(and(eq(companySkillRepositorySnapshots.companyId, skill.companyId),
eq(companySkillRepositorySnapshots.id, candidate.packageSnapshotId))).for("update");
if (!receipt?.storage) throw conflict("Skill package changed during publication. Retry the operation.");
}
if (options.reuseCurrentVersion && skill.currentVersionId && candidate.packageSnapshotId) {
const [version] = await tx.select(skillVersionMetadataColumns).from(companySkillVersions).where(and(
eq(companySkillVersions.companyId, skill.companyId), eq(companySkillVersions.companySkillId, skill.id),
eq(companySkillVersions.id, skill.currentVersionId)));
if (version && !version.repositorySnapshotId && version.packageSnapshotId === candidate.packageSnapshotId
&& fileMapIdentity(version.packageFileMap ?? candidate.implicitFileMap) === fileMapIdentity(candidate.packageFileMap)) {
if (!version.packageFileMap) {
await tx.update(companySkillVersions).set({ packageFileMap: candidate.packageFileMap })
.where(and(eq(companySkillVersions.companyId, skill.companyId), eq(companySkillVersions.id, version.id)));
version.packageFileMap = candidate.packageFileMap ?? null;
}
const [saved] = await tx.update(companySkills).set({ ...fields, updatedAt: new Date() })
.where(and(eq(companySkills.companyId, skill.companyId), eq(companySkills.id, skill.id))).returning();
await options.afterPublish?.(tx, saved!, version);
return { skill: saved!, version };
}
}
const [revision] = await tx.select({ next: sql<number>`coalesce(max(${companySkillVersions.revisionNumber}), 0) + 1` })
.from(companySkillVersions).where(and(eq(companySkillVersions.companyId, skill.companyId), eq(companySkillVersions.companySkillId, skill.id)));
const [version] = await tx.insert(companySkillVersions).values({
companyId: skill.companyId, companySkillId: skill.id, revisionNumber: Number(revision!.next),
label: options.label ?? null, packageSnapshotId: candidate.packageSnapshotId, packageFileMap: candidate.packageFileMap,
fileInventory: candidate.packageSnapshotId ? [] : candidate.files,
migrationAt: candidate.packageSnapshotId ? null : new Date(),
authorAgentId: actor?.type === "agent" ? actor.agentId : null,
authorUserId: actor?.type === "user" ? actor.userId : null,
}).returning();
const [saved] = await tx.update(companySkills).set({ ...fields, currentVersionId: version!.id, updatedAt: new Date() })
.where(and(eq(companySkills.companyId, skill.companyId), eq(companySkills.id, skill.id))).returning();
await options.afterPublish?.(tx, saved!, version!);
return { skill: saved!, version: version! };
}
async function publish(base: Pick<SkillContentRow, "id" | "companyId" | "slug" | "key" | "sourceType" | "currentVersionId" | typeof contentMetadataFields[number]> & { accessSourceId?: string | null }, candidate: PreparedSkillContent,
fields: Partial<typeof companySkills.$inferInsert>, actor: SkillContentActor | null = null,
options: SkillContentPublication = {}) {
if (options.expectedVersionId !== undefined && options.expectedVersionId !== base.currentVersionId) {
throw conflict("Skill version changed. Read the current version before retrying.");
}
return db.transaction(async tx => {
await options.beforeLock?.(tx);
for (const slug of [...new Set([base.slug, fields.slug ?? base.slug])].sort()) {
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`${base.companyId}:${slug}`}, 0))`);
}
const [current] = await tx.select().from(companySkills).where(and(eq(companySkills.companyId, base.companyId), eq(companySkills.id, base.id))).for("update");
if (!current) throw notFound("Skill not found");
if (current.currentVersionId !== base.currentVersionId || current.key !== base.key || current.sourceType !== base.sourceType
|| current.accessSourceId !== (base.accessSourceId ?? null)) {
throw conflict("Skill version changed. Read the current version before retrying.");
}
if (contentMetadataFields.some(field => Object.hasOwn(fields, field)
&& JSON.stringify(current[field]) !== JSON.stringify(base[field]))) {
throw conflict("Skill metadata changed. Read the current skill before retrying.");
}
await options.validate?.(tx);
return append(tx, current, candidate, actor, options, fields);
});
}
async function create(values: typeof companySkills.$inferInsert, candidate: PreparedSkillContent,
actor: SkillContentActor | null = null, options: SkillContentPublication = {}) {
return db.transaction(async tx => {
const [company] = await tx.select({ id: companies.id }).from(companies)
.where(eq(companies.id, values.companyId)).for("share");
if (!company) throw notFound("Company not found");
await options.beforeLock?.(tx);
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`${values.companyId}:${values.slug}`}, 0))`);
await options.validate?.(tx);
const [existing] = await tx.select({ id: companySkills.id }).from(companySkills)
.where(and(eq(companySkills.companyId, values.companyId), eq(companySkills.key, values.key)));
if (existing) throw conflict(`A company skill with slug "${values.slug}" already exists.`);
const [skill] = await tx.insert(companySkills).values(values).returning();
return append(tx, skill!, candidate, actor, options);
});
}
return { read, prepare, publish, create };
}
@@ -402,7 +402,7 @@ describe("assigned MCP runner tools", () => {
expect(f.executeTool).not.toHaveBeenCalled();
});
it.each(["planning", "ask"] as const)("exposes and permits only read tools in %s mode", async workMode => {
it.each(["planning", "ask", "skill_test"] as const)("exposes and permits only read tools in %s mode", async workMode => {
const f = fixture([descriptor("read"), descriptor("remember", "write"), descriptor("forget", "destructive")]);
const standard = await createAssignedMcpTools(f);
const restricted = await createAssignedMcpTools({ ...f, workMode });
@@ -423,7 +423,7 @@ describe("assigned MCP runner tools", () => {
for (const [call] of f.executeTool.mock.calls) expect(call).not.toHaveProperty("approvedActionRequestId");
});
it.each(["planning", "ask"] as const)("enforces fresh %s mode without relaxing the pinned mode", async currentMode => {
it.each(["planning", "ask", "skill_test"] as const)("enforces fresh %s mode without relaxing the pinned mode", async currentMode => {
const f = fixture([descriptor("read"), descriptor("remember", "write")]);
const standard = await createAssignedMcpTools(f);
const restricted = await createAssignedMcpTools({ ...f, workMode: currentMode });
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
import type { Db } from "@paperclipai/db";
import { deduplicateToolDescriptors, ToolGatewayHttpError, type ToolGatewayDescriptor, type ToolGatewayService } from "../tool-gateway.js";
type WorkMode = "standard" | "planning" | "ask";
type WorkMode = "standard" | "planning" | "ask" | "skill_test";
type ToolDefinition = Record<string, unknown>;
const SEARCH_TOOL = "paperclip_search_assigned_tools";
@@ -516,7 +516,7 @@ describe("native case tools through authenticated production routes", () => {
expect(review.definitions().map(tool => tool.name)).not.toContain("read_case");
await expect(review.execute({ tool: "update_case", callId: randomUUID(), arguments: { idempotencyKey: "review", title: "No" } })).rejects.toThrow("may only inspect");
await server.db.update(issues).set({ workMode: "skill_test" }).where(eq(issues.id, f.issueId));
await expect(f.call("read_case")).rejects.toThrow("mode_denied");
await expect(f.call("read_case")).rejects.toMatchObject({ status: 403 });
await server.db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, f.issueId));
await instanceSettingsService(server.db).updateExperimental({ enableCases: false });
try { await expect(f.call("read_case")).rejects.toThrow("Cases are disabled"); }
@@ -9,7 +9,7 @@ import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js";
import { buildNativeModelEnvelope, canonicalNativeRuntimeContextDigest } from "../../vendor/paperclip-runner/index.js";
describe("LCA-05 explicit native work mode", () => {
it.each(["standard", "planning", "ask"])("title and description cannot override %s mode", (workMode) => {
it.each(["standard", "planning", "ask", "skill_test"])("title and description cannot override %s mode", (workMode) => {
for (const text of ["Inspect files", "Making a plan", "Create a report", "Research proposal", "Implement now; no plan needed"]) {
const input = buildNativeExecutionInput({
companyId: "10000000-0000-4000-8000-000000000001",
@@ -144,8 +144,8 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { p
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot" | "grok_bot" | "muse"> }): NativeExecutionInputV5 | NativeExecutionInputV7 | NativeExecutionInputV9 | NativeExecutionInputV10;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9 | NativeExecutionInputV10;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9 | NativeExecutionInputV10 {
if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask") {
throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, or ask");
if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask" && input.issue.workMode !== "skill_test") {
throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, ask, or skill_test");
}
const mode = resolvePaperclipRunnerCursorMode(input.provider, input.acpxAgent, input.acpxSessionMode);
const piThinkingLevel = resolvePaperclipRunnerPiThinkingLevel(input.provider, input.acpxAgent, input.piThinkingLevel);
@@ -75,6 +75,7 @@ import { agentInstructionWorkingCopies } from "@paperclipai/db";
import * as noLaunchProofModule from "./native-maintenance-no-launch.js";
import {
NativeSessionCleanupQuarantinedError,
NativeSessionCloseUnrecoverableError,
NativeProviderTerminalFailure,
NativeSessionProtocolIntegrityError,
} from "../../vendor/paperclip-runner/index.js";
@@ -9201,6 +9202,102 @@ describe("native warm session supervision", () => {
await closeIdleWarmNativeSessionsForRestart();
});
it.each(["retired", "unconfirmed", "other-error", "collection-failed", "same-session", "same-id-provider", "unbound", "pending-semantic"] as const)(
"handles a permanently rejected plugin close during reset (%s)", async mode => {
const { base, next, run } = fixture(`identity-poisoned-close-${mode}`);
const identity = { runnerInstanceId: "runner", environmentLeaseId: "lease", runId: base.binding.runId,
normalizedSessionId: base.session.normalizedSessionId!, turnId: "turn", itemId: "item" };
let stopped = false;
const task = vi.fn(async (operation: any) => {
if (operation.kind === "describe") return { kind: "description", taskId: "old-task", launchPreparationVersion: 1,
projectAuthCheckVersion: 1, runner: { version: "fixture", digest: `sha256:${"a".repeat(64)}` } };
if (operation.kind === "stop") stopped = true;
if (operation.kind === "status") return { kind: "status", taskId: "old-task", phase: stopped ? "cancelled" : "running", executionStopped: stopped };
if (operation.kind === "complete" && mode === "unconfirmed") throw new Error("handoff unconfirmed");
return { kind: "accepted", taskId: "old-task" };
});
const createOwner = pluginTaskRuntime.createPluginRunnerTaskOwner;
let owner!: ReturnType<typeof createOwner>;
const ownerFactory = vi.spyOn(pluginTaskRuntime, "createPluginRunnerTaskOwner").mockImplementation(input => {
owner = createOwner({ ...input, adoptIdentity: identity });
return owner;
});
onTestFinished(() => ownerFactory.mockRestore());
// Bind through the production backend so the receipt belongs to this
// exact session object. The task boundary supplies the stop proof.
const backend = await createRunnerdBackend({ db: leaseDb(base),
execution: { ...base, session: { ...base.session, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } } },
runnerInstanceId: "runner", runnerIngressAuthorized: true,
runnerExecutionTarget: { kind: "remote", transport: "sandbox", remoteCwd: ".", leaseId: "lease", runnerTask: task } });
const registration = await owner.registration({} as never, base.binding.runId);
const failure = mode === "other-error" ? new Error("controller retirement unconfirmed") : new NativeSessionCloseUnrecoverableError(undefined, mode !== "pending-semantic");
let closePromise: Promise<void> | undefined;
const close = vi.fn(() => closePromise ??= (async () => {
await registration.release();
await registration.checkpoint("unsettled").catch(() => undefined);
throw failure;
})());
const session = { close };
if (mode !== "unbound") backend.bindManagedSession(session as never);
const collectStopped = vi.fn(async () => {
expect(owner.isRetired()).toBe(true);
if (mode === "collection-failed") throw new Error("file collection failed");
});
const retirementFailed = vi.fn(async () => {});
state.execute.mockReset().mockImplementationOnce(async options => {
await options.onSession?.(session); return result;
}).mockImplementationOnce(async options => {
expect(options.existingSession).toBeUndefined();
expect(collectStopped).toHaveBeenCalled();
return result;
});
try {
await executePaperclipNativeSession({ db: leaseDb(base), execution: base, runnerInstanceId: "runner",
instructionWorkingCopy: { hasChanges: async () => false, collectStopped, retirementFailed } });
// First poison the retained owner through the real configuration
// retirement path. An explicit reset must not need a process restart.
const changed = { ...base, binding: { ...base.binding, runId: `${base.binding.runId}-changed` } };
await expect(executePaperclipNativeSession({ db: leaseDb(changed), execution: changed,
runnerInstanceId: "runner", refreshTools: true })).rejects.toThrow(failure.message);
expect(close).toHaveBeenCalledOnce();
expect(collectStopped).not.toHaveBeenCalled();
expect(retirementFailed).toHaveBeenCalledOnce();
retirementFailed.mockClear();
const replacement = mode === "same-session" ? { ...next, session: base.session }
: mode === "same-id-provider" ? { ...next, session: base.session,
provider: { kind: "opencode" as const, model: "openai/test", permissionMode: "allow" as const } } : next;
const attempt = () => mode === "same-session"
? executePaperclipNativeSession({ db: leaseDb(replacement), execution: replacement, runnerInstanceId: "runner", refreshTools: true })
: run(replacement);
if (mode === "retired") {
await attempt();
expect(state.execute).toHaveBeenCalledTimes(2);
expect(retirementFailed).not.toHaveBeenCalled();
} else {
// Both attempts see the same rejected promise, just as the real
// transport does; no mock succeeds on the second close.
const expected = mode === "collection-failed" ? "file collection failed" : failure.message;
await expect(attempt()).rejects.toThrow(expected);
await expect(attempt()).rejects.toThrow(expected);
expect(state.execute).toHaveBeenCalledTimes(1);
if (mode !== "collection-failed") expect(collectStopped).not.toHaveBeenCalled();
else {
collectStopped.mockResolvedValue(undefined);
await attempt();
expect(state.execute).toHaveBeenCalledTimes(2);
}
}
expect(task.mock.calls.filter(([operation]) => operation.kind === "stop")).toHaveLength(1);
expect(task.mock.calls.some(([operation]) => operation.kind === "read_state")).toBe(false);
} finally {
collectStopped.mockResolvedValue(undefined);
close.mockResolvedValue(undefined);
await closeIdleWarmNativeSessionsForRestart();
ownerFactory.mockRestore();
}
},
);
it("retains failed retirement for retry and never launches over an uncontained owner", async () => {
const { base, next, run } = fixture("identity-close-failure");
const close = vi.fn().mockRejectedValueOnce(new Error("containment unavailable")).mockResolvedValue(undefined);
@@ -12675,7 +12772,7 @@ describe("runnerd provider runtime wiring", () => {
const driver = { prepareLaunch: vi.fn(), runnerProcessLauncher: launcher, controlPlaneRegistration: registration };
state.toolAuthorityDefinitions.mockImplementation(async binding => binding.readRemoteTaskFile ? [{ name: "register_deliverable" }] as never : []);
const readWorkspaceFile = vi.fn(async () => Buffer.from("proof"));
const owner = vi.spyOn(pluginTaskRuntime, "createPluginRunnerTaskOwner").mockReturnValue({ launcher, registration, driver, readWorkspaceFile, rebind:vi.fn() });
const owner = vi.spyOn(pluginTaskRuntime, "createPluginRunnerTaskOwner").mockReturnValue({ launcher, registration, driver, readWorkspaceFile, isRetired: () => false, rebind:vi.fn() });
const task = vi.fn(async (operation: unknown) => {
environmentTaskOperationSchema.parse(operation);
return { kind: "description", launchPreparationVersion: 1, projectAuthCheckVersion: 1, ...(kind === "codex" ? { workspaceFileReadVersion: 1 } : {}), taskId: "task", runner: { version: "fixture", digest: `sha256:${"a".repeat(64)}` } };
@@ -1,3 +1,4 @@
import { rehydratePinnedSkillAssets } from "./runtime-context.js";
import { createNativeUsageDelivery } from "./native-usage-delivery.js";
import { createNativeTurnAccounting, nativeUsageCostUsd, nativeUsageBiller, normalizeNativeUsage, usesNativeUsageDelivery, isNativeUsageAccountingEvent } from "./native-usage-accounting.js";
export { nativeUsageCostUsd, nativeUsageBiller, normalizeNativeUsage } from "./native-usage-accounting.js";
@@ -127,6 +128,7 @@ import type {
import {
NativeProviderTerminalFailure,
NativeSessionCleanupQuarantinedError,
NativeSessionCloseUnrecoverableError,
NativeSessionProtocolIntegrityError,
completeRetainedNativeSessionCleanup,
completeTerminatedLocalNativeSessionCleanup,
@@ -581,6 +583,7 @@ type WarmNativeSession = {
configDigest: string;
configuredEnvironmentDigest: string;
ownerScope: string;
normalizedSessionId: string;
companyId: string;
environmentId: string | null;
busy: boolean;
@@ -589,18 +592,31 @@ type WarmNativeSession = {
lastActivityAt: string;
};
async function closeWarmNativeSession(entry: WarmNativeSession, reason: string, preserveInstructionsForRunId?: string) {
async function closeWarmNativeSession(entry: WarmNativeSession, reason: string, options?: {
preserveInstructionsForRunId?: string;
discardUnsettledSession?: boolean;
}) {
// Revoke before awaiting process retirement/checkpoint IO.
const stopping = entry.githubAccess?.stop();
try {
try { await entry.session.close({ reason }); }
catch (error) {
try { await entry.instructionWorkingCopy?.retirementFailed?.(); }
catch (receiptError) { throw new AggregateError([error, receiptError], "Warm instruction retirement and receipt both failed"); }
throw error;
// A new conversation does not consume the old checkpoint. Its failed
// suspension must not poison every later reset after Launcher has proved
// that the exact task stopped and completed its credential handoff.
// Other close errors can mean controller authority is still live.
const retiredWithoutCheckpoint = options?.discardUnsettledSession === true
&& error instanceof NativeSessionCloseUnrecoverableError
&& error.semanticToolsSettled
&& pluginTaskSessionOwners.get(entry.session)?.isRetired() === true;
if (!retiredWithoutCheckpoint) {
try { await entry.instructionWorkingCopy?.retirementFailed?.(); }
catch (receiptError) { throw new AggregateError([error, receiptError], "Warm instruction retirement and receipt both failed"); }
throw error;
}
}
await entry.instructionWorkingCopy?.collectStopped();
if (entry.instructionCopy?.runId !== preserveInstructionsForRunId) await entry.instructionCopy?.collectStopped();
if (entry.instructionCopy?.runId !== options?.preserveInstructionsForRunId) await entry.instructionCopy?.collectStopped();
}
finally { await stopping; }
}
@@ -1915,6 +1931,7 @@ function nativeSessionOwnerScope(
async function retireSupersededWarmNativeSessions(
ownerScope: string,
nextSessionScope: string,
nextSessionId: string,
): Promise<void> {
for (const [scope, entry] of warmNativeSessions) {
if (scope === nextSessionScope || entry.ownerScope !== ownerScope) continue;
@@ -1926,7 +1943,9 @@ async function retireSupersededWarmNativeSessions(
entry.busy = true;
entry.closeOnReleaseReason = "warm native session identity changed";
try {
await closeWarmNativeSession(entry, entry.closeOnReleaseReason);
await closeWarmNativeSession(entry, entry.closeOnReleaseReason, {
discardUnsettledSession: entry.normalizedSessionId !== nextSessionId,
});
forgetWarmNativeSession(scope, entry);
} finally {
// A failed close retains the owner and prevents launch. A later attempt
@@ -7939,7 +7958,7 @@ export async function executePaperclipNativeSession(input: {
}
executingNativeOwnerScopes.set(ownerScope, ownerToken);
await retireSupersededWarmNativeSessions(
ownerScope, nativeSessionScopeKey(input.execution),
ownerScope, nativeSessionScopeKey(input.execution), nativeSessionKey(input.execution),
);
if ((input.execution.schema === "paperclip.native-execution-input.v6" || input.execution.schema === "paperclip.native-execution-input.v8") && isCloudManagedInstance()
&& (!input.useRunnerd || input.runnerExecutionTarget?.kind !== "remote"
@@ -9025,7 +9044,7 @@ async function executePaperclipNativeSessionWithinScope(
entry.busy = true;
entry.closeOnReleaseReason = "warm native session configuration changed";
try {
await closeWarmNativeSession(entry, entry.closeOnReleaseReason, input.execution.binding.runId);
await closeWarmNativeSession(entry, entry.closeOnReleaseReason, { preserveInstructionsForRunId: input.execution.binding.runId });
forgetWarmNativeSession(warmSessionId, entry);
} finally { entry.busy = false; }
if (preparedCopy) throw new Error("native_instruction_preparation_configuration_changed");
@@ -9481,6 +9500,7 @@ async function executePaperclipNativeSessionWithinScope(
ownerScope: nativeSessionOwnerScope(
input.execution, input.runnerExecutionTarget?.environmentId ?? null,
),
normalizedSessionId: nativeSessionKey(input.execution),
companyId: input.execution.binding.companyId,
environmentId:
input.runnerExecutionTarget?.environmentId ?? null,
@@ -12382,8 +12402,9 @@ async function createRunnerdBackendWithinSessionClaim(
: remoteRunnerFilesystemRoot
? posix.join(remoteRunnerFilesystemRoot, directory.name)
: null;
const sourceRuntimeContext =
"runtimeContext" in input.execution ? input.execution.runtimeContext : null;
const sourceRuntimeContext = "runtimeContext" in input.execution
? await rehydratePinnedSkillAssets(input.db, input.execution.binding, input.execution.runtimeContext)
: null;
const remoteRuntimeContext: NativeRuntimeContextSnapshot | null =
remoteRunnerFilesystemRoot && sourceRuntimeContext
? {
@@ -0,0 +1,66 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { agentWakeupRequests, heartbeatRuns, issues } from "@paperclipai/db";
import { startRunnerApiTestServer } from "../../__tests__/helpers/runner-api-server.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
describe("native Studio test authorization", () => {
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
const oldSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
beforeAll(async () => {
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
server = await startRunnerApiTestServer();
}, 60_000);
afterAll(async () => {
await server?.close();
if (oldSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
else process.env.PAPERCLIP_AGENT_JWT_SECRET = oldSecret;
});
it.each(["skill_test", "standard"] as const)("keeps admitted tests task-scoped with current mode %s", async workMode => {
const fixture = await server.fixture();
await server.db.update(issues).set({ workMode }).where(eq(issues.id, fixture.issueId));
await server.db.update(issues).set({ parentId: fixture.issueId }).where(eq(issues.id, fixture.blockerId));
await server.db.update(heartbeatRuns).set({ contextSnapshot: {
issueId: fixture.issueId, paperclipAgentJwtScope: { kind: "skill_test", issueId: fixture.issueId },
} }).where(eq(heartbeatRuns.id, fixture.runId));
const authority = new PaperclipRunnerToolAuthority(server.db, { ...fixture, workMode });
const call = (tool: string, args: unknown = {}) => ({ tool, callId: randomUUID(), arguments: args });
const before = await fixture.snapshot();
const wakes = await server.db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, fixture.companyId));
const create = call("create_task", { title: "Must not escape Studio", idempotencyKey: "denied-create", assigneeActorId: fixture.agentId });
for (const denied of [create, call("search_tasks"), call("list_agents"), call("list_people"), call("list_approvals"),
call("get_agent", { actorId: fixture.agentId }), call("connection_request", { service: "notion" })]) {
await expect(authority.execute(denied)).rejects.toMatchObject({ status: 403 });
await expect(authority.recover(denied)).rejects.toMatchObject({ status: 403 });
}
expect((await fixture.snapshot()).issues).toEqual(before.issues);
expect(await server.db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, fixture.companyId))).toEqual(wakes);
const context = await authority.execute(call("get_task_context"));
expect(context).toMatchObject({ activeTask: { id: fixture.issueId }, childTasks: [], childReviewOutcomes: [] });
expect(JSON.stringify(context)).not.toContain(fixture.blockerId);
const write = call("write_document", { key: "proof", title: "Studio evidence", body: "Executed the delivered skill", idempotencyKey: "own-proof" });
const receipt = await authority.execute(write);
expect(await authority.recover(write)).toEqual({ result: receipt });
expect(await authority.execute(call("read_document", { key: "proof" }))).toMatchObject({ document: { body: "Executed the delivered skill" } });
expect(await authority.execute(call("get_task", { taskId: fixture.issueId }))).toMatchObject({ ok: true, status: 200, data: { id: fixture.issueId } });
expect(await authority.execute(call("get_task", { taskId: fixture.blockerId }))).toMatchObject({ ok: false, status: 404 });
if (workMode === "skill_test") {
const names = authority.definitions().map(tool => tool.name);
expect(names).toContain("write_document");
expect(names).toContain("get_task_context");
expect(names).not.toContain("create_task");
expect(names).not.toContain("list_agents");
expect(names).not.toContain("connection_request");
}
});
it("still creates ordinary tasks for a standard run", async () => {
const fixture = await server.fixture({ disableWakeOnDemand: true });
await fixture.authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
title: "Normal child task", idempotencyKey: "standard-create", assigneeActorId: fixture.agentId,
} });
expect((await fixture.snapshot()).issues.filter(issue => issue.title === "Normal child task")).toHaveLength(1);
});
});
@@ -0,0 +1,39 @@
import type { AgentApiKeyScope } from "@paperclipai/shared";
import { forbidden } from "../../errors.js";
const taskTools = new Set([
"get_identity", "get_task_context", "get_task_history", "get_task",
"list_documents", "read_document", "list_document_revisions", "write_document",
"list_task_documents", "read_task_document", "write_task_document", "comment_on_task",
"report_progress", "set_task_title", "request_human_input", "register_deliverable",
"list_assigned_skills", "read_assigned_skill", "search_api", "call_api",
"read_current_wake_comments", "list_chat_attachments", "read_chat_attachment",
"reuse_chat_attachment", "list_task_attachments", "read_task_attachment",
]);
const record = (value: unknown): Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> : {};
export function nativeRunKeyScope(input: {
contextSnapshot?: unknown;
issueId: string;
workMode?: string;
}): AgentApiKeyScope {
const snapshot = record(input.contextSnapshot);
const admitted = record(snapshot.paperclipAgentJwtScope);
const skillTest = admitted.kind === "skill_test" || (admitted.kind !== "standard"
&& (Boolean(snapshot.paperclipSkillTest) || input.workMode === "skill_test"
|| record(snapshot.paperclipIssue).workMode === "skill_test"));
return skillTest ? { kind: "skill_test", issueId: input.issueId } : { kind: "standard" };
}
export function nativeSkillTestToolAllowed(tool: string) {
return taskTools.has(tool);
}
export function assertNativeSkillTestTool(scope: AgentApiKeyScope, tool: string) {
if (scope.kind === "skill_test" && !nativeSkillTestToolAllowed(tool)) {
throw forbidden("Skill-test runs may only use their own harness task and admitted skill contents.");
}
}
@@ -0,0 +1,94 @@
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
activityLog, agents, companies, companySkills, companySkillVersions, companySkillTestRuns,
completionContracts, createDb, documents, heartbeatRuns, issueDocuments, issues,
nativeRunFinalizations, nativeRunResults, workAssessments, type Db,
} from "@paperclipai/db";
import { recoveryPostgresSupport, startRecoveryPostgres } from "../../__tests__/helpers/recovery-postgres.js";
import { commitNativeStatusDecision } from "./status-decision-committer.js";
import { NATIVE_STATUS_ARBITER_POLICY_VERSION } from "./status-arbiter.js";
describe.skipIf(!recoveryPostgresSupport.supported)("native Studio status publication", () => {
let database: Awaited<ReturnType<typeof startRecoveryPostgres>>, db: Db;
beforeAll(async () => {
database = await startRecoveryPostgres("native-skill-test-status-");
db = createDb(database.connectionString);
}, 60_000);
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
async function seed() {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
const skillId = randomUUID(), versionId = randomUUID(), testId = randomUUID(), documentId = randomUUID();
const contractId = randomUUID(), resultId = randomUUID(), assessmentId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Studio status", issuePrefix: `S${companyId.slice(0, 7)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Tester", adapterType: "paperclip_runner" });
await db.insert(companySkills).values({ id: skillId, companyId, key: "company/test", slug: "test", name: "Test", markdown: "# Test", sourceType: "managed" });
await db.insert(companySkillVersions).values({ id: versionId, companyId, companySkillId: skillId, revisionNumber: 1 });
// Task mode can change after admission; the saved Studio obligation survives.
await db.insert(issues).values({ id: issueId, companyId, title: "Studio output", status: "in_progress", assigneeAgentId: agentId });
await db.insert(companySkillTestRuns).values({ id: testId, companyId, skillId, skillVersionId: versionId, agentId, issueId, inputSnapshot: "Execute the skill" });
await db.insert(documents).values({ id: documentId, companyId, latestBody: "Verified the selected immutable bytes." });
await db.insert(issueDocuments).values({ companyId, issueId, documentId, key: "output" });
await db.insert(completionContracts).values({ id: contractId, companyId, issueId, revision: 1, schemaVersion: "paperclip.completion-contract.v1",
policyVersion: "studio-test", risk: "standard", completionAuthority: "server_arbiter", incompleteCriteriaPolicy: "preserve_non_terminal",
contractJson: { revision: "1", criteria: [] }, canonicalSha256: randomUUID(), createdByActorType: "system", createdByActorId: "test" });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: issueId,
completionContractId: contractId, contextSnapshot: { issueId, paperclipAgentJwtScope: { kind: "skill_test", issueId } } });
await db.insert(nativeRunResults).values({ id: resultId, companyId, issueId, runId, completionContractId: contractId,
serverFingerprint: randomUUID(), schemaStatus: "accepted", resultJson: {}, canonicalSha256: randomUUID() });
const [task] = await db.select().from(issues).where(eq(issues.id, issueId));
await db.insert(workAssessments).values({ id: assessmentId, companyId, issueId, runId, contractId, resultId, triggerKind: "native_result",
triggerActorCompanyId: companyId, priorIssueStatus: task.status, priorStatusVersion: task.statusVersion,
policyVersion: "studio-test", assessmentJson: {}, inputDigest: randomUUID() });
await db.insert(nativeRunFinalizations).values({ runId, companyId, issueId, phase: "arbitrating", resultId });
const read = async () => (await db.select().from(companySkillTestRuns).where(eq(companySkillTestRuns.id, testId)))[0]!;
const audit = () => db.select().from(activityLog).where(and(eq(activityLog.companyId, companyId), eq(activityLog.action, "company.skill_test_run_completed")));
const commit = (status: "done" | "cancelled" | "blocked", executor: Db = db) => commitNativeStatusDecision({ db: executor,
companyId, issueId, runId, assessmentId, priorStatus: task.status, priorStatusVersion: task.statusVersion, priorDecisionId: task.lastStatusDecisionId,
decision: { policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, statusAction: status, toStatus: status,
reasonCode: "studio_status_test", unblockDescriptor: null, effects: [] } });
return { companyId, issueId, testId, documentId, read, audit, commit };
}
it.each(["done", "cancelled"] as const)("atomically publishes %s and its output once across replay", async status => {
const f = await seed();
await f.commit(status);
const saved = await f.read();
expect(saved).toMatchObject({ status: status === "done" ? "succeeded" : "cancelled", outputSnapshot: "Verified the selected immutable bytes." });
expect(await f.audit()).toHaveLength(1);
await db.update(documents).set({ latestBody: "Changed after completion" }).where(eq(documents.id, f.documentId));
const reopened = createDb(database.connectionString);
try { expect((await f.commit(status, reopened)).replayed).toBe(true); }
finally { await reopened.$client.end(); }
expect(await f.read()).toEqual(saved);
expect(await f.audit()).toHaveLength(1);
});
it("rolls back task, Studio output, and audit together", async () => {
const f = await seed();
await expect(db.transaction(async tx => {
await f.commit("done", tx as unknown as Db);
expect((await tx.select().from(companySkillTestRuns).where(eq(companySkillTestRuns.id, f.testId)))[0].status).toBe("succeeded");
throw new Error("rollback Studio fixture");
})).rejects.toThrow("rollback Studio fixture");
expect(await f.read()).toMatchObject({ status: "queued", outputSnapshot: "" });
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("in_progress");
expect(await f.audit()).toHaveLength(0);
await f.commit("done");
expect((await f.read()).status).toBe("succeeded");
});
it("keeps blocked tests pending and does not revive superseded tests", async () => {
const blocked = await seed();
await blocked.commit("blocked");
expect((await blocked.read()).status).toBe("queued");
expect(await blocked.audit()).toHaveLength(0);
const superseded = await seed();
await db.update(companySkillTestRuns).set({ supersededAt: new Date() }).where(eq(companySkillTestRuns.id, superseded.testId));
await superseded.commit("done");
expect((await superseded.read()).status).toBe("queued");
expect(await superseded.audit()).toHaveLength(0);
});
});
@@ -1,4 +1,5 @@
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
import { nativeRunKeyScope, nativeSkillTestToolAllowed, assertNativeSkillTestTool } from "./native-skill-test-scope.js";
import { requiredQualityReviewSchema } from "@paperclipai/shared";
import { priorRecoveryApiReceipt } from "../recovery-effect-receipts.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
@@ -185,7 +186,7 @@ type Binding = {
connectorAssignments?: Pick<ConnectorAssignment, "tools">[];
assignedMcpTools?: Awaited<ReturnType<typeof createAssignedMcpTools>>;
apiToolsEnabled?: boolean;
workMode?: "standard" | "planning" | "ask";
workMode?: "standard" | "planning" | "ask" | "skill_test";
casesEnabled?: boolean;
workspaceRoot?: string;
workspaceBridge?: boolean;
@@ -390,14 +391,15 @@ export class PaperclipRunnerToolAuthority {
if (!this.binding.firstPartyOnly && this.binding.assignedMcpTools) definitions.push(READ_TOOL_RESULT_DEFINITION);
const assignedTools = this.binding.firstPartyOnly ? [] : this.binding.assignedMcpTools?.definitions((tools) =>
runnerCodexDynamicToolsFit([...connectionTools, ...tools, ...definitions])) ?? [];
const all = [...connectionTools, ...assignedTools, ...definitions];
const all = [...connectionTools, ...assignedTools, ...definitions]
.filter(tool => workMode !== "skill_test" || nativeSkillTestToolAllowed(String(tool.name)));
return this.binding.museRuntime ? all.filter(definition => MUSE_NATIVE_OPERATIONS.has(String(definition.name))) : all;
}
async recover(call: Parameters<PaperclipRunnerToolAuthority["execute"]>[0]): Promise<{ result: unknown; isError?: boolean } | null> {
await this.#assertToolScope(call.tool);
if (!this.binding.museRuntime) await this.binding.assertBridgeAuthority?.();
const context = await this.#boundContext();
const context = await this.#boundContext({ tool: call.tool });
const { run } = context;
if (RECOVERABLE_READ_OPERATIONS.has(call.tool)) return this.#recoverRead(call);
if (call.tool === "update_case" || call.tool === "create_workflow" || call.tool === "update_workflow") {
@@ -532,6 +534,7 @@ export class PaperclipRunnerToolAuthority {
const [run] = await this.db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, this.binding.runId),
eq(heartbeatRuns.companyId, this.binding.companyId), eq(heartbeatRuns.agentId, this.binding.agentId)));
if (!run) throw forbidden("Run binding unavailable");
assertNativeSkillTestTool(nativeRunKeyScope({ ...this.binding, contextSnapshot: run.contextSnapshot }), call.tool);
const request = create ? {
idempotencyKey: create.idempotencyKey,
case: { create: { caseType: create.caseType, title: create.title,
@@ -552,7 +555,7 @@ export class PaperclipRunnerToolAuthority {
if (this.binding.nativeReview && call.tool === "resolve_review") return this.#resolveReview(call.arguments);
if (Object.hasOwn(TASK_ATTACHMENT_SCHEMAS, call.tool)) {
const authorize = async () => {
const current = await this.#boundContext();
const current = await this.#boundContext({ tool: call.tool });
if (!this.binding.taskAttachmentRead || current.actor.adapterType !== "paperclip_runner"
|| current.actor.adapterConfig?.provider !== "openai_dot" || current.actor.adapterConfig?.dotAttachmentAccess !== true)
throw forbidden("Dot task attachment reading was not enabled or was disabled");
@@ -570,13 +573,13 @@ export class PaperclipRunnerToolAuthority {
});
}
if (RUNNER_BRIDGE_SCHEMAS.has(call.tool)) {
await this.#boundContext();
await this.#boundContext({ tool: call.tool });
if (!this.definitions().some(definition => definition.name === call.tool)) throw forbidden("Tool is not available to this run");
const parsed = RUNNER_BRIDGE_SCHEMAS.get(call.tool)!.safeParse(call.arguments);
if (!parsed.success) return { outcome: "failed", code: "runner_bridge_invalid_arguments", message: "Arguments do not match this tool's advertised schema." };
const input = parsed.data as Record<string, any>;
if (call.tool.startsWith("workspace_")) {
const authorize = async () => { const current = await this.#boundContext();
const authorize = async () => { const current = await this.#boundContext({ tool: call.tool });
if (current.actor.adapterConfig?.dotWorkspaceAccess !== true) throw forbidden("Dot workspace access was disabled");
await this.binding.assertBridgeAuthority?.();
if (["workspace_write", "workspace_run"].includes(call.tool) && current.issue.workMode !== "standard") throw forbidden("This work mode cannot change files or run commands");
@@ -586,7 +589,7 @@ export class PaperclipRunnerToolAuthority {
return ["workspace_write", "workspace_run"].includes(call.tool) ? this.#bridgeMutation(call, execute) : settleRunnerBridgeRead(execute);
}
if (call.tool === "get_identity") {
const { actor, run } = await this.#boundContext();
const { actor, run } = await this.#boundContext({ tool: call.tool });
const [person] = run.responsibleUserId ? await this.db.select({ id: authUsers.id, name: authUsers.name }).from(authUsers).where(eq(authUsers.id, run.responsibleUserId)) : [];
return { agent: redactedActor(actor), responsibleUser: person ?? null, permissions: actor.permissions, tools: this.definitions().map(tool => tool.name) };
}
@@ -595,7 +598,7 @@ export class PaperclipRunnerToolAuthority {
.where(and(eq(companyMemberships.companyId, this.binding.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.status, "active"), input.after ? sql`${authUsers.id} > ${input.after}` : undefined))
.orderBy(authUsers.id).limit(input.limit);
if (call.tool === "list_assigned_skills" || call.tool === "read_assigned_skill") {
const current = await this.#boundContext();
const current = await this.#boundContext({ tool: call.tool });
const visibility = await skillAccessService(this.db, this.#privacyActor(current.run)).condition(this.binding.companyId);
const known = await this.db.select({ key: companySkills.key }).from(companySkills).where(eq(companySkills.companyId, this.binding.companyId));
const permitted = await this.db.select({ key: companySkills.key }).from(companySkills).where(and(eq(companySkills.companyId, this.binding.companyId), visibility));
@@ -611,21 +614,24 @@ export class PaperclipRunnerToolAuthority {
}
if (this.binding.firstPartyOnly && (call.tool === READ_TOOL_RESULT || this.binding.assignedMcpTools?.has(call.tool) || isConnectorTool(call.tool)
|| RUNTIME_CONNECTION_TOOL_DEFINITIONS.some(tool => tool.name === call.tool))) throw forbidden("This Grok Bot connection exposes first-party Paperclip operations. Use your own provider apps under their existing permissions.");
if (call.tool === READ_TOOL_RESULT) return this.#readToolResult(call.arguments);
if (call.tool === READ_TOOL_RESULT) {
await this.#boundContext({ tool: call.tool });
return this.#readToolResult(call.arguments);
}
if (this.binding.assignedMcpTools?.has(call.tool)) {
const current = await this.#boundContext();
const current = await this.#boundContext({ tool: call.tool });
const result = await this.binding.assignedMcpTools.execute(call, current.issue.workMode as "standard" | "planning" | "ask");
return captureRunnerToolResult(result, this.#responseCaptureIo("runner.assigned_tool"));
}
if (isConnectorTool(call.tool)) {
if (!(this.binding.connectorAssignments ?? []).some((assignment) => assignment.tools.some((tool) => tool.name === call.tool))) throw forbidden("Connector tool is not available to this run");
const { run } = await this.#boundContext({ includeChatWake: call.tool.startsWith("agentmail_") });
const { run } = await this.#boundContext({ tool: call.tool, includeChatWake: call.tool.startsWith("agentmail_") });
const snapshot = record(run.contextSnapshot);
if (call.tool.startsWith("agentmail_") && (isPaperclipExternalChatContractTurn(snapshot.paperclipWake) || String(snapshot.source ?? "").startsWith("chat:") || snapshot.paperclipExternalChatQuestionResponse)) throw forbidden("Restricted chat runs cannot use email actions");
return executeConnectorTool(this.db, this.binding, call.tool, call.arguments);
}
if (RUNTIME_CONNECTION_TOOL_DEFINITIONS.some((tool) => tool.name === call.tool)) {
await this.#boundContext();
await this.#boundContext({ tool: call.tool });
const { run } = await captureRunIdentity(this.db, this.binding, { readScope: "identity" });
if (!run.responsibleUserId) throw forbidden("This task needs a responsible user before requesting a connection");
const claims = {
@@ -678,7 +684,7 @@ export class PaperclipRunnerToolAuthority {
) {
throw new Error("paperclip_runner_tool_not_advertised");
}
const context = await this.#boundContext();
const context = await this.#boundContext({ tool: call.tool });
const input = record(call.arguments);
if (call.tool === READ_CHAT_ATTACHMENT_TOOL_NAME) {
const scope = this.binding.chatAttachmentReadScope;
@@ -801,6 +807,11 @@ export class PaperclipRunnerToolAuthority {
return apiToolResult(await this.#callApi(call.callId, call.arguments));
}
case "get_task_context": {
if (this.#apiScope(context).kind === "skill_test") return {
company: { id: this.binding.companyId }, actor: redactedActor(context.actor),
activeTask: redactedTask(context.issue), childTasks: [], childReviewOutcomes: [],
run: { id: context.run.id, status: context.run.status, invocationSource: context.run.invocationSource },
};
const monitorCheckAt = !this.binding.nativeReview && context.issue.workMode === "standard" &&
context.issue.executionRunId === this.binding.runId
? eligibleIssueMonitorWait(context.issue, this.binding.agentId) : null;
@@ -995,11 +1006,8 @@ export class PaperclipRunnerToolAuthority {
issue: { workMode: string };
run: { contextSnapshot: unknown };
}) {
const snapshot = record(context.run.contextSnapshot);
const admittedKind = record(snapshot.paperclipAgentJwtScope).kind;
const skillTest = admittedKind === "skill_test" || (admittedKind !== "standard"
&& (Boolean(snapshot.paperclipSkillTest) || context.issue.workMode === "skill_test"));
return skillTest ? { kind: "skill_test" as const, issueId: this.binding.issueId } : { kind: "standard" as const };
return nativeRunKeyScope({ contextSnapshot: context.run.contextSnapshot,
issueId: this.binding.issueId, workMode: context.issue.workMode });
}
#apiToken(context: {
@@ -1371,13 +1379,14 @@ export class PaperclipRunnerToolAuthority {
return outcomes.filter(outcome => { const id = taskId(outcome); return typeof id === "string" && readable.has(id); });
}
#privacyActor(run: { responsibleUserId: string | null }): AuthorizationActor {
#privacyActor(run: { responsibleUserId: string | null; contextSnapshot?: unknown }): AuthorizationActor {
return { type: "agent", agentId: this.binding.agentId, companyId: this.binding.companyId,
runId: this.binding.runId, source: "agent_jwt", onBehalfOfUserId: run.responsibleUserId,
keyScope: nativeRunKeyScope({ ...this.binding, contextSnapshot: run.contextSnapshot }),
...(this.binding.museRuntime ? { authorizingUserId: this.binding.externalAuthorizingUserId } : {}) };
}
async #boundContext(options: { includeChatWake?: boolean } = {}) {
async #boundContext(options: { includeChatWake?: boolean; tool?: string } = {}) {
if (this.binding.museRuntime && !this.binding.externalAuthorizingUserId) throw forbidden("Muse authorizer is unavailable");
const [row] = await this.db.select({ issue: issues, actor: agents, run: {
id: heartbeatRuns.id,
@@ -1439,6 +1448,7 @@ export class PaperclipRunnerToolAuthority {
const privacy = await authorizationService(this.db).decide({ actor: this.#privacyActor(row.run), action: "issue:read",
resource: { type: "issue", companyId: this.binding.companyId, issueId: row.issue.id } });
if (!privacy.allowed) throw forbidden("The task is no longer available to this run");
if (options.tool) assertNativeSkillTestTool(this.#apiScope(row), options.tool);
return row;
}
@@ -2574,6 +2584,7 @@ export class PaperclipRunnerToolAuthority {
const context = await this.#lockAuthorizedMutationContext(
tx as unknown as Db,
);
assertNativeSkillTestTool(this.#apiScope(context), operationId);
const isFilePreparation =
operationId === "register_deliverable" ||
operationId === REUSE_CHAT_ATTACHMENT_TOOL_NAME;
@@ -137,6 +137,39 @@ describe("plugin native Runner ownership", () => {
expect(retained).toHaveBeenCalledWith(expect.objectContaining(requirements.identity));
expect(task.mock.calls.map(([op]) => op.kind)).toEqual(["status", "read_state", "connection", "stop", "status", "read_state", "complete"]);
});
it.each(["settled", "unsettled"] as const)("proves retirement independently of %s checkpoint reuse", async settlement => {
const { task } = provider();
const retained = vi.fn(async () => {});
const owner = createPluginRunnerTaskOwner({ task, projectIds: [], harness: "codex",
adoptIdentity: requirements.identity, onSessionRetained: retained });
const registration = await owner.registration({} as never, "run");
expect(owner.isRetired()).toBe(false);
await registration.release();
expect(owner.isRetired()).toBe(false);
await registration.checkpoint(settlement);
expect(owner.isRetired()).toBe(true);
expect(retained).toHaveBeenCalledTimes(settlement === "settled" ? 1 : 0);
expect(task.mock.calls.map(([op]) => op.kind)).toEqual(settlement === "settled"
? ["stop", "status", "read_state", "complete"] : ["stop", "status", "complete"]);
});
it.each(["credentials", "read_state", "complete"])("does not prove retirement when %s obligations fail after execution stops", async failingOperation => {
const { task } = provider();
const perform = task.getMockImplementation()!;
task.mockImplementation(async operation => {
if (operation.kind === failingOperation) throw new Error("handoff failed");
if (operation.kind === "credentials") return { kind: "credentials", taskId: "task", credential: null };
return perform(operation);
});
const owner = createPluginRunnerTaskOwner({ task, projectIds: [], harness: "codex", accountIdentity: "account",
adoptIdentity: requirements.identity, onCredentials: async () => {}, onSessionRetained: async () => {} });
const registration = await owner.registration({} as never, "run");
await registration.release();
await expect(registration.checkpoint("settled")).rejects.toThrow("handoff failed");
expect(await owner.driver.adoptExistingRunner!.isAlive()).toBe(false);
expect(owner.isRetired()).toBe(false);
});
it("submits only a prepared identity and configuration ID", async () => {
const { task } = provider();
const owner = createPluginRunnerTaskOwner({ projectAuthCheckVersion: 1, task, projectIds: [], harness: "codex", pollMs: 1 });
@@ -59,6 +59,7 @@ export function createPluginRunnerTaskOwner(input: {
let failReady!: (error: unknown) => void;
let confirmStopped: (code: number) => void = () => {};
let stoppedState: Extract<PluginEnvironmentTaskResult, { kind: "status" }> | undefined;
let completed = false;
let rejectFailure!: (error: unknown) => void;
const failure = new Promise<never>((_resolve, reject) => { rejectFailure = reject; });
void failure.catch(() => undefined);
@@ -284,7 +285,10 @@ export function createPluginRunnerTaskOwner(input: {
}
await input.onSessionRetained(state);
}
if (submitted) await call({ kind: "complete" });
if (submitted) {
await call({ kind: "complete" });
completed = true;
}
},
release: async (reason?: "detach") => {
registrationReleased = true;
@@ -328,6 +332,9 @@ export function createPluginRunnerTaskOwner(input: {
return bytes;
};
return { launcher, registration, driver, readWorkspaceFile,
// This is a lease-bound Launcher receipt, not a terminal phase or a sent
// stop request. Completion also proves credential/writeback obligations ran.
isRetired: () => stoppedState?.executionStopped === true && completed && !detached,
rebind(next: typeof input) {
if (!launched || stopRequested || stoppedState || detached || next.harness !== input.harness ||
input.accountIdentity !== next.accountIdentity || input.credentialIdentity !== next.credentialIdentity ||
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from "node:crypto";
import { chmod, writeFile, symlink, mkdir, open } from "node:fs/promises";
import { join, dirname } from "node:path";
import { and, eq } from "drizzle-orm";
import { agents, agentWakeupRequests, assets, documents, heartbeatRuns, issues, projects, routineDocuments, routines, runnerApiResponseReservations, companySkills, companyMemberships, principalPermissionGrants } from "@paperclipai/db";
import { agents, agentWakeupRequests, assets, documents, heartbeatRuns, issues, projects, routineDocuments, routines, runnerApiResponseReservations, companySkills, companySkillVersions, companySkillTestRuns, companyMemberships, principalPermissionGrants } from "@paperclipai/db";
import { initializeRunIdentity } from "../run-identity.js";
import { beforeAll, afterAll, beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { secretService } from "../secrets.js";
@@ -16,6 +16,7 @@ import { createLocalAgentJwt } from "../../agent-auth-jwt.js";
import { RUNNER_API_RESPONSE_MAX_BYTES, RUNNER_API_RESPONSE_RUN_MAX_BYTES } from "./runner-api-response-limits.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
import { prepareHeartbeatTaskContext } from "../heartbeat/task-context.js";
import { createHeartbeatRunPreparation } from "../heartbeat/run-preparation.js";
import { createAssignedMcpTools } from "./assigned-mcp-tools.js";
import type { ToolGatewayService } from "../tool-gateway.js";
import { RUNNER_TOOL_RESULT_MAX_BYTES } from "./runner-tool-results.js";
@@ -498,7 +499,8 @@ else if(m.id!==undefined) send({id:m.id,result:{}});
await prepareHeartbeatTaskContext(server.db, { run, agent,
task: { issueRef: issue, issueContext: null, wakeCommentContext: null, context, taskSession: null },
policy: { recovering: true, trustPreset: { kind: "standard" }, experimentalInstanceSettings: {} },
services: { issuesSvc: { getAncestors: async () => [] }, getPinnedSkillTestContext: async () => null },
services: { issuesSvc: { getAncestors: async () => [] },
getPinnedSkillTestContext: createHeartbeatRunPreparation(server.db).getPinnedSkillTestContext },
});
await server.db.update(heartbeatRuns).set({ contextSnapshot: context }).where(eq(heartbeatRuns.id, fixture.runId));
}
@@ -510,7 +512,17 @@ else if(m.id!==undefined) send({id:m.id,result:{}});
} }).where(eq(heartbeatRuns.id, fixture.runId));
await server.db.update(issues).set({ workMode: "skill_test" }).where(eq(issues.id, fixture.issueId));
await server.db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, fixture.issueId));
await expect(reprepareRun(fixture)).rejects.toThrow("The skill test's saved version is unavailable");
const [skill] = await server.db.insert(companySkills).values({ companyId: fixture.companyId,
key: `company/${fixture.companyId}/scope-test`, slug: "scope-test", name: "Scope test", markdown: "# Scope test", sourceType: "managed" }).returning();
const [version] = await server.db.insert(companySkillVersions).values({ companyId: fixture.companyId,
companySkillId: skill.id, revisionNumber: 1, fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# Scope test" }] }).returning();
await server.db.insert(companySkillTestRuns).values({ companyId: fixture.companyId, skillId: skill.id,
skillVersionId: version.id, agentId: fixture.agentId, issueId: fixture.issueId, inputSnapshot: "Exercise the saved scope" });
await reprepareRun(fixture);
const [prepared] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
expect(prepared.contextSnapshot).toMatchObject({ paperclipAgentJwtScope: { kind: "skill_test", issueId: fixture.issueId },
paperclipSkillTest: { skillKey: skill.key, skillVersionId: version.id } });
const body = { kind: "secret", name: "parity/skill-test", value: "disposable-skill-test-value", justification: "Must remain denied" };
const token = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId,
fixture.responsibleUserId, { kind: "skill_test", issueId: fixture.issueId });
@@ -1,3 +1,4 @@
import { skillExecutionContent } from "../skill-execution-content.js";
import { withRuntimeAssetUse, removeRuntimeAssetTree } from "./runtime-asset-retention.js";
import { cachedSkillAsset, rememberSkillAsset } from "./skill-asset-cache.js";
import { verifyFileManifest, type FileManifestEntry } from "../file-manifest-cache.js";
@@ -11,7 +12,7 @@ import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { and, eq } from "drizzle-orm";
import { heartbeatRuns, type Db } from "@paperclipai/db";
import { companySkillVersions, heartbeatRuns, type Db } from "@paperclipai/db";
import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
import { isToolConnectionAttentionHealth } from "@paperclipai/shared";
import {
@@ -185,6 +186,54 @@ async function materializeInstructionBundle(db: Db, agent: RuntimeAgent, agentFi
return { entryPath, bundle: await materializeAsset(files) };
}
async function materializeSkillAsset(source: string) {
let bundle = await measureRunPhase("runtime_context.asset_verify", () => cachedSkillAsset(source));
if (!bundle) {
const files = await measureRunPhase("runtime_context.asset_read", () => collectDirectoryFiles(source));
bundle = await materializeAsset(files);
const manifest: FileManifestEntry[] = files.map(file => ({ path: file.path, size: file.content.length,
sha256: sha256(file.content), mode: file.mode & 0o555, executable: Boolean(file.mode & 0o111) }));
if (await verifyFileManifest(source, manifest)) rememberSkillAsset(source, manifest, bundle);
}
return bundle;
}
export async function rehydratePinnedSkillAssets(
db: Db,
binding: { companyId: string; runId: string; agentId: string },
context: NativeRuntimeContextSnapshot,
): Promise<NativeRuntimeContextSnapshot> {
if (!context.skills.some(skill => skill.versionId)) return context;
const [run] = await db.select({ responsibleUserId: heartbeatRuns.responsibleUserId,
contextSnapshot: heartbeatRuns.contextSnapshot }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, binding.companyId), eq(heartbeatRuns.id, binding.runId), eq(heartbeatRuns.agentId, binding.agentId)));
if (!run) throw new Error("Skill recovery has no matching admitted run");
const actor = { type: "agent" as const, companyId: binding.companyId, agentId: binding.agentId,
runId: binding.runId, source: "agent_jwt" as const,
onBehalfOfUserId: run.responsibleUserId === "local-board" ? null : run.responsibleUserId };
const delivery = skillExecutionContent(db, { actor });
const pins = run.contextSnapshot?.skillVersions;
const skills: NativeRuntimeContextSnapshot["skills"] = [];
for (const skill of context.skills) {
if (!skill.versionId) { skills.push(skill); continue; }
if (pins !== undefined && (!pins || typeof pins !== "object" || Array.isArray(pins)
|| (pins as Record<string, unknown>)[skill.key] !== skill.versionId)) {
throw new Error("Skill recovery differs from the admitted version selection");
}
const [version] = await db.select({ skillId: companySkillVersions.companySkillId }).from(companySkillVersions).where(and(
eq(companySkillVersions.companyId, binding.companyId), eq(companySkillVersions.id, skill.versionId)));
if (!version) throw new Error("Pinned skill version is unavailable for recovery");
const bundle = await delivery.withVersion({ companyId: binding.companyId, skillId: version.skillId, versionId: skill.versionId },
source => materializeSkillAsset(source));
if (bundle.digest !== skill.bundle.digest || bundle.manifestDigest !== skill.bundle.manifestDigest
|| bundle.fileCount !== skill.bundle.fileCount || bundle.totalBytes !== skill.bundle.totalBytes) {
throw new Error("Recovered skill projection does not match its admitted digest");
}
skills.push({ ...skill, bundle });
}
return { ...context, skills };
}
export async function materializeSelectedSkills(runtimeConfig: Record<string, unknown>, entries: PaperclipSkillEntry[], omitLegacy: boolean) {
const desiredKeys = resolvePaperclipDesiredSkillNames(runtimeConfig, entries).filter(
(key) => !omitLegacy || key !== PAPERCLIP_OPERATIONAL_SKILL_KEY,
@@ -194,16 +243,7 @@ export async function materializeSelectedSkills(runtimeConfig: Record<string, un
const entry = byKey.get(key);
if (!entry) throw new Error(`assigned runtime skill is missing from the company library: ${key}`);
if (entry.sourceStatus === "missing") throw new Error(entry.missingDetail ?? `assigned runtime skill source is missing: ${key}`);
let bundle = await measureRunPhase("runtime_context.asset_verify", () => cachedSkillAsset(entry.source));
if (!bundle) {
const files = await measureRunPhase("runtime_context.asset_read", () => collectDirectoryFiles(entry.source));
bundle = await materializeAsset(files);
const manifest: FileManifestEntry[] = files.map(file => ({ path: file.path, size: file.content.length,
sha256: sha256(file.content), mode: file.mode & 0o555, executable: Boolean(file.mode & 0o111) }));
// Populate the stat/digest cache against these exact source bytes before
// remembering the reference. A concurrent source edit cannot seed it.
if (await verifyFileManifest(entry.source, manifest)) rememberSkillAsset(entry.source, manifest, bundle);
}
const bundle = await materializeSkillAsset(entry.source);
return { key: entry.key, runtimeName: safeRelativePath(entry.runtimeName, `runtime name for ${key}`), versionId: entry.versionId ?? entry.currentVersionId ?? null, bundle };
}));
}
@@ -419,10 +419,16 @@ describe("resolveNativeRuntimeMode", () => {
it("rejects an explicit native profile outside the approved boundary", () => {
expect(resolveNativeRuntimeMode({ ...eligible, agent: { ...eligible.agent, adapterType: "claude_local" } }))
.toEqual(expect.objectContaining({ kind: "legacy", reason: "direct_adapter" }));
expect(() => resolveNativeRuntimeMode({ ...eligible, issue: { id: "issue", workMode: "skill_test" } }))
expect(() => resolveNativeRuntimeMode({ ...eligible, issue: { id: "issue", workMode: "unknown" } }))
.toThrow(NativeRuntimeEligibilityError);
});
it("admits Skills Studio tests on remote Runners", () => {
expect(resolveNativeRuntimeMode({ ...eligible,
issue: { id: "skill-test", workMode: "skill_test" }, target: { kind: "remote" },
})).toMatchObject({ kind: "native", profile: { backend: "codex_app_server" } });
});
it("admits remote targets only through paperclip_runner", () => {
expect(resolveNativeRuntimeMode({
...eligible,
@@ -156,11 +156,11 @@ export function resolveNativeRuntimeMode(input: {
"Paperclip Runner requires an active agent.",
);
}
const allowedWorkModes = ["standard", "planning", "ask"];
const allowedWorkModes = ["standard", "planning", "ask", "skill_test"];
if (!input.issue || !allowedWorkModes.includes(input.issue.workMode)) {
throw ineligible(
"paperclip_runner_issue_ineligible",
"Paperclip Runner requires a standard, planning, or ask task.",
"Paperclip Runner requires a standard, planning, ask, or skill test task.",
);
}
const rollout = resolveNativeMigrationStatus({
@@ -66,6 +66,7 @@ import {
resolveExternalChatResponseWaitAuthorizationInTransaction,
} from "./chat-attachment-reuse.js";
import { issueService } from "../issues.js";
import { companySkillService } from "../company-skills.js";
import { syncRoutineRunStatusForIssue } from "../routine-run-status.js";
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
@@ -2231,6 +2232,31 @@ export async function commitNativeStatusDecision(input: {
if (updated.originKind === "routine_execution") {
await syncRoutineRunStatusForIssue(tx as unknown as Db, input.issueId);
}
// Native task completion bypasses the REST issue route. Commit the Studio
// output with the authoritative task status so restart cannot lose it.
// The durable test row, not a mutable task mode, identifies the obligation.
if (issue.status !== updated.status && (updated.status === "done" || updated.status === "cancelled")) {
const testRun = await companySkillService(tx as unknown as Db).completeTestRunForIssue({
companyId: input.companyId,
issueId: input.issueId,
outcome: updated.status === "done" ? "succeeded" : "cancelled",
error: updated.status === "cancelled" ? "Harness issue was cancelled" : null,
});
if (testRun) {
const { publication } = await persistActivity(tx as unknown as Db, {
companyId: input.companyId,
actorType: "system",
actorId: "native-status-committer",
runId: input.runId,
action: "company.skill_test_run_completed",
entityType: "company_skill_test_run",
entityId: testRun.id,
issueId: input.issueId,
details: { issueId: input.issueId, status: testRun.status, outputDocumentKey: testRun.outputDocumentKey },
});
publications.push(publication);
}
}
materialized.unshift({
effectKind: "issue_status_projection",
targetType: "issue",
@@ -2,17 +2,17 @@ import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { eq, and } from "drizzle-orm";
import { activityLog, agents, authUsers, companyMemberships, companies, createDb, heartbeatRuns, issues, issueComments } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { startRecoveryPostgres } from "../../__tests__/helpers/recovery-postgres.js";
import { issueService } from "../issues.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
describe("runner task reassignment", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let temporary: Awaited<ReturnType<typeof startRecoveryPostgres>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("runner-reassign-");
temporary = await startRecoveryPostgres("runner-reassign-");
db = createDb(temporary.connectionString);
});
}, 60_000);
afterAll(async () => { await temporary?.cleanup(); });
async function fixture(status = "todo") {
@@ -183,7 +183,9 @@ describe("runner task reassignment", () => {
it.each(["planning", "ask", "skill_test"])("denies reassignment from %s mode", async workMode => {
const f = await fixture();
await db.update(issues).set({ workMode }).where(eq(issues.id, f.issueId));
await expect(f.authority.execute(f.call)).rejects.toThrow("mode_denied");
if (workMode === "skill_test") await expect(f.authority.execute(f.call)).rejects.toMatchObject({ status: 403 });
else await expect(f.authority.execute(f.call)).rejects.toThrow("mode_denied");
expect(f.stopTaskForReassignment).not.toHaveBeenCalled();
});
it("denies foreign targets and unavailable assignees", async () => {
@@ -0,0 +1,52 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import type { Db } from "@paperclipai/db";
import type { AuthorizationActor } from "./authorization.js";
import { notFound, unprocessable } from "../errors.js";
import { skillAccessService } from "./skill-access.js";
import { skillPackageStore } from "./skill-package-store.js";
import { skillPackageRuntime } from "./skill-package-runtime.js";
import { skillPackageManifest, verifySkillPackageDirectory } from "./skill-package-files.js";
import { skillFileBytes } from "./skill-snapshot.js";
import { ensureSkillCacheDirectory, skillVersionCachePaths, touchSkillVersionCache, withSkillVersionCacheLock } from "./skill-version-cache.js";
import { runVersionSnapshotFlight } from "./skill-version-retention.js";
export type SkillContentReference = { companyId: string; skillId: string; versionId: string };
export function skillExecutionContent(db: Db, options: { actor?: AuthorizationActor; packageDb?: Db } = {}) {
const packages = skillPackageStore(options.packageDb ?? db);
const runtime = skillPackageRuntime(options.packageDb ?? db);
async function withVersion<T>(reference: SkillContentReference, consume: (directory: string) => Promise<T>): Promise<T> {
const { companyId, skillId, versionId } = reference;
await skillAccessService(db, options.actor).assertSkill(companyId, skillId);
return withSkillVersionCacheLock(db, companyId, async () => {
const version = await packages.version(companyId, skillId, versionId);
if (!version) throw notFound("Skill version not found");
const { directory } = skillVersionCachePaths(companyId, skillId, versionId);
await runVersionSnapshotFlight(directory, async () => {
if (version.repositorySnapshotId || version.packageSnapshotId) return runtime.materialize(version, directory);
const files = await packages.versionFiles(version);
if (!files.some(file => file.path === "SKILL.md")) throw unprocessable("Skill version has no SKILL.md snapshot.");
const manifest = skillPackageManifest(files);
const parent = path.dirname(directory);
await ensureSkillCacheDirectory(parent, true);
if (await verifySkillPackageDirectory(directory, manifest.files)) return directory;
const staging = await fs.mkdtemp(path.join(parent, `.${versionId}-`));
try {
for (const file of files) {
const target = path.join(staging, file.path);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, skillFileBytes(file), { mode: file.executable ? 0o555 : 0o444 });
}
if (!await verifySkillPackageDirectory(staging, manifest.files)) throw new Error("Skill delivery verification failed");
await fs.rm(directory, { recursive: true, force: true });
await fs.rename(staging, directory);
return directory;
} finally { await fs.rm(staging, { recursive: true, force: true }); }
});
await touchSkillVersionCache(companyId, skillId, versionId);
return consume(directory);
});
}
return { withVersion };
}
@@ -0,0 +1,236 @@
import { constants, promises as fs } from "node:fs";
import path from "node:path";
import { and, asc, eq, gt, sql } from "drizzle-orm";
import { companySkills, companySkillVersions, type Db } from "@paperclipai/db";
import { parseFrontmatterMarkdown, type CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
import { conflict } from "../errors.js";
import { classifyInventoryKind, companySkillService } from "./company-skills.js";
import { managedSkillContent } from "./managed-skill-content.js";
import { skillPackageStore, skillVersionMetadataColumns } from "./skill-package-store.js";
import { skillPackageMaintenance } from "./skill-package-maintenance.js";
import { skillPackageManifest } from "./skill-package-files.js";
import { assertSkillSnapshotPath, MAX_SKILL_SNAPSHOT_BYTES, MAX_SKILL_SNAPSHOT_FILES, skillSnapshotHash, snapshotFile } from "./skill-snapshot.js";
import { protectedVersions, withSkillVersionCacheLock } from "./skill-version-cache.js";
import { getStorageService, type StorageService } from "../storage/index.js";
type Page = { companyId?: string; after?: string; id?: string; limit?: number; signal?: AbortSignal };
type Conversion = Page & { apply?: boolean; writersStopped?: boolean };
const limit = (options: Page) => Math.min(50, Math.max(1, options.limit ?? 10));
const legacyManaged = (skill: typeof companySkills.$inferSelect) => skill.sourceType === "local_path" && skill.metadata?.sourceKind === "managed_local";
const linkedLocal = (skill: typeof companySkills.$inferSelect) => skill.sourceType === "local_path"
&& !legacyManaged(skill) && skill.metadata?.sourceKind !== "paperclip_bundled";
function coversInstalledInventory(skill: typeof companySkills.$inferSelect, files: Array<{ path: string }>) {
const paths = new Set(files.map(file => file.path));
return paths.has("SKILL.md") && skill.fileInventory.every(file => typeof file.path === "string" && paths.has(file.path));
}
async function legacyFiles(skill: typeof companySkills.$inferSelect, signal?: AbortSignal) {
if (!skill.sourceLocator || !path.isAbsolute(skill.sourceLocator)) return null;
const source = path.resolve(skill.sourceLocator);
const root = path.basename(source) === "SKILL.md" ? path.dirname(source) : source;
const stat = await fs.lstat(root).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (!stat) return null;
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Legacy skill root is not a safe directory");
const files: CompanySkillVersionFileInventoryEntry[] = [];
let bytes = 0;
async function visit(directory: string) {
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
signal?.throwIfAborted();
if (entry.name === ".git" || entry.name === "node_modules") continue;
const absolute = path.join(directory, entry.name);
if (entry.isSymbolicLink()) throw new Error("Legacy skill contains a symbolic link");
if (entry.isDirectory()) { await visit(absolute); continue; }
if (!entry.isFile()) throw new Error("Legacy skill contains an unsupported file");
const relative = assertSkillSnapshotPath(path.relative(root, absolute).split(path.sep).join("/"));
const handle = await fs.open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const stat = await handle.stat();
if (!stat.isFile()) throw new Error("Legacy skill contains an unsupported file");
bytes += stat.size;
if (files.length >= MAX_SKILL_SNAPSHOT_FILES || bytes > MAX_SKILL_SNAPSHOT_BYTES) throw new Error("Legacy skill exceeds package limits");
const body = Buffer.alloc(stat.size);
let offset = 0;
while (offset < body.length) {
signal?.throwIfAborted();
const { bytesRead } = await handle.read(body, offset, body.length - offset, offset);
if (!bytesRead) throw new Error("Legacy skill changed during conversion");
offset += bytesRead;
}
const after = await handle.stat();
if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) throw new Error("Legacy skill changed during conversion");
files.push(snapshotFile(relative, classifyInventoryKind(relative), body, Boolean(stat.mode & 0o111)));
} finally { await handle.close(); }
}
}
await visit(root);
if (!files.some(file => file.path === "SKILL.md" && file.encoding !== "base64")) throw new Error("Legacy skill is missing UTF-8 SKILL.md");
return files;
}
export function skillInstallationMaintenance(db: Db, storage?: StorageService) {
const store = skillPackageStore(db, storage);
const content = managedSkillContent(db, db, storage);
const provider = () => (storage ?? getStorageService()).provider;
function scope(options: Page) {
return and(options.companyId ? eq(companySkills.companyId, options.companyId) : undefined,
options.id ? eq(companySkills.id, options.id) : undefined,
options.after ? gt(companySkills.id, options.after) : undefined);
}
async function installations(options: Conversion = {}) {
if (options.apply && !options.writersStopped) throw conflict("Installation conversion requires stopped admission and retired legacy writers (--writers-stopped).");
const skills = await db.select().from(companySkills).where(scope(options)).orderBy(asc(companySkills.id)).limit(limit(options));
const results: Array<{ skillId: string; companyId: string; status: string; versionId?: string | null; reason?: string }> = [];
for (const skill of skills) {
options.signal?.throwIfAborted();
const catalogOrigin = skill.sourceType === "catalog" && !skill.metadata?.originVersionId;
if (linkedLocal(skill)) {
results.push({ skillId: skill.id, companyId: skill.companyId, status: "linked_local", versionId: skill.currentVersionId });
continue;
}
try {
const version = skill.currentVersionId ? await store.version(skill.companyId, skill.id, skill.currentVersionId) : null;
const complete = version && coversInstalledInventory(skill, await store.versionInventory(version));
if (!legacyManaged(skill) && !catalogOrigin && complete) {
results.push({ skillId: skill.id, companyId: skill.companyId, status: "unchanged", versionId: skill.currentVersionId });
continue;
}
const pins = await protectedVersions(db, skill.companyId);
if (pins.versions === null) throw new Error("Active run lacks an exact durable skill selection; settle or snapshot it on the legacy server first");
const saved = version ? await store.versionFiles(version) : null;
const local = await legacyFiles(skill, options.signal);
const completeSaved = saved && coversInstalledInventory(skill, saved) ? saved : null;
const files = local ?? completeSaved ?? ((skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url")
? await companySkillService(db).captureLegacyInstallation(skill.companyId, skill.id) : null);
const origin = catalogOrigin && typeof skill.metadata?.originSnapshotLocator === "string"
? await legacyFiles({ ...skill, sourceLocator: skill.metadata.originSnapshotLocator }, options.signal) : null;
if (catalogOrigin && !origin && skill.metadata?.userModifiedAt) {
throw new Error("Catalog reset snapshot is unavailable; preserve the original installed package before conversion");
}
if (!files?.length || !files.some(file => file.path === "SKILL.md")) throw new Error("Neither complete legacy files nor a saved version is available");
if (!local && !coversInstalledInventory(skill, files)) {
throw new Error("Saved version does not cover the installed inventory");
}
if (!options.apply) {
results.push({ skillId: skill.id, companyId: skill.companyId, status: "conversion_required", versionId: skill.currentVersionId });
continue;
}
if (version && saved && !version.packageSnapshotId && skillSnapshotHash(saved) === skillSnapshotHash(files)) {
await skillPackageMaintenance(db, storage).migrateVersions({ id: version.id, apply: true, clearLegacy: true, signal: options.signal });
}
const candidate = await content.prepare(skill.companyId, files, options.signal);
if (!candidate.packageSnapshotId || !await store.verifyDurable(skill.companyId, candidate.packageSnapshotId, options.signal)) {
throw new Error("Final snapshot is not independently recoverable from object storage");
}
let originVersionId = typeof skill.metadata?.originVersionId === "string" ? skill.metadata.originVersionId : null;
if (catalogOrigin && origin && skillSnapshotHash(origin) !== skillSnapshotHash(files)) {
originVersionId = (await companySkillService(db).createVersion(skill.companyId, skill.id,
{ label: "Original catalog package" }, { type: "system" }, {
fileInventory: origin, updateCurrentVersion: false, skipInventoryRefresh: true,
})).id;
}
const markdown = files.find(file => file.path === "SKILL.md")!.content;
const parsed = parseFrontmatterMarkdown(markdown);
const { missingSource: _missing, ...metadata } = skill.metadata ?? {};
const result = await withSkillVersionCacheLock(db, skill.companyId, () => content.publish(skill, candidate, {
...(legacyManaged(skill) ? { sourceType: "managed", sourceLocator: null } : {}), markdown,
name: typeof parsed.frontmatter.name === "string" ? parsed.frontmatter.name : skill.name,
description: typeof parsed.frontmatter.description === "string" ? parsed.frontmatter.description : skill.description,
fileInventory: files.map(({ path, kind }) => ({ path, kind })),
trustLevel: files.some(file => file.kind === "script" || file.executable) ? "scripts_executables"
: files.some(file => file.kind === "asset" || file.kind === "other") ? "assets" : "markdown_only",
metadata: { ...metadata, ...(legacyManaged(skill) ? { sourceKind: "managed" } : {}),
...(originVersionId ? { originVersionId } : {}) },
}, { type: "system" }, { label: "Legacy installation conversion",
reuseCurrentVersion: Boolean(saved && skillSnapshotHash(saved) === skillSnapshotHash(files)),
afterPublish: async (tx, row, version) => {
if (catalogOrigin && !originVersionId) await tx.update(companySkills)
.set({ metadata: { ...row.metadata, originVersionId: version.id } }).where(eq(companySkills.id, row.id));
},
}));
results.push({ skillId: skill.id, companyId: skill.companyId, status: "converted", versionId: result.version.id });
} catch (error) {
results.push({ skillId: skill.id, companyId: skill.companyId, status: "blocked", reason: error instanceof Error ? error.message : "Conversion failed" });
}
}
return { inspected: skills.length, after: skills.at(-1)?.id ?? null, results };
}
async function eligibility(options: Page = {}) {
const skills = await db.select().from(companySkills).where(scope(options)).orderBy(asc(companySkills.id)).limit(limit(options));
const results = [];
for (const skill of skills) {
const pins = await protectedVersions(db, skill.companyId);
let status = linkedLocal(skill) ? "linked_local" : legacyManaged(skill) ? "legacy_managed" : "version_backed";
if (pins.versions === null) status = "unsupported_active_run";
else if (!linkedLocal(skill) && !skill.currentVersionId) status = "missing_current_version";
else if (skill.sourceType === "catalog" && !skill.metadata?.originVersionId) status = "legacy_catalog_origin";
else if (status === "version_backed" && skill.currentVersionId) {
try {
const version = await store.version(skill.companyId, skill.id, skill.currentVersionId);
if (!version) status = "unavailable_current_version";
else if (!coversInstalledInventory(skill, await store.versionInventory(version))) status = "incomplete_current_version";
} catch { status = "unavailable_current_version"; }
}
results.push({ skillId: skill.id, companyId: skill.companyId, sourceType: skill.sourceType, versionId: skill.currentVersionId, status });
}
return { inspected: skills.length, after: skills.at(-1)?.id ?? null, results, contentVerified: false };
}
async function verifyVersions(options: Page = {}) {
const versions = await db.select(skillVersionMetadataColumns).from(companySkillVersions).where(and(
options.companyId ? eq(companySkillVersions.companyId, options.companyId) : undefined,
options.id ? eq(companySkillVersions.id, options.id) : undefined,
options.after ? gt(companySkillVersions.id, options.after) : undefined,
)).orderBy(asc(companySkillVersions.id)).limit(limit(options));
const results = [];
for (const version of versions) {
options.signal?.throwIfAborted();
const packageId = version.repositorySnapshotId ?? version.packageSnapshotId;
let status = "missing_package";
try {
if (packageId) {
const receipt = await store.descriptor(version.companyId, packageId);
status = !receipt.storage ? "no_durable_receipt" : receipt.storage.provider !== provider() ? "provider_mismatch"
: receipt.storage.provider === "local_disk" ? "local_storage" : "remote_storage";
if (receipt.storage && receipt.storage.provider === provider()
&& !await store.verifyDurable(version.companyId, packageId, options.signal)) status = "corrupt_package";
if (!receipt.storage && receipt.restorable && version.repositorySnapshotId === packageId) {
const active = await protectedVersions(db, version.companyId);
const [protectedRow] = await db.execute(sql`select 1 from company_skills
where company_id = ${version.companyId} and current_version_id = ${version.id}
union all select 1 from company_skill_test_runs where company_id = ${version.companyId}
and status in ('queued', 'running') and skill_version_id = ${version.id}
union all select 1 from agents where company_id = ${version.companyId}
and adapter_config::text like ${`%${version.id}%`} limit 1`);
if (!protectedRow && active.versions && !active.versions.has(version.id)
&& Date.now() - receipt.createdAt.getTime() >= 30 * 24 * 60 * 60_000) {
await store.recoveryRecipe(version.companyId, packageId);
status = "expired_git_history";
}
}
} else {
const files = await store.versionFiles(version, false, options.signal);
skillPackageManifest(files);
status = files.some(file => file.path === "SKILL.md" && file.encoding !== "base64")
? version.releaseId ? "database_release_snapshot" : "database_snapshot"
: "incomplete_database_snapshot";
}
// A valid archive can still be an incomplete legacy installation. Compare
// only the current head: historical revisions may legitimately omit files
// that were added later. Inventory reads do not load SQL package bodies.
const [installed] = await db.select().from(companySkills).where(and(
eq(companySkills.companyId, version.companyId), eq(companySkills.id, version.companySkillId),
eq(companySkills.currentVersionId, version.id),
));
if (installed && !linkedLocal(installed)
&& !coversInstalledInventory(installed, await store.versionInventory(version))) status = "incomplete_current_version";
} catch { status = "unavailable_or_corrupt_package"; }
results.push({ companyId: version.companyId, skillId: version.companySkillId, versionId: version.id, packageId, status });
}
return { inspected: versions.length, after: versions.at(-1)?.id ?? null, results };
}
return { installations, eligibility, verifyVersions };
}
@@ -1,5 +1,5 @@
import path from "node:path";
import { and, asc, eq, gt, isNull, lte, sql } from "drizzle-orm";
import { and, asc, eq, gt, isNull, lte, or, sql } from "drizzle-orm";
import { companySkillRepositorySnapshots as snapshots, companySkillVersions as versions,
skillPackageDeletions, type Db } from "@paperclipai/db";
import { getStorageService, type StorageService } from "../storage/index.js";
@@ -43,7 +43,8 @@ export function skillPackageMaintenance(db: Db, storage?: StorageService) {
const rows = await db.select(skillVersionMetadataColumns).from(versions).where(and(
options.companyId ? eq(versions.companyId, options.companyId) : undefined,
options.id ? eq(versions.id, options.id) : undefined,
options.after ? gt(versions.id, options.after) : undefined, options.clearLegacy ? undefined : isNull(versions.packageSnapshotId), isNull(versions.releaseId)))
options.after ? gt(versions.id, options.after) : undefined,
options.clearLegacy ? undefined : or(isNull(versions.packageSnapshotId), isNull(versions.packageFileMap)), isNull(versions.releaseId)))
.orderBy(asc(versions.id)).limit(pageSize(options));
let migrated = 0;
if (options.apply) for (const row of rows) {
@@ -51,6 +52,15 @@ export function skillPackageMaintenance(db: Db, storage?: StorageService) {
const files = legacy?.files;
options.signal?.throwIfAborted();
if (!files?.length) {
if (row.packageSnapshotId && !row.packageFileMap) {
const manifest = await store.manifest(row.companyId, row.packageSnapshotId, options.signal);
const changed = await db.update(versions).set({ packageFileMap: skillVersionFileMap(manifest.files), migrationAt: null })
.where(and(eq(versions.companyId, row.companyId), eq(versions.id, row.id),
eq(versions.packageSnapshotId, row.packageSnapshotId), isNull(versions.packageFileMap)))
.returning({ id: versions.id });
migrated += changed.length;
continue;
}
await db.update(versions).set({ migrationAt: null }).where(eq(versions.id, row.id));
continue;
}
@@ -71,7 +81,7 @@ export function skillPackageMaintenance(db: Db, storage?: StorageService) {
packageId ??= await store.prepare(row.companyId, files, true, options.signal);
const legacyMetadata = new Map(files.map(file => [file.path, file]));
fileMap = fileMap ? fileMap.map(file => ({ ...file, kind: legacyMetadata.get(file.path)?.kind ?? file.kind,
encoding: legacyMetadata.get(file.path)?.encoding ?? "utf8" })) : skillVersionFileMap(files, await store.manifest(row.companyId, packageId, options.signal));
encoding: legacyMetadata.get(file.path)?.encoding ?? "utf8" })) : skillVersionFileMap(files);
const receipt = await store.descriptor(row.companyId, packageId);
if (!receipt.storage || !await store.verifyDurable(row.companyId, packageId, options.signal)) throw new Error(`Package must be migrated and verified first: ${packageId}`);
await db.update(versions).set({ packageSnapshotId: packageId, packageFileMap: fileMap, ...(options.clearLegacy ? { fileInventory: [], migrationAt: null } : {}) })
+17 -4
View File
@@ -30,10 +30,15 @@ type Descriptor = Pick<typeof snapshots.$inferSelect, "id" | "companyId" | "cont
export const skillVersionMetadataColumns = { ...getTableColumns(companySkillVersions),
fileInventory: sql<CompanySkillVersionFileInventoryEntry[]>`'[]'::jsonb` };
export type StoredSkillVersion = typeof companySkillVersions.$inferSelect;
export function skillVersionFileMap(files: CompanySkillVersionFileInventoryEntry[], manifest: SkillPackageManifest): SkillPackageFileMapping[] | null {
const stored = new Map(manifest.files.map(file => [file.path, file]));
if (files.every(file => stored.get(file.path)?.kind === file.kind && stored.get(file.path)?.encoding === (file.encoding ?? "utf8"))) return null;
return files.map(file => ({ path: file.path, sourcePath: file.path, kind: file.kind, encoding: file.encoding ?? "utf8" }));
export function skillVersionFileMap(files: Array<Pick<CompanySkillVersionFileInventoryEntry, "path" | "kind" | "encoding">>): SkillPackageFileMapping[] {
return files.map(file => ({ path: file.path, sourcePath: file.path, kind: file.kind, encoding: file.encoding ?? "utf8" as const }));
}
export function skillVersionUsesWholePackage(version: Pick<StoredSkillVersion, "packageFileMap">, manifest: SkillPackageManifest) {
if (!version.packageFileMap) return true;
const paths = new Set(version.packageFileMap.map(file => file.path));
return paths.size === manifest.files.length && version.packageFileMap.length === manifest.files.length
&& version.packageFileMap.every(file => file.path === file.sourcePath)
&& manifest.files.every(file => paths.has(file.path));
}
type ReadScope = { db: Db; descriptors: Map<string, Promise<Descriptor>>; durable: Map<string, Promise<boolean>>; versions: Map<string, StoredSkillVersion> };
const readScope = new AsyncLocalStorage<ReadScope>();
@@ -197,6 +202,13 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService, cach
const [saved] = await database.select({ restore: snapshots.restore }).from(snapshots).where(scope(row.companyId, row.id));
const recipe = saved?.restore;
if (!recipe || recipe.version !== 1) throw notFound("This skill package has no exact-commit restore recipe");
const url = new URL(recipe.repositoryUrl);
if (!/^[a-f0-9]{40}$/i.test(recipe.commitSha) || !recipe.sourceId || !recipe.repositoryId
|| url.protocol !== "https:" || url.hostname !== "github.com" || url.username || url.password
|| !/^\/[^/]+\/[^/]+$/.test(url.pathname) || url.search || url.hash) {
throw new Error("Invalid immutable skill package recipe");
}
if (recipe.skillPath) assertSkillSnapshotPath(recipe.skillPath);
const manifest = validateSkillPackageManifest(recipe.manifest);
if (manifest.contentHash !== row.contentHash || (row.storage && packageSha256(skillPackageManifestBytes(manifest)) !== row.storage.manifestSha256)) {
throw new Error("Skill package restore recipe does not match its receipt");
@@ -670,5 +682,6 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService, cach
return row?.files ?? [];
}
return { descriptor, prepare, bindRestore, files, version, prefetch, versionFiles, versionInventory, readVersionFile, withPackage, withIsolatedPackage, verifyDurable,
recoveryRecipe: async (companyId: string, id: string) => restoreRecipe(await descriptor(companyId, id)),
manifest: async (companyId: string, id: string, cancellation?: AbortSignal) => manifestFor(await descriptor(companyId, id), cancellation) };
}
+10 -14
View File
@@ -7,7 +7,6 @@ import { listSkillSourceBranches } from './skill-source-branches.js';
import { createHash, randomUUID } from 'node:crypto';
import { skillSourceRetryAt } from './skill-source-retry.js';
import path from 'node:path';
import { promises as fs } from 'node:fs';
import { folderService } from './folders.js';
import { and, eq, isNull, lt, or, sql } from 'drizzle-orm';
import { companySkillSources as sources, companySkillSourceEntries as entries, companySkills, companySkillRepositorySnapshots, projectSkillSources, projects, type Db, type SkillPackageRestoreRecipe, authUsers } from '@paperclipai/db';
@@ -450,20 +449,17 @@ export function skillSourceService(db: Db) {
const [user] = await db.select({ name: authUsers.name }).from(authUsers).where(eq(authUsers.id, context.actor.userId));
folderId = (await folderService(db).ensureMyFolder(companyId, context.actor.userId, user?.name ?? null)).id;
}
const directories: string[] = [];
try {
return await db.transaction(async tx => {
await tx.execute(sql`SET CONSTRAINTS paperclip_skill_runtime_changed DEFERRED`);
await lock(tx);
for (const skillId of skillIds) await skills.makeImportedSkillEditable(companyId, skillId, context.actor, folderId, tx, directories);
await remove(tx, skillIds.length, 0);
return { id, copiedCount: skillIds.length, deletedCount: 0 };
});
} catch (error) {
await Promise.all(directories.map(directory => fs.rm(directory, { recursive: true, force: true })));
throw error;
}
const prepared: Array<Awaited<ReturnType<typeof skills.prepareImportedSkillEditable>>> = [];
for (const skillId of skillIds) prepared.push(await skills.prepareImportedSkillEditable(companyId, skillId));
return db.transaction(async tx => {
await tx.execute(sql`SET CONSTRAINTS paperclip_skill_runtime_changed DEFERRED`);
await lock(tx);
for (const candidate of prepared) await skills.makeImportedSkillEditable(candidate, context.actor, folderId, tx);
await remove(tx, skillIds.length, 0);
return { id, copiedCount: skillIds.length, deletedCount: 0 };
});
}
async function importFromUrl(companyId: string, input: string, context: SkillSourceContext) {
const parsed = parseSkillImportSourceInput(input);
const url = new URL(parsed.resolvedSource);
+1 -3
View File
@@ -144,9 +144,7 @@ export async function protectedVersions(db: Db, companyId: string): Promise<{ ve
|| input.binding.runId !== id || input.binding.agentId !== agentId
|| input.binding.issueId !== nativeIssueId) return unknown();
for (const skill of input.runtimeContext.skills) {
// Unversioned skills have their own captured asset bundle; they do
// not reference a directory under __versions__.
if (skill.versionId === null) continue;
if (skill.versionId === null) return unknown();
if (!uuid.test(skill.versionId)) return unknown();
add(skill.versionId);
}