mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
Carry plugin task refusals and batch project reads (#20762)
This commit is contained in:
1 parent
f125b823f4
commit
4d141cdf64
21 files changed
+303
-44
No files matched your search
@@ -719,6 +719,17 @@ filesystem — there is no such capability. The browser still goes through the
|
||||
worker via `getData` / `performAction`, and the worker only exposes paths it
|
||||
chose to expose.
|
||||
|
||||
### Reading project snapshots
|
||||
|
||||
Workers with `projects.read`, `project.workspaces.read`, and
|
||||
`projects.instance-secrets` can call
|
||||
`ctx.projects.readSnapshots(projectIds, companyId, entityType)` to read up to
|
||||
64 distinct projects in one call. Each snapshot includes workspaces, the
|
||||
worker's authorized instance secrets, and its own company-scoped entity of the
|
||||
given type keyed by the project ID. A missing, archived, inaccessible, or
|
||||
changing project fails the whole request. Older hosts return `null` through
|
||||
the SDK. Keep instance secrets inside the trusted worker.
|
||||
|
||||
### When to keep worker-mediated project workspace browsing
|
||||
|
||||
When the data lives inside an existing project workspace, keep the browsing
|
||||
|
||||
@@ -121,6 +121,8 @@ export const environmentTaskOperationSchema = z.discriminatedUnion("kind", [
|
||||
|
||||
export const environmentTaskResultSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("accepted"), taskId: providerId }).strict(),
|
||||
z.object({ kind: z.literal("admission_rejected"), taskId: providerId,
|
||||
reason: z.enum(["session_state_unavailable", "account_binding_mismatch"]) }).strict(),
|
||||
z.object({ kind: z.literal("released"), taskId: providerId }).strict(),
|
||||
z.object({ kind: z.literal("resume"), taskId: providerId, outcome: z.enum(["resumed", "unavailable"]) }).strict(),
|
||||
z.object({ kind: z.literal("copilot_metadata"), taskId: providerId, metadata: z.record(z.string(), z.unknown()),
|
||||
@@ -210,6 +212,7 @@ export interface PluginEnvironmentTaskParams extends Omit<PluginEnvironmentDrive
|
||||
export function parseEnvironmentTaskResult(operation: PluginEnvironmentTaskOperation, taskId: string, value: unknown): PluginEnvironmentTaskResult {
|
||||
const result = environmentTaskResultSchema.parse(value);
|
||||
const expected = operation.kind === "release" ? "released" : operation.kind === "resume" ? "resume" : operation.kind === "read_file" ? "file" : operation.kind === "describe" ? "description" : operation.kind === "prepare" ? "prepared" : operation.kind === "check_project_auth" ? "project_auth" : ["read_state", "archive_state"].includes(operation.kind) ? "state" : ["status", "connection", "credentials", "copilot_metadata"].includes(operation.kind) ? operation.kind : "accepted";
|
||||
if (result.taskId !== taskId || result.kind !== expected) throw new Error("Invalid plugin-provided Runner execution response");
|
||||
const rejectedSubmit = operation.kind === "submit" && result.kind === "admission_rejected";
|
||||
if (result.taskId !== taskId || (result.kind !== expected && !rejectedSubmit)) throw new Error("Invalid plugin-provided Runner execution response");
|
||||
return result;
|
||||
}
|
||||
@@ -48,9 +48,10 @@
|
||||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||||
*/
|
||||
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type { PluginCapability } from "@paperclipai/shared";
|
||||
import type { WorkerHostCallContext, WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
|
||||
import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js";
|
||||
import { PLUGIN_RPC_ERROR_CODES, validateProjectSnapshotRequest } from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
@@ -444,6 +445,7 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||
"projects.list": "projects.read",
|
||||
"projects.get": "projects.read",
|
||||
"projects.getInstanceSecrets": "projects.instance-secrets",
|
||||
"projects.readSnapshots": "projects.instance-secrets",
|
||||
"projects.resolveGitCredential": "projects.git-credentials",
|
||||
"projects.listWorkspaces": "project.workspaces.read",
|
||||
"projects.getPrimaryWorkspace": "project.workspaces.read",
|
||||
@@ -849,6 +851,32 @@ export function createHostClientHandlers(
|
||||
const companyId = resolveRequiredCompanyId("projects.getInstanceSecrets", params, context);
|
||||
return services.projects.getInstanceSecrets({ ...params, companyId });
|
||||
}),
|
||||
"projects.readSnapshots": gated("projects.readSnapshots", async (params, context) => {
|
||||
const companyId = resolveRequiredCompanyId("projects.readSnapshots", params, context);
|
||||
requireCapability("projects.get");
|
||||
requireCapability("projects.listWorkspaces");
|
||||
const { projectIds, entityType } = params;
|
||||
validateProjectSnapshotRequest(projectIds, entityType);
|
||||
const snapshots: WorkerToHostMethods["projects.readSnapshots"][1] = [];
|
||||
for (const projectId of projectIds) {
|
||||
const scope = { companyId, projectId };
|
||||
const project = await services.projects.get(scope);
|
||||
if (project?.id !== projectId || project.companyId !== companyId || project.archivedAt) {
|
||||
throw new Error("Project snapshot unavailable");
|
||||
}
|
||||
const query = { entityType, scopeKind: "company" as const, scopeId: companyId, externalId: projectId, limit: 1 };
|
||||
const [entity] = await services.entities.list(query);
|
||||
const instanceSecrets = await services.projects.getInstanceSecrets(scope);
|
||||
const workspaces = await services.projects.listWorkspaces(scope);
|
||||
const current = await services.projects.get(scope);
|
||||
const [latest] = await services.entities.list(query);
|
||||
if (!isDeepStrictEqual(current, project) || !isDeepStrictEqual(latest, entity)) {
|
||||
throw new Error("Project snapshot changed during read");
|
||||
}
|
||||
snapshots.push({ projectId, workspaces, entity: entity ?? null, instanceSecrets });
|
||||
}
|
||||
return snapshots;
|
||||
}),
|
||||
"projects.get": gated("projects.get", async (params) => {
|
||||
return services.projects.get(params);
|
||||
}),
|
||||
|
||||
@@ -329,6 +329,7 @@ export type {
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginWorkspace,
|
||||
PluginProjectSnapshot,
|
||||
PluginExecutionWorkspaceMetadata,
|
||||
Company,
|
||||
Project,
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
PluginLauncherRenderEnvironment,
|
||||
PluginStateScopeKind,
|
||||
PluginEntityQuery,
|
||||
Company,
|
||||
Project,
|
||||
Issue,
|
||||
@@ -70,6 +71,7 @@ import type {
|
||||
PluginJobContext,
|
||||
PluginExecutionWorkspaceMetadata,
|
||||
PluginWorkspace,
|
||||
PluginProjectSnapshot,
|
||||
ToolRunContext,
|
||||
ToolResult,
|
||||
PluginLocalFolderListing,
|
||||
@@ -310,6 +312,15 @@ export interface PluginInvocationContext {
|
||||
traceparent?: string;
|
||||
}
|
||||
|
||||
export function validateProjectSnapshotRequest(projectIds: unknown, entityType: unknown): void {
|
||||
if (!Array.isArray(projectIds) || !projectIds.length || projectIds.length > 64 ||
|
||||
new Set(projectIds).size !== projectIds.length ||
|
||||
projectIds.some(id => typeof id !== "string" || !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(id)) ||
|
||||
typeof entityType !== "string" || !/^[a-zA-Z0-9._-]{1,128}$/.test(entityType)) {
|
||||
throw new Error("Invalid project snapshot scope");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Context provided to host-side worker→host handlers after the worker echoes a
|
||||
* host-issued invocation id.
|
||||
@@ -1734,14 +1745,7 @@ export interface WorkerToHostMethods {
|
||||
},
|
||||
];
|
||||
"entities.list": [
|
||||
params: {
|
||||
entityType?: string;
|
||||
scopeKind?: PluginStateScopeKind;
|
||||
scopeId?: string;
|
||||
externalId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
params: PluginEntityQuery,
|
||||
result: Array<{
|
||||
id: string;
|
||||
entityType: string;
|
||||
@@ -1855,6 +1859,10 @@ export interface WorkerToHostMethods {
|
||||
params: { projectId: string; companyId: string },
|
||||
result: Record<string, string>,
|
||||
];
|
||||
"projects.readSnapshots": [
|
||||
params: { projectIds: string[]; companyId: string; entityType: string },
|
||||
result: PluginProjectSnapshot[],
|
||||
];
|
||||
"projects.get": [
|
||||
params: { projectId: string; companyId: string },
|
||||
result: Project | null,
|
||||
|
||||
@@ -75,6 +75,7 @@ import type {
|
||||
PluginPerformActionActorContext,
|
||||
PluginPerformActionContext,
|
||||
} from "./protocol.js";
|
||||
import { validateProjectSnapshotRequest } from "./protocol.js";
|
||||
|
||||
export interface TestHarnessOptions {
|
||||
/** Plugin manifest used to seed capability checks and metadata. */
|
||||
@@ -1044,6 +1045,21 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
if (!isInCompany(project, companyId) || project.archivedAt) throw new Error("Project instance secrets unavailable");
|
||||
return structuredClone(projectInstanceSecrets.get(JSON.stringify([companyId, projectId])) ?? {});
|
||||
},
|
||||
async readSnapshots(projectIds, companyId, entityType) {
|
||||
requireCapability(manifest, capabilitySet, "projects.read");
|
||||
requireCapability(manifest, capabilitySet, "project.workspaces.read");
|
||||
requireCapability(manifest, capabilitySet, "projects.instance-secrets");
|
||||
requireCompanyId(companyId);
|
||||
validateProjectSnapshotRequest(projectIds, entityType);
|
||||
return Promise.all(projectIds.map(async projectId => {
|
||||
const project = await ctx.projects.get(projectId, companyId);
|
||||
if (!project || project.archivedAt) throw new Error("Project snapshot unavailable");
|
||||
const [entity] = await ctx.entities.list({ entityType, scopeKind: "company", scopeId: companyId, externalId: projectId, limit: 1 });
|
||||
const instanceSecrets = await ctx.projects.getInstanceSecrets(projectId, companyId);
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
return { projectId, workspaces, entity: entity ?? null, instanceSecrets };
|
||||
}));
|
||||
},
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "projects.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
PaperclipPluginManifestV1,
|
||||
PluginStateScopeKind,
|
||||
PluginEventType,
|
||||
PluginEntityQuery,
|
||||
PluginToolDeclaration,
|
||||
PluginLauncherDeclaration,
|
||||
Company,
|
||||
@@ -64,6 +65,7 @@ import type { PluginPerformActionContext } from "./protocol.js";
|
||||
|
||||
export type {
|
||||
PaperclipPluginManifestV1,
|
||||
PluginEntityQuery,
|
||||
PluginJobDeclaration,
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
@@ -344,24 +346,6 @@ export interface PluginEntityRecord {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query parameters for `ctx.entities.list()`.
|
||||
*/
|
||||
export interface PluginEntityQuery {
|
||||
/** Filter by entity type. */
|
||||
entityType?: string;
|
||||
/** Filter by scope kind. */
|
||||
scopeKind?: PluginStateScopeKind;
|
||||
/** Filter by scope ID. */
|
||||
scopeId?: string;
|
||||
/** Filter by external ID. */
|
||||
externalId?: string;
|
||||
/** Maximum number of results to return. */
|
||||
limit?: number;
|
||||
/** Number of results to skip (for pagination). */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project workspace metadata (read-only via ctx.projects)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -876,6 +860,13 @@ export interface PluginEntitiesClient {
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §7 — Project Workspaces
|
||||
*/
|
||||
export interface PluginProjectSnapshot {
|
||||
projectId: string;
|
||||
workspaces: PluginWorkspace[];
|
||||
entity: PluginEntityRecord | null;
|
||||
instanceSecrets: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PluginProjectsClient {
|
||||
/** Transient GitHub credential for a stored repository in the current host-issued
|
||||
* lifecycle/workspace invocation. Requires projects.git-credentials. Never persist or log. */
|
||||
@@ -883,6 +874,10 @@ export interface PluginProjectsClient {
|
||||
/** Trusted worker access to this plugin’s native project credentials.
|
||||
* Requires projects.instance-secrets; never expose via UI/data handlers. */
|
||||
getInstanceSecrets(projectId: string, companyId: string): Promise<Record<string, string>>;
|
||||
/** Read authorized project workspaces, instance secrets and one plugin-owned entity per project.
|
||||
* Missing or changing projects reject the batch. Null means the host does not support this method.
|
||||
* Keep the result in the trusted worker. */
|
||||
readSnapshots(projectIds: string[], companyId: string, entityType: string): Promise<PluginProjectSnapshot[] | null>;
|
||||
|
||||
/**
|
||||
* List projects visible to the plugin.
|
||||
|
||||
@@ -761,6 +761,14 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
async getInstanceSecrets(projectId, companyId) {
|
||||
return callHost("projects.getInstanceSecrets", { projectId, companyId });
|
||||
},
|
||||
async readSnapshots(projectIds, companyId, entityType) {
|
||||
try {
|
||||
return await callHost("projects.readSnapshots", { projectIds, companyId, entityType });
|
||||
} catch (error) {
|
||||
if (error instanceof JsonRpcCallError && error.code === JSONRPC_ERROR_CODES.METHOD_NOT_FOUND) return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async listWorkspaces(projectId: string, companyId: string) {
|
||||
return callHost("projects.listWorkspaces", { projectId, companyId });
|
||||
|
||||
@@ -923,6 +923,8 @@ export interface PluginCompanySettings {
|
||||
* Query filter for `ctx.entities.list`.
|
||||
*/
|
||||
export interface PluginEntityQuery {
|
||||
scopeKind?: PluginStateScopeKind;
|
||||
scopeId?: string;
|
||||
/** Optional filter by entity type (e.g. 'project', 'issue'). */
|
||||
entityType?: string;
|
||||
/** Optional filter by external system identifier. */
|
||||
|
||||
@@ -61,6 +61,28 @@ describe("stopped Runner checkpoint settlement", () => {
|
||||
});
|
||||
|
||||
describe("plugin-provided Runner execution", () => {
|
||||
it("records a confirmed admission refusal so lease cleanup never looks for a Runner", async () => {
|
||||
const value = row();
|
||||
const fixture = database(value);
|
||||
const refusal = { kind: "admission_rejected", taskId: "attempt-1", reason: "session_state_unavailable" };
|
||||
const workers = worker(refusal);
|
||||
await expect(executeEnvironmentTask(fixture.db, workers as never, {
|
||||
companyId: "company", leaseId, operation: submit,
|
||||
})).resolves.toEqual(refusal);
|
||||
const update = new PgDialect().sqlToQuery(fixture.sets.at(-1)!.metadata as never);
|
||||
expect(update.sql).toContain("- 'runnerTaskIdentity'");
|
||||
const durable = JSON.parse(String(update.params[0]));
|
||||
expect(durable).toEqual({
|
||||
runnerTaskAdmissionRejection: { taskId: "attempt-1", reason: "session_state_unavailable" },
|
||||
runnerTaskDispatched: false, runnerTaskExecutionStopped: true, runnerTaskSessionRetained: false,
|
||||
});
|
||||
Object.assign(value.lease.metadata, durable);
|
||||
await expect(settleDispatchedEnvironmentTask(database(value).db, workers as never, {
|
||||
companyId: "company", id: leaseId,
|
||||
})).resolves.toBe("stopped");
|
||||
expect(workers.call.mock.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([false, true])("retires an undispatched warm task only with required preparation proof (%s)", async preparationStopped => {
|
||||
const value = row();
|
||||
Object.assign(value.lease.metadata, { runnerTaskDispatched: false,
|
||||
@@ -615,5 +637,10 @@ describe("plugin-provided Runner execution", () => {
|
||||
expect(environmentTaskOperationSchema.safeParse({ ...submit, runner: { ...submit.runner, unknownField: true } }).success).toBe(false);
|
||||
expect(() => parseEnvironmentTaskResult({ kind: "status" }, "attempt-1", { kind: "accepted", taskId: "attempt-1" })).toThrow();
|
||||
expect(parseEnvironmentTaskResult(submit, "attempt-1", { kind: "accepted", taskId: "attempt-1" }).kind).toBe("accepted");
|
||||
const rejected = { kind: "admission_rejected", taskId: "attempt-1", reason: "account_binding_mismatch" };
|
||||
expect(parseEnvironmentTaskResult(submit, "attempt-1", rejected)).toEqual(rejected);
|
||||
expect(() => parseEnvironmentTaskResult(submit, "attempt-2", rejected)).toThrow();
|
||||
expect(() => parseEnvironmentTaskResult(submit, "attempt-1", { ...rejected, reason: "unknown" })).toThrow();
|
||||
expect(() => parseEnvironmentTaskResult({ kind: "stop" }, "attempt-1", rejected)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createHostClientHandlers, type HostServices } from "../../../packages/plugins/sdk/src/host-client-factory.js";
|
||||
import type { PluginCapability } from "@paperclipai/shared";
|
||||
|
||||
const companyId = "00000000-0000-4000-8000-000000000001";
|
||||
const otherCompany = "00000000-0000-4000-8000-000000000002";
|
||||
const first = "00000000-0000-4000-8000-000000000003";
|
||||
const second = "00000000-0000-4000-8000-000000000004";
|
||||
const capabilities: PluginCapability[] = ["projects.read", "project.workspaces.read", "projects.instance-secrets"];
|
||||
const entityType = "project-settings";
|
||||
|
||||
function fixture(granted = capabilities) {
|
||||
const projects = new Map([first, second].map(id => [id, { id, companyId, archivedAt: null as Date | null }]));
|
||||
const entities = new Map([first, second].map(id => [id, { id, status: "ready" }]));
|
||||
const services = {
|
||||
projects: {
|
||||
get: vi.fn(async ({ projectId }: { projectId: string }) => structuredClone(projects.get(projectId) ?? null)),
|
||||
listWorkspaces: vi.fn(async ({ projectId }: { projectId: string }) => [{ projectId }]),
|
||||
getInstanceSecrets: vi.fn(async ({ projectId }: { projectId: string }) => ({ fixture: projectId })),
|
||||
},
|
||||
entities: { list: vi.fn(async ({ externalId }: { externalId: string }) => entities.has(externalId) ? [{ ...entities.get(externalId) }] : []) },
|
||||
};
|
||||
const handlers = createHostClientHandlers({ pluginId: "project-plugin", capabilities: granted, services: services as unknown as HostServices });
|
||||
return { services, projects, entities, read: (projectIds: string[], requestedCompany = companyId) => handlers["projects.readSnapshots"](
|
||||
{ projectIds, companyId: requestedCompany, entityType }, { invocationScope: { companyId } },
|
||||
) };
|
||||
}
|
||||
|
||||
describe("plugin project snapshots", () => {
|
||||
it("returns the requested projects in order with only the plugin's company-scoped entity", async () => {
|
||||
const f = fixture();
|
||||
const result = await f.read([second, first]);
|
||||
expect(result.map(row => [row.projectId, row.workspaces[0].projectId, row.entity?.id, row.instanceSecrets.fixture]))
|
||||
.toEqual([[second, second, second, second], [first, first, first, first]]);
|
||||
expect(f.services.entities.list.mock.calls.map(([query]) => query)).toEqual([second, second, first, first].map(externalId => ({
|
||||
entityType, scopeKind: "company", scopeId: companyId, externalId, limit: 1,
|
||||
})));
|
||||
});
|
||||
|
||||
it("requires each underlying capability and the invocation's company", async () => {
|
||||
for (const omitted of capabilities) {
|
||||
const f = fixture(capabilities.filter(capability => capability !== omitted));
|
||||
await expect(f.read([first])).rejects.toThrow("missing required capability");
|
||||
expect(f.services.projects.get).not.toHaveBeenCalled();
|
||||
}
|
||||
const f = fixture();
|
||||
await expect(f.read([first], otherCompany)).rejects.toThrow(/invocation.*company/i);
|
||||
await expect(f.read([first, first])).rejects.toThrow("Invalid project snapshot scope");
|
||||
expect(f.services.projects.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["wrong company", "archived", "missing", "changed entity"])("rejects the whole batch for a %s project", async failure => {
|
||||
const f = fixture();
|
||||
if (failure === "wrong company") f.projects.set(second, { id: second, companyId: otherCompany, archivedAt: null });
|
||||
else if (failure === "archived") f.projects.get(second)!.archivedAt = new Date();
|
||||
else if (failure === "missing") f.projects.delete(second);
|
||||
else f.services.projects.listWorkspaces.mockImplementation(async ({ projectId }: { projectId: string }) => {
|
||||
if (projectId === second) f.entities.get(second)!.status = "changed";
|
||||
return [{ projectId }];
|
||||
});
|
||||
await expect(f.read([first, second])).rejects.toThrow(/Project snapshot (unavailable|changed during read)/);
|
||||
if (failure !== "changed entity") {
|
||||
expect(f.services.projects.getInstanceSecrets).not.toHaveBeenCalledWith({ companyId, projectId: second });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,27 @@ function seededIssue(input: Partial<Issue> & Pick<Issue, "id" | "companyId" | "t
|
||||
}
|
||||
|
||||
describe("plugin SDK test harness", () => {
|
||||
it("rejects project snapshot requests that the host would deny", async () => {
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "paperclip.test-project-snapshots",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Project Snapshots",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["projects.read", "project.workspaces.read", "projects.instance-secrets"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
};
|
||||
const harness = createTestHarness({ manifest });
|
||||
const projectId = "10000000-0000-4000-8000-000000000001";
|
||||
await expect(harness.ctx.projects.readSnapshots([], "company", "settings")).rejects.toThrow("Invalid project snapshot scope");
|
||||
await expect(harness.ctx.projects.readSnapshots([projectId, projectId], "company", "settings")).rejects.toThrow("Invalid project snapshot scope");
|
||||
await expect(harness.ctx.projects.readSnapshots([projectId], "company", "bad type")).rejects.toThrow("Invalid project snapshot scope");
|
||||
const denied = createTestHarness({ manifest: { ...manifest, capabilities: [] } });
|
||||
await expect(denied.ctx.projects.readSnapshots([], "company", "settings")).rejects.toThrow("missing required capability");
|
||||
});
|
||||
|
||||
it("returns scoped execution workspace metadata with the read capability", async () => {
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "paperclip.test-execution-workspace-metadata",
|
||||
|
||||
@@ -372,6 +372,14 @@ describeEmbeddedPostgres("plugin tenant isolation (company_id FK)", () => {
|
||||
expect(fromInstance?.companyId).toBeNull();
|
||||
expect(fromInstance?.title).toBe("instance");
|
||||
|
||||
const services = buildHostServices(db, pluginId, "tenant-isolation-test", createEventBusStub());
|
||||
await expect(services.entities.list({
|
||||
entityType: "issue", scopeKind: "company", scopeId: companyB, externalId: "ext-shared", limit: 1,
|
||||
})).resolves.toEqual([expect.objectContaining({ title: "B" })]);
|
||||
await expect(services.entities.list({
|
||||
entityType: "issue", scopeKind: "company", scopeId: randomUUID(), externalId: "ext-shared", limit: 1,
|
||||
})).resolves.toEqual([]);
|
||||
|
||||
// Unknown tenant returns null, not another tenant's row.
|
||||
const unknown = await registry.getEntityByExternalId(
|
||||
pluginId,
|
||||
|
||||
@@ -258,7 +258,7 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage
|
||||
if (operation.kind === "submit" || operation.kind === "copilot_metadata") {
|
||||
// No ticket, headers, environment values, or launch paths belong in metadata.
|
||||
const marked = await db.update(environmentLeases).set({
|
||||
metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || ${JSON.stringify({ runnerTaskLease: true, runnerTaskDispatched: true, runnerTaskExecutionStopped: false, runnerTaskSessionRetained: false, ...(operation.kind === "submit" ? { runnerTaskIdentity: operation.runner, runnerTaskResumeFrom: operation.resumeFrom ?? null } : {}) })}::jsonb`,
|
||||
metadata: sql`(coalesce(${environmentLeases.metadata}, '{}'::jsonb) - 'runnerTaskAdmissionRejection') || ${JSON.stringify({ runnerTaskLease: true, runnerTaskDispatched: true, runnerTaskExecutionStopped: false, runnerTaskSessionRetained: false, ...(operation.kind === "submit" ? { runnerTaskIdentity: operation.runner, runnerTaskResumeFrom: operation.resumeFrom ?? null } : {}) })}::jsonb`,
|
||||
}).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, input.companyId),
|
||||
eq(environmentLeases.status, "active"), sql`coalesce(${environmentLeases.metadata}->>'runnerTaskRetiring', 'false') != 'true'`,
|
||||
requiredProjectAuthProof ? sql`${environmentLeases.metadata}->'runnerTaskProjectAuthValidation' = ${JSON.stringify(requiredProjectAuthProof)}::jsonb` : undefined,
|
||||
@@ -280,7 +280,9 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage
|
||||
if (marked.length !== 1) throw new Error("Plugin warm task attachment lost authority");
|
||||
}
|
||||
try {
|
||||
const stoppedWithoutSession = !!run && metadata.runnerTaskExecutionStopped === true && metadata.runnerTaskSessionRetained !== true;
|
||||
const admissionRejection = metadata.runnerTaskAdmissionRejection as { taskId?: unknown } | undefined;
|
||||
const stoppedWithoutSession = !!run && metadata.runnerTaskExecutionStopped === true &&
|
||||
metadata.runnerTaskSessionRetained !== true && admissionRejection?.taskId !== taskId;
|
||||
if (operation.kind === "describe" && (metadata.runnerTaskReused === true || stoppedWithoutSession)) {
|
||||
const state = parseEnvironmentTaskResult({kind:"status"}, taskId, await callWorker("environmentTask", {
|
||||
driverKey: metadata.driverKey, companyId: input.companyId, environmentId: environment?.id ?? null,
|
||||
@@ -408,6 +410,20 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage
|
||||
const result = await callWorker("environmentTask", params, timeout);
|
||||
if (executionContext) context!.assertOpen();
|
||||
const parsed = parseEnvironmentTaskResult(operation, taskId, result);
|
||||
if (operation.kind === "submit" && parsed.kind === "admission_rejected") {
|
||||
const recorded = await db.update(environmentLeases).set({
|
||||
metadata: sql`(coalesce(${environmentLeases.metadata}, '{}'::jsonb) - 'runnerTaskIdentity') || ${JSON.stringify({
|
||||
runnerTaskAdmissionRejection: { taskId, reason: parsed.reason },
|
||||
runnerTaskDispatched: false, runnerTaskExecutionStopped: true, runnerTaskSessionRetained: false,
|
||||
})}::jsonb`,
|
||||
}).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, input.companyId),
|
||||
eq(environmentLeases.providerLeaseId, taskId),
|
||||
sql`${environmentLeases.metadata}->>'runnerTaskDispatched' = 'true'`,
|
||||
sql`${environmentLeases.metadata}->'runnerTaskIdentity' = ${JSON.stringify(operation.runner)}::jsonb`,
|
||||
)).returning({ id: environmentLeases.id });
|
||||
if (recorded.length !== 1) throw new Error("Plugin Runner admission receipt is stale");
|
||||
return parsed;
|
||||
}
|
||||
if (stoppedSourceCheckpoint && parsed.kind === "state" && isSettledReadyRunnerState(parsed.state)) {
|
||||
parsed.state = { ...parsed.state, lifecycle: "suspended" };
|
||||
}
|
||||
|
||||
@@ -13989,6 +13989,16 @@ describe("native session bounded recovery", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("stops same-run retries when the execution provider proves task admission was rejected", () => {
|
||||
for (const prefix of ["", "runner_ingress_unavailable: runnerd exited unexpectedly with code 1: "]) {
|
||||
const code = nativeSessionFailureSourceCode(new Error(`${prefix}runner_task_admission_rejected: session_state_unavailable`));
|
||||
expect(code).toBe("runner_task_admission_rejected");
|
||||
expect(nativeSessionFailureDisposition(1, new Date(), code)).toEqual({
|
||||
phase: "terminal_failure", failureCode: code, nextAttemptAt: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("stops same-run retries when the runner proves the saved provider session incompatible", () => {
|
||||
const failure = new NativeProviderStateIncompatibleError(
|
||||
"Error: PRP command run.attach failed: ACPX durable launch profile digest does not match runner startup",
|
||||
|
||||
@@ -8426,6 +8426,7 @@ export function nativeSessionFailureDisposition(
|
||||
sourceFailureCode?: ReturnType<typeof nativeSessionFailureSourceCode>,
|
||||
) {
|
||||
const permanentFailure =
|
||||
sourceFailureCode === "runner_task_admission_rejected" ||
|
||||
sourceFailureCode === "runner_ingress_access_rejected" ||
|
||||
sourceFailureCode === "native_provider_runtime_missing" ||
|
||||
sourceFailureCode === "native_provider_model_rejected" ||
|
||||
@@ -8543,6 +8544,7 @@ export function nativeSessionFailureSourceCode(
|
||||
| typeof NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ
|
||||
| "provider_session_open_rejected"
|
||||
| "provider_session_open_timeout"
|
||||
| "runner_task_admission_rejected"
|
||||
| "runner_ingress_access_rejected"
|
||||
| "runner_ingress_acquisition_failed"
|
||||
| "local_runtime_api_listener_unavailable"
|
||||
@@ -8583,6 +8585,9 @@ export function nativeSessionFailureSourceCode(
|
||||
}
|
||||
if (runnerClosedTurnStartSession(error)) return NATIVE_PROVIDER_SESSION_CLOSED;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/^(?:runner_ingress_unavailable: runnerd exited unexpectedly with code 1: )?runner_task_admission_rejected: (session_state_unavailable|account_binding_mismatch)$/.test(message)) {
|
||||
return "runner_task_admission_rejected";
|
||||
}
|
||||
if (/classification=runtime_package_missing\)/.test(message))
|
||||
return "native_provider_runtime_missing";
|
||||
if (/native_provider_model_rejected/i.test(message))
|
||||
@@ -8679,6 +8684,9 @@ export function nativeSessionFailureSourceCode(
|
||||
const NATIVE_PROVIDER_SIDE_EFFECT_PENDING_ACTION =
|
||||
"The saved provider session has an unfinished provider turn, or a tool call whose result never reached the runner, so Paperclip did not replace the session or retry it. Reconcile the action: check whether it ran and record the outcome. The task then continues in a fresh provider session in the same workspace. The saved session and workspace are unchanged.";
|
||||
|
||||
const RUNNER_TASK_ADMISSION_REJECTED_ACTION =
|
||||
"The execution provider refused to start this task. Review the admission reason before retrying. Automatic retries are stopped.";
|
||||
|
||||
const NATIVE_PROVIDER_STATE_INCOMPATIBLE_ACTION =
|
||||
"The current runner cannot restore this task's saved provider session, so retrying this run cannot succeed. Retry the task to continue in a fresh provider session with the same workspace. The earlier provider conversation is not carried over.";
|
||||
|
||||
@@ -12425,6 +12433,8 @@ async function executePaperclipNativeSessionAttempt(
|
||||
? nativeCleanupRefusalAction(completedTurnCleanup)
|
||||
: sourceFailureCode === "runner_remote_provider_verification_unavailable"
|
||||
? "Provider files could not be verified after two read attempts. Inspect the retained workspace and command transport before retrying the original run."
|
||||
: sourceFailureCode === "runner_task_admission_rejected"
|
||||
? RUNNER_TASK_ADMISSION_REJECTED_ACTION
|
||||
: sourceFailureCode === "runner_ingress_access_rejected"
|
||||
? "The computer provider rejected access. Restore the environment's credentials or permissions before retrying. Automatic retries are stopped."
|
||||
: sourceFailureCode === "native_provider_terminal_failed"
|
||||
@@ -12694,6 +12704,8 @@ async function executePaperclipNativeSessionAttempt(
|
||||
? NATIVE_PROVIDER_SESSION_CLOSED_ACTION
|
||||
: sourceFailureCode === "native_session_cleanup_quarantined"
|
||||
? nativeCleanupRefusalAction(completedTurnCleanup)
|
||||
: sourceFailureCode === "runner_task_admission_rejected"
|
||||
? RUNNER_TASK_ADMISSION_REJECTED_ACTION
|
||||
: sourceFailureCode === "runner_ingress_access_rejected"
|
||||
? "The computer provider rejected access. Restore the environment's credentials or permissions before retrying. Automatic retries are stopped."
|
||||
: sourceFailureCode === "native_provider_terminal_failed"
|
||||
|
||||
@@ -440,6 +440,23 @@ describe("plugin native Runner ownership", () => {
|
||||
expect(task.mock.calls.find(([op]) => op.kind === "stop")).toBeUndefined();
|
||||
await registration.release(); await registration.checkpoint("settled"); await handle.completion;
|
||||
});
|
||||
it("surfaces a proven admission rejection without polling or stopping the task", async () => {
|
||||
const { task } = provider();
|
||||
const perform = task.getMockImplementation()!;
|
||||
task.mockImplementation(async op => op.kind === "submit"
|
||||
? { kind: "admission_rejected", taskId: "task", reason: "session_state_unavailable" } : perform(op));
|
||||
const owner = createPluginRunnerTaskOwner({ taskId: "task", projectAuthCheckVersion: 1, task, projectIds: [], harness: "codex" });
|
||||
await prepare(owner);
|
||||
const registration = await owner.registration({} as never, "run");
|
||||
const process = owner.launcher(launchSpec());
|
||||
registration.activate();
|
||||
await expect(registration.ready()).rejects.toThrow("runner_task_admission_rejected: session_state_unavailable");
|
||||
await expect(registration.failure).rejects.toThrow("runner_task_admission_rejected: session_state_unavailable");
|
||||
await expect(process.completion).resolves.toMatchObject({ code: 1 });
|
||||
await registration.checkpoint("unsettled");
|
||||
expect(task.mock.calls.map(([op]) => op.kind)).toEqual(["prepare", "check_project_auth", "submit"]);
|
||||
});
|
||||
|
||||
it("reconciles a lost submit reply without launching another attempt", async () => {
|
||||
const { task } = provider();
|
||||
const perform = task.getMockImplementation()!;
|
||||
|
||||
@@ -71,7 +71,7 @@ export function createPluginRunnerTaskOwner(input: {
|
||||
let adoptionVerified = false;
|
||||
let markReady!: () => void;
|
||||
let failReady!: (error: unknown) => void;
|
||||
let confirmStopped: (code: number) => void = () => {};
|
||||
let confirmStopped: (code: number, error?: Error) => void = () => {};
|
||||
let stoppedState: Extract<PluginEnvironmentTaskResult, { kind: "status" }> | undefined;
|
||||
let completed = false;
|
||||
let retiredBeforeClaim = false;
|
||||
@@ -225,11 +225,11 @@ export function createPluginRunnerTaskOwner(input: {
|
||||
return true;
|
||||
} };
|
||||
const completion = new Promise<Awaited<RunnerProcessHandle["completion"]>>(resolve => {
|
||||
confirmStopped = code => {
|
||||
confirmStopped = (code, error) => {
|
||||
if (child.exitCode !== null) return;
|
||||
child.exitCode = code;
|
||||
failReady(new Error("Plugin Runner stopped before connection"));
|
||||
resolve({ code, signal: null, stdout: "", stderr: "" });
|
||||
failReady(error ?? new Error("Plugin Runner stopped before connection"));
|
||||
resolve({ code, signal: null, stdout: "", stderr: error?.message ?? "" });
|
||||
};
|
||||
});
|
||||
void (async () => {
|
||||
@@ -248,9 +248,17 @@ export function createPluginRunnerTaskOwner(input: {
|
||||
return;
|
||||
}
|
||||
submitted = true;
|
||||
try { await call(operation); } catch {
|
||||
const result = await call(operation).catch(async () => {
|
||||
// Submission can have succeeded remotely. Inspect the same saved task.
|
||||
await readStatus();
|
||||
});
|
||||
if (result?.kind === "admission_rejected") {
|
||||
submitted = false;
|
||||
removeStartupAbort();
|
||||
const error = new Error(`runner_task_admission_rejected: ${result.reason}`);
|
||||
rejectFailure(error);
|
||||
confirmStopped(1, error);
|
||||
return;
|
||||
}
|
||||
let stopSent = false;
|
||||
while (child.exitCode === null) {
|
||||
|
||||
@@ -1754,7 +1754,7 @@ export function buildHostServices(
|
||||
return registry.upsertEntity(pluginId, params as any) as any;
|
||||
},
|
||||
async list(params) {
|
||||
return registry.listEntities(pluginId, params as any) as any;
|
||||
return registry.listEntities(pluginId, params) as any;
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -541,6 +541,8 @@ export function pluginRegistryService(db: Db) {
|
||||
const conditions = [eq(pluginEntities.pluginId, pluginId)];
|
||||
if (query?.entityType) conditions.push(eq(pluginEntities.entityType, query.entityType));
|
||||
if (query?.externalId) conditions.push(eq(pluginEntities.externalId, query.externalId));
|
||||
if (query?.scopeKind) conditions.push(eq(pluginEntities.scopeKind, query.scopeKind));
|
||||
if (query?.scopeId) conditions.push(eq(pluginEntities.scopeId, query.scopeId));
|
||||
|
||||
return db
|
||||
.select()
|
||||
|
||||
@@ -13,23 +13,23 @@
|
||||
"packages/plugins/sdk/src/environment-stop-error.ts": "b0e03f8aa2b2170ba095da5c7138050fced3f3cb",
|
||||
"packages/plugins/sdk/src/environment-sync-error.ts": "7d6a51a95da49a7da4525aebbab6382dc5c44ae8",
|
||||
"packages/plugins/sdk/src/environment-task-error.ts": "98aae8a06743b38884b68cb9a3ec44c57b34c0e4",
|
||||
"packages/plugins/sdk/src/environment-tasks.ts": "3dd691d4423402ab7c0d3249a03bf6b22eeb78f2",
|
||||
"packages/plugins/sdk/src/host-client-factory.ts": "f1bba9f1830701510f2c94bc623465cb2d93413b",
|
||||
"packages/plugins/sdk/src/environment-tasks.ts": "98e5f69a00b81eb6e8c25bdb5fcdc22e5485ef18",
|
||||
"packages/plugins/sdk/src/host-client-factory.ts": "e0301c390ea13f2340b669383f07af2455e48c8a",
|
||||
"packages/plugins/sdk/src/idle-drain.test.ts": "cc59f7881e7d46c89395a1d93d74f6c6b9b0a64a",
|
||||
"packages/plugins/sdk/src/idle-drain.ts": "ae6e4c6be4657a3d541396922b3bc551b8db557f",
|
||||
"packages/plugins/sdk/src/index.ts": "20e52d9e4ad3d5015cfbc604da49756e568e34ba",
|
||||
"packages/plugins/sdk/src/index.ts": "4e573c181198ea98442321cc136341741e856ff7",
|
||||
"packages/plugins/sdk/src/invocation-tracer.ts": "b28ac4aa4bc3e82658abac4a8081ed4508008696",
|
||||
"packages/plugins/sdk/src/protocol.duplex-channel.test.ts": "18255eb3d63038dc459240c111cbcb29fcba25cc",
|
||||
"packages/plugins/sdk/src/protocol.ts": "215258cc8862b342acdbd6446fb49d00cc578fd8",
|
||||
"packages/plugins/sdk/src/testing.ts": "eb496adf22c62b6d68ef467c25465afd0973d236",
|
||||
"packages/plugins/sdk/src/types.ts": "9555522abfd796c59b1439dd433be356ec344bb5",
|
||||
"packages/plugins/sdk/src/protocol.ts": "95e8af999dabf93b1b0e531edfec4c80daf52b61",
|
||||
"packages/plugins/sdk/src/testing.ts": "fa99065efa867d8f8a9372cb4a448b8f9d687883",
|
||||
"packages/plugins/sdk/src/types.ts": "80375e46504f96c7c0c1f156319dfb81275d16cc",
|
||||
"packages/plugins/sdk/src/ui/clipboard.ts": "1f42069c97d731e636a6ad5481dad22d20c0ed63",
|
||||
"packages/plugins/sdk/src/ui/components.ts": "3f0594408a1bb5ac3a8d00e709f91877dd260359",
|
||||
"packages/plugins/sdk/src/ui/hooks.ts": "686d02cf4b35f1cddb79a83a4c4d81fd52f0100e",
|
||||
"packages/plugins/sdk/src/ui/index.ts": "f7d1cee64a8f0c924bd10a1bd20745a52fe2060e",
|
||||
"packages/plugins/sdk/src/ui/runtime.ts": "998428e9701eba3b762d3417236d32c2d0d5763d",
|
||||
"packages/plugins/sdk/src/ui/types.ts": "5a5644acf6554d2e5e1d000bfd03b97d57001eb2",
|
||||
"packages/plugins/sdk/src/worker-rpc-host.ts": "f6b801fc08596a8ab6344f12ac0ee80f256b2066",
|
||||
"packages/plugins/sdk/src/worker-rpc-host.ts": "72f844508f695737a44d34b01b3949ec3be10aec",
|
||||
"packages/plugins/sdk/src/workspace-operation-error.ts": "2cd247d002d83b163239229e631bca4e519e5075",
|
||||
"packages/plugins/sdk/src/workspace-provider.ts": "c212c3baffd4151010060c9ec7393bd989a9e833",
|
||||
"packages/plugins/sdk/tsconfig.json": "d62ebdff94bc4b75dabf1fcad187e1e7a90fc794",
|
||||
@@ -405,7 +405,7 @@
|
||||
"packages/shared/src/types/issue.ts": "4598caecc50e60450a1204de832a4fcd7991096d",
|
||||
"packages/shared/src/types/live.ts": "b90e55e4de8600c6fc17d7fa8d89079d4ae655ec",
|
||||
"packages/shared/src/types/native-finalization.ts": "c026a73003ae232fea4f7624d561493b383df358",
|
||||
"packages/shared/src/types/plugin.ts": "31fe2fb3346dabbf83aa7e37193bb0dd229ea8a7",
|
||||
"packages/shared/src/types/plugin.ts": "36e0f50d97f96532cb1fbdf3223368d92a7e9b1e",
|
||||
"packages/shared/src/types/project.ts": "2a20b4d8772d5f0fa5015828818b025abc7cedcf",
|
||||
"packages/shared/src/types/quota.ts": "a0686a9afb3111a4de093f66200dc0c9152e883e",
|
||||
"packages/shared/src/types/resource-memberships.ts": "9eab6fad50facf3e489f8c40e5bedd92e1d1faf8",
|
||||
|
||||
Reference in new issue
Block a user