merge: integrate agent lifecycle ownership from master

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-09 11:24:59 -05:00
commit ad5d8826ad
159 files changed
+7173 -2805

No files matched your search

+1
View File
@@ -1340,6 +1340,7 @@ export const PLUGIN_CAPABILITIES = [
"approvals.read",
"issue.documents.read",
"agents.read",
"agents.lifecycle.manage",
"goals.read",
"goals.create",
"goals.update",
+4
View File
@@ -2859,3 +2859,7 @@ export * from "./agent-avatar-upload.js";
export type { DotBinding, DotInvitation, DotConnection, DotPairing } from "./dot-invitations.js";
export { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, type AgentRunnerChoice, type AgentRunner, type AgentRunnerAvailability } from "./agent-runner.js";
export { AGENT_LIFECYCLE_STATES, type AgentLifecycleState, type AgentLifecycleOperation } from "./types/agent-lifecycle.js";
export type { AgentLifecycleRequest, AgentLifecycleResult } from "./types/agent-lifecycle.js";
@@ -0,0 +1,31 @@
/** The lifecycle controls admission. Execution status describes current work. */
export const AGENT_LIFECYCLE_STATES = [
"pending_approval", "preparing", "verifying", "ready", "pausing", "paused",
"resuming", "terminating", "cleaning_up", "terminated", "rejected",
] as const;
export type AgentLifecycleState = typeof AGENT_LIFECYCLE_STATES[number];
export interface AgentLifecycleOperation {
id: string;
resumeState?: "preparing" | "verifying" | "ready";
hostComplete: boolean;
completedPluginIds: string[];
leaseOwner?: string;
leaseUntil?: string;
retryAt?: string;
attempts: number;
responsibleUserId?: string | null;
}
export interface AgentLifecycleRequest {
companyId: string;
agentId: string;
operationId: string;
version: number;
phase: AgentLifecycleState;
}
export interface AgentLifecycleResult {
operationId: string;
version: number;
status: "complete" | "pending";
}
+4
View File
@@ -1,3 +1,4 @@
import type { AgentLifecycleState } from "./agent-lifecycle.js";
import type { AgentAppearance } from "../agent-appearance.js";
import type { AiConnectionLoginIntent } from "../ai-connections.js";
import type {
@@ -90,6 +91,9 @@ export interface Agent {
appearance?: AgentAppearance | null;
avatarUrl?: string;
status: AgentStatus;
lifecycleState?: AgentLifecycleState;
lifecycleVersion?: number;
lifecycleError?: string | null;
reportsTo: string | null;
capabilities: string | null;
adapterType: AgentAdapterType;
+2
View File
@@ -1098,3 +1098,5 @@ export type { AgentInstructionErrorCode, AgentInstructionErrorDetails, AgentInst
export type { AgentInstructionCandidate } from "./agent.js";
export * from "./skill-source.js";
export * from "./agent-lifecycle.js";
+2
View File
@@ -688,6 +688,8 @@ export interface PaperclipPluginManifestV1 {
/** Capabilities this plugin requires from the host. Enforced at runtime. */
capabilities: PluginCapability[];
/** Opt into the native pooled-connection catalog, setup, and management UI. */
/** Required participant in agent preparation, stop, resume, and cleanup. */
agentLifecycle?: true;
aiConnectionRouter?: { name: string; description: string };
/** Entrypoint paths relative to the package root. */
entrypoints: {
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { updateAgentSchema } from "./agent.js";
describe("agent update schema", () => {
it.each(["paused", "idle", "terminated"])("accepts lifecycle status request %s", status => {
expect(updateAgentSchema.parse({ status })).toEqual({ status });
});
it.each(["active", "running", "error", "pending_approval"])("rejects direct status write %s", status => {
const result = updateAgentSchema.safeParse({ name: "Renamed", status });
expect(result.success).toBe(false);
if (!result.success) expect(result.error.issues).toEqual(expect.arrayContaining([
expect.objectContaining({ path: ["status"] }),
]));
});
it.each([{ spentMonthlyCents: 0 }, { name: "Renamed", spentMonthlyCents: 10 }])("rejects spend totals: %j", payload => {
const result = updateAgentSchema.safeParse(payload);
expect(result.success).toBe(false);
if (!result.success) expect(result.error.issues).toEqual(expect.arrayContaining([
expect.objectContaining({ path: ["spentMonthlyCents"] }),
]));
});
it("accepts profile and budget limit changes", () => {
expect(updateAgentSchema.parse({ name: "Renamed", budgetMonthlyCents: 100 })).toEqual({ name: "Renamed", budgetMonthlyCents: 100 });
});
});
+2 -3
View File
@@ -4,7 +4,6 @@ import { z } from "zod";
import {
AGENT_ICON_NAMES,
AGENT_ROLES,
AGENT_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
} from "../constants.js";
import { agentAdapterTypeSchema } from "../adapter-type.js";
@@ -157,8 +156,8 @@ export const updateAgentSchema = objectWithoutDefaults(
.extend({
permissions: z.never().optional(),
replaceAdapterConfig: z.boolean().optional(),
status: z.enum(AGENT_STATUSES).optional(),
spentMonthlyCents: z.number().int().nonnegative().optional(),
status: z.enum(["paused", "idle", "terminated"]).optional(),
spentMonthlyCents: z.never().optional(),
});
export type UpdateAgent = z.infer<typeof updateAgentSchema>;
@@ -33,6 +33,12 @@ describe("plugin capability constants", () => {
});
describe("plugin manifest validators", () => {
it("requires authority for required agent lifecycle participants", () => {
const manifest = { id: "example.lifecycle", apiVersion: 1, version: "0.1.0", displayName: "Lifecycle", description: "Lifecycle tests", author: "Tests", categories: ["automation"], entrypoints: { worker: "worker.js" }, agentLifecycle: true };
expect(pluginManifestV1Schema.safeParse({ ...manifest, capabilities: ["agents.read"] }).success).toBe(false);
expect(pluginManifestV1Schema.parse({ ...manifest, capabilities: ["agents.lifecycle.manage"] }).agentLifecycle).toBe(true);
});
it("requires routing authority for native pooled connector declarations without a custom UI bundle", () => {
const manifest = { id: "example.pool", apiVersion: 1, version: "0.1.0", displayName: "Pool", description: "Pool", author: "Tests", categories: ["connector"], entrypoints: { worker: "worker.js" }, aiConnectionRouter: { name: "AI connection pool", description: "Use saved connections" } };
expect(pluginManifestV1Schema.safeParse({ ...manifest, capabilities: ["ui.page.register"] }).success).toBe(false);
+5
View File
@@ -782,6 +782,7 @@ export const pluginManifestV1Schema = z.object({
"minimumPaperclipVersion must follow semver (e.g. 1.0.0)",
).optional(),
capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)).min(1),
agentLifecycle: z.literal(true).optional(),
aiConnectionRouter: z.object({ name: z.string().trim().min(1).max(100), description: z.string().trim().min(1).max(500) }).strict().optional(),
entrypoints: z.object({
worker: z.string().min(1),
@@ -806,6 +807,10 @@ export const pluginManifestV1Schema = z.object({
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
}).optional(),
}).superRefine((manifest, ctx) => {
if (manifest.agentLifecycle && !manifest.capabilities.includes("agents.lifecycle.manage")) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent lifecycle participation requires agents.lifecycle.manage", path: ["capabilities"] });
}
// ── Entrypoint ↔ UI slot consistency ──────────────────────────────────
// Plugins that declare UI slots must also declare a UI entrypoint so the
// host knows where to load the bundle from (PLUGIN_SPEC.md §10.1).