mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
Merge master into agent configuration redesign
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
e2b4bb6009
23 files changed
+1369
-62
No files matched your search
@@ -100,17 +100,6 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
mockAdapterExecute.mockReset();
|
||||
mockAdapterExecute.mockImplementation(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: null,
|
||||
summary: "Dependency-aware heartbeat test run.",
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
}));
|
||||
runningProcesses.clear();
|
||||
let idlePolls = 0;
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const runs = await db
|
||||
@@ -125,7 +114,22 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const runIds = await db
|
||||
.select({ id: heartbeatRuns.id })
|
||||
.from(heartbeatRuns)
|
||||
.then((runs) => runs.map((run) => run.id));
|
||||
await Promise.all(runIds.map((runId) => heartbeat.waitForRunExecutionDrain(runId)));
|
||||
mockAdapterExecute.mockReset();
|
||||
mockAdapterExecute.mockImplementation(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: null,
|
||||
summary: "Dependency-aware heartbeat test run.",
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
}));
|
||||
runningProcesses.clear();
|
||||
await db.delete(environmentLeases);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(companySkills);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { heartbeatRuns } from "@paperclipai/db";
|
||||
import { resolveLedgerScopeForRun } from "../services/heartbeat.ts";
|
||||
|
||||
type IssueRow = { id: string; projectId: string | null; billingCode: string | null };
|
||||
|
||||
/**
|
||||
* Minimal Drizzle stand-in for the single lookup `resolveLedgerScopeForRun`
|
||||
* performs: `db.select({...}).from(issues).where(...)` resolved as a thenable.
|
||||
*/
|
||||
type LedgerDb = Parameters<typeof resolveLedgerScopeForRun>[0];
|
||||
|
||||
function makeDb(rows: IssueRow[]) {
|
||||
const where = vi.fn(() => Promise.resolve(rows));
|
||||
const from = vi.fn(() => ({ where }));
|
||||
const select = vi.fn(() => ({ from }));
|
||||
return { db: { select } as unknown as LedgerDb, select, from, where };
|
||||
}
|
||||
|
||||
function makeRun(contextSnapshot: Record<string, unknown>) {
|
||||
return { id: "run-1", contextSnapshot } as unknown as typeof heartbeatRuns.$inferSelect;
|
||||
}
|
||||
|
||||
describe("resolveLedgerScopeForRun billing code propagation", () => {
|
||||
it("carries the issue's billing code onto the ledger scope", async () => {
|
||||
const { db } = makeDb([{ id: "issue-1", projectId: "project-1", billingCode: "ACME-42" }]);
|
||||
|
||||
const scope = await resolveLedgerScopeForRun(db, "company-1", makeRun({
|
||||
issueId: "issue-1",
|
||||
projectId: "context-project",
|
||||
}));
|
||||
|
||||
expect(scope).toEqual({
|
||||
issueId: "issue-1",
|
||||
projectId: "project-1",
|
||||
billingCode: "ACME-42",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a null billing code when the issue has none set", async () => {
|
||||
const { db } = makeDb([{ id: "issue-1", projectId: "project-1", billingCode: null }]);
|
||||
|
||||
const scope = await resolveLedgerScopeForRun(db, "company-1", makeRun({
|
||||
issueId: "issue-1",
|
||||
projectId: "context-project",
|
||||
}));
|
||||
|
||||
expect(scope.billingCode).toBeNull();
|
||||
expect(scope.issueId).toBe("issue-1");
|
||||
});
|
||||
|
||||
it("resolves a null billing code without querying when the run has no issue in context", async () => {
|
||||
const { db, select } = makeDb([]);
|
||||
|
||||
const scope = await resolveLedgerScopeForRun(db, "company-1", makeRun({
|
||||
projectId: "context-project",
|
||||
}));
|
||||
|
||||
expect(scope).toEqual({
|
||||
issueId: null,
|
||||
projectId: "context-project",
|
||||
billingCode: null,
|
||||
});
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves a null billing code when the issue is not visible to the company", async () => {
|
||||
const { db } = makeDb([]);
|
||||
|
||||
const scope = await resolveLedgerScopeForRun(db, "other-company", makeRun({
|
||||
issueId: "issue-1",
|
||||
projectId: "context-project",
|
||||
}));
|
||||
|
||||
expect(scope).toEqual({
|
||||
issueId: null,
|
||||
projectId: "context-project",
|
||||
billingCode: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -103,6 +103,28 @@ export async function resolveEnvironmentExecutionTarget(input: {
|
||||
startedAt,
|
||||
};
|
||||
},
|
||||
// Expose the native file-sync capability only when the provider's
|
||||
// worker advertises BOTH sync verbs; otherwise leave syncIn/syncOut
|
||||
// undefined so the orchestrator keeps the byte-identical base64 path.
|
||||
...(input.environmentRuntime.supportsSync({
|
||||
environment: input.environment as Environment,
|
||||
lease: input.lease,
|
||||
})
|
||||
? {
|
||||
syncIn: (operations) =>
|
||||
input.environmentRuntime!.syncIn({
|
||||
environment: input.environment as Environment,
|
||||
lease: input.lease!,
|
||||
operations,
|
||||
}),
|
||||
syncOut: (operations) =>
|
||||
input.environmentRuntime!.syncOut({
|
||||
environment: input.environment as Environment,
|
||||
lease: input.lease!,
|
||||
operations,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
PluginEnvironmentExecuteResult,
|
||||
PluginEnvironmentLease,
|
||||
PluginEnvironmentRealizeWorkspaceResult,
|
||||
PluginEnvironmentSyncResult,
|
||||
PluginSyncOperation,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import { ensureSshWorkspaceReady } from "@paperclipai/adapter-utils/ssh";
|
||||
import { environmentService } from "./environments.js";
|
||||
@@ -185,6 +187,10 @@ export interface EnvironmentDriverExecuteInput extends EnvironmentDriverLeaseInp
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface EnvironmentDriverSyncInput extends EnvironmentDriverLeaseInput {
|
||||
operations: PluginSyncOperation[];
|
||||
}
|
||||
|
||||
export interface EnvironmentRuntimeDriver {
|
||||
readonly driver: string;
|
||||
acquireRunLease(input: EnvironmentDriverAcquireInput): Promise<EnvironmentLease>;
|
||||
@@ -193,6 +199,16 @@ export interface EnvironmentRuntimeDriver {
|
||||
destroyRunLease?(input: EnvironmentDriverLeaseInput): Promise<EnvironmentLease | null>;
|
||||
realizeWorkspace?(input: EnvironmentDriverRealizeWorkspaceInput): Promise<PluginEnvironmentRealizeWorkspaceResult>;
|
||||
execute?(input: EnvironmentDriverExecuteInput): Promise<PluginEnvironmentExecuteResult>;
|
||||
/**
|
||||
* Optional native inbound/outbound file transfer, delegated to the plugin
|
||||
* worker's `environmentSyncIn`/`environmentSyncOut` verbs. Only present for
|
||||
* plugin-backed sandbox drivers whose worker advertises both verbs; callers
|
||||
* gate on {@link EnvironmentRuntimeDriver.supportsSync}.
|
||||
*/
|
||||
syncIn?(input: EnvironmentDriverSyncInput): Promise<PluginEnvironmentSyncResult>;
|
||||
syncOut?(input: EnvironmentDriverSyncInput): Promise<PluginEnvironmentSyncResult>;
|
||||
/** True when the lease's plugin worker advertises both sync verbs. */
|
||||
supportsSync?(input: EnvironmentDriverLeaseInput): boolean;
|
||||
}
|
||||
|
||||
export interface EnvironmentRuntimeLeaseRecord {
|
||||
@@ -722,6 +738,39 @@ function createSandboxEnvironmentDriver(
|
||||
}
|
||||
}
|
||||
|
||||
async function callPluginEnvironmentSync(
|
||||
method: "environmentSyncIn" | "environmentSyncOut",
|
||||
input: EnvironmentDriverSyncInput,
|
||||
): Promise<PluginEnvironmentSyncResult> {
|
||||
if (!input.lease.metadata?.sandboxProviderPlugin || !pluginWorkerManager) {
|
||||
throw new Error("Sandbox driver does not support native file sync for this lease.");
|
||||
}
|
||||
const pluginId = readString(input.lease.metadata?.pluginId);
|
||||
const providerKey = readString(input.lease.metadata?.provider);
|
||||
if (!pluginId || !providerKey) {
|
||||
throw new Error("Sandbox lease is missing plugin/provider metadata for native file sync.");
|
||||
}
|
||||
const config = await resolvePluginSandboxRuntimeConfig({
|
||||
environment: input.environment,
|
||||
lease: input.lease,
|
||||
provider: providerKey,
|
||||
});
|
||||
const sanitizedConfig = stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig);
|
||||
return await pluginWorkerManager.call(pluginId, method, {
|
||||
driverKey: providerKey,
|
||||
companyId: input.lease.companyId,
|
||||
environmentId: input.environment.id,
|
||||
issueId: input.lease.issueId,
|
||||
config: sanitizedConfig,
|
||||
lease: {
|
||||
providerLeaseId: input.lease.providerLeaseId,
|
||||
metadata: input.lease.metadata ?? undefined,
|
||||
expiresAt: input.lease.expiresAt?.toISOString() ?? null,
|
||||
},
|
||||
operations: input.operations,
|
||||
}, resolvePluginSandboxRpcTimeoutMs(sanitizedConfig));
|
||||
}
|
||||
|
||||
return {
|
||||
driver: "sandbox",
|
||||
|
||||
@@ -1235,6 +1284,22 @@ function createSandboxEnvironmentDriver(
|
||||
throw new Error("Sandbox driver does not support direct command execution for built-in providers.");
|
||||
},
|
||||
|
||||
supportsSync(input) {
|
||||
if (!input.lease.metadata?.sandboxProviderPlugin || !pluginWorkerManager) return false;
|
||||
const pluginId = readString(input.lease.metadata?.pluginId);
|
||||
if (!pluginId) return false;
|
||||
const advertised = pluginWorkerManager.getWorker(pluginId)?.supportedMethods ?? [];
|
||||
return advertised.includes("environmentSyncIn") && advertised.includes("environmentSyncOut");
|
||||
},
|
||||
|
||||
async syncIn(input) {
|
||||
return await callPluginEnvironmentSync("environmentSyncIn", input);
|
||||
},
|
||||
|
||||
async syncOut(input) {
|
||||
return await callPluginEnvironmentSync("environmentSyncOut", input);
|
||||
},
|
||||
|
||||
async destroyRunLease(input) {
|
||||
return await destroyReusableSandboxLease({
|
||||
environment: input.environment,
|
||||
@@ -1890,6 +1955,27 @@ export function environmentRuntimeService(
|
||||
}
|
||||
return await driver.execute(input);
|
||||
},
|
||||
|
||||
supportsSync(input: EnvironmentDriverLeaseInput): boolean {
|
||||
const driver = getDriver(getLeaseDriverKey(input.lease, input.environment));
|
||||
return driver?.supportsSync?.(input) ?? false;
|
||||
},
|
||||
|
||||
async syncIn(input: EnvironmentDriverSyncInput): Promise<PluginEnvironmentSyncResult> {
|
||||
const driver = requireDriverKey(getLeaseDriverKey(input.lease, input.environment));
|
||||
if (!driver.syncIn) {
|
||||
throw new Error(`Environment driver "${driver.driver}" does not support native file sync.`);
|
||||
}
|
||||
return await driver.syncIn(input);
|
||||
},
|
||||
|
||||
async syncOut(input: EnvironmentDriverSyncInput): Promise<PluginEnvironmentSyncResult> {
|
||||
const driver = requireDriverKey(getLeaseDriverKey(input.lease, input.environment));
|
||||
if (!driver.syncOut) {
|
||||
throw new Error(`Environment driver "${driver.driver}" does not support native file sync.`);
|
||||
}
|
||||
return await driver.syncOut(input);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2667,7 +2667,7 @@ export function resolveLedgerCostStatus(input: {
|
||||
return input.costUsd == null && hasTokenUsage ? "unpriced" : "reported";
|
||||
}
|
||||
|
||||
async function resolveLedgerScopeForRun(
|
||||
export async function resolveLedgerScopeForRun(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
@@ -2680,6 +2680,7 @@ async function resolveLedgerScopeForRun(
|
||||
return {
|
||||
issueId: null,
|
||||
projectId: contextProjectId,
|
||||
billingCode: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2687,6 +2688,7 @@ async function resolveLedgerScopeForRun(
|
||||
.select({
|
||||
id: issues.id,
|
||||
projectId: issues.projectId,
|
||||
billingCode: issues.billingCode,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, contextIssueId), eq(issues.companyId, companyId)))
|
||||
@@ -2695,6 +2697,7 @@ async function resolveLedgerScopeForRun(
|
||||
return {
|
||||
issueId: issue?.id ?? null,
|
||||
projectId: issue?.projectId ?? contextProjectId,
|
||||
billingCode: issue?.billingCode ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11653,6 +11656,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
agentId: agent.id,
|
||||
issueId: ledgerScope.issueId,
|
||||
projectId: ledgerScope.projectId,
|
||||
billingCode: ledgerScope.billingCode,
|
||||
provider,
|
||||
biller,
|
||||
billingType,
|
||||
|
||||
Reference in new issue
Block a user