mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Fix routine env binding sync review issues
This commit is contained in:
1 parent
48f530699e
commit
596b4b3c64
5 files changed
+154
-31
No files matched your search
@@ -382,8 +382,25 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
const [initialRevision] = await svc.listRevisions(routine.id);
|
||||
expect(initialRevision?.snapshot.routine.env).toEqual(routine.env);
|
||||
|
||||
await db.delete(companySecretBindings).where(eq(companySecretBindings.targetId, routine.id));
|
||||
const repaired = await svc.update(routine.id, { env: routine.env }, {});
|
||||
expect(repaired).not.toBeNull();
|
||||
const repairedBindings = await db
|
||||
.select()
|
||||
.from(companySecretBindings)
|
||||
.where(eq(companySecretBindings.targetId, routine.id));
|
||||
expect(repairedBindings).toMatchObject([
|
||||
{
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
targetType: "routine",
|
||||
configPath: "env.ROUTINE_API_KEY",
|
||||
},
|
||||
]);
|
||||
|
||||
const currentRoutine = repaired ?? routine;
|
||||
const runBefore = await svc.runRoutine(routine.id, { source: "manual" });
|
||||
expect(runBefore.routineRevisionId).toBe(routine.latestRevisionId);
|
||||
expect(runBefore.routineRevisionId).toBe(currentRoutine.latestRevisionId);
|
||||
|
||||
const updated = await svc.update(
|
||||
routine.id,
|
||||
@@ -395,7 +412,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
},
|
||||
{},
|
||||
);
|
||||
expect(updated?.latestRevisionNumber).toBe(routine.latestRevisionNumber + 1);
|
||||
expect(updated?.latestRevisionNumber).toBe(currentRoutine.latestRevisionNumber + 1);
|
||||
|
||||
const runAfter = await svc.runRoutine(routine.id, { source: "manual" });
|
||||
expect(runAfter.routineRevisionId).toBe(updated?.latestRevisionId);
|
||||
|
||||
@@ -1562,15 +1562,16 @@ export function routineService(
|
||||
const { routine } = await appendRoutineRevision(txDb, created, actor, {
|
||||
changeSummary: "Created routine",
|
||||
});
|
||||
if (env) {
|
||||
await secretsSvc.syncEnvBindingsForTarget(
|
||||
companyId,
|
||||
{ targetType: "routine", targetId: routine.id },
|
||||
env,
|
||||
{ db: tx },
|
||||
);
|
||||
}
|
||||
return routine;
|
||||
});
|
||||
if (env) {
|
||||
await secretsSvc.syncEnvBindingsForTarget(
|
||||
companyId,
|
||||
{ targetType: "routine", targetId: createdRoutine.id },
|
||||
env,
|
||||
);
|
||||
}
|
||||
return createdRoutine;
|
||||
},
|
||||
|
||||
@@ -1672,6 +1673,14 @@ export function routineService(
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (latestRevision && snapshotsMatch(nextSnapshot, latestRevision.snapshot as RoutineRevisionSnapshotV1)) {
|
||||
if (patch.env !== undefined) {
|
||||
await secretsSvc.syncEnvBindingsForTarget(
|
||||
locked.companyId,
|
||||
{ targetType: "routine", targetId: locked.id },
|
||||
candidate.env,
|
||||
{ db: tx },
|
||||
);
|
||||
}
|
||||
return locked;
|
||||
}
|
||||
}
|
||||
@@ -1701,15 +1710,16 @@ export function routineService(
|
||||
const { routine } = await appendRoutineRevision(txDb, updated, actor, {
|
||||
changeSummary: "Updated routine",
|
||||
});
|
||||
if (patch.env !== undefined) {
|
||||
await secretsSvc.syncEnvBindingsForTarget(
|
||||
routine.companyId,
|
||||
{ targetType: "routine", targetId: routine.id },
|
||||
routine.env,
|
||||
{ db: tx },
|
||||
);
|
||||
}
|
||||
return routine;
|
||||
});
|
||||
if (updatedRoutine && patch.env !== undefined) {
|
||||
await secretsSvc.syncEnvBindingsForTarget(
|
||||
updatedRoutine.companyId,
|
||||
{ targetType: "routine", targetId: updatedRoutine.id },
|
||||
updatedRoutine.env,
|
||||
);
|
||||
}
|
||||
return updatedRoutine;
|
||||
},
|
||||
|
||||
@@ -2084,6 +2094,12 @@ export function routineService(
|
||||
changeSummary: `Restored from revision ${targetRevision.revisionNumber}`,
|
||||
restoredFromRevisionId: targetRevision.id,
|
||||
});
|
||||
await secretsSvc.syncEnvBindingsForTarget(
|
||||
locked.companyId,
|
||||
{ targetType: "routine", targetId: locked.id },
|
||||
routineSnapshot.env,
|
||||
{ db: tx },
|
||||
);
|
||||
return {
|
||||
routine: appended.routine,
|
||||
revision: appended.revision,
|
||||
@@ -2092,11 +2108,6 @@ export function routineService(
|
||||
secretMaterials: [...recreatedWebhookSecrets.values()].map((entry) => entry.secretMaterial),
|
||||
};
|
||||
});
|
||||
await secretsSvc.syncEnvBindingsForTarget(
|
||||
existingRoutine.companyId,
|
||||
{ targetType: "routine", targetId: existingRoutine.id },
|
||||
routineSnapshot.env,
|
||||
);
|
||||
return result;
|
||||
},
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ const COMING_SOON_SECRET_PROVIDERS: ReadonlySet<SecretProvider> = new Set([
|
||||
"gcp_secret_manager",
|
||||
"vault",
|
||||
]);
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type SecretBindingDb = Pick<Db | DbTransaction, "select" | "delete" | "insert">;
|
||||
|
||||
function remoteProviderHttpError(error: unknown, context: {
|
||||
companyId: string;
|
||||
@@ -294,8 +296,8 @@ export function secretService(db: Db) {
|
||||
fieldPath?: string;
|
||||
};
|
||||
|
||||
async function getById(id: string) {
|
||||
return db
|
||||
async function getById(id: string, source: Pick<Db | DbTransaction, "select"> = db) {
|
||||
return source
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(eq(companySecrets.id, id))
|
||||
@@ -401,8 +403,12 @@ export function secretService(db: Db) {
|
||||
});
|
||||
}
|
||||
|
||||
async function assertSecretInCompany(companyId: string, secretId: string) {
|
||||
const secret = await getById(secretId);
|
||||
async function assertSecretInCompany(
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
source: Pick<Db | DbTransaction, "select"> = db,
|
||||
) {
|
||||
const secret = await getById(secretId, source);
|
||||
if (!secret) throw notFound("Secret not found");
|
||||
if (secret.status === "deleted") throw notFound("Secret not found");
|
||||
if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company");
|
||||
@@ -2025,6 +2031,7 @@ export function secretService(db: Db) {
|
||||
companyId: string,
|
||||
target: { targetType: SecretBindingTargetType; targetId: string; pathPrefix?: string },
|
||||
envValue: unknown,
|
||||
options?: { db?: SecretBindingDb },
|
||||
) => {
|
||||
const record = asRecord(envValue) ?? {};
|
||||
const refs: Array<{
|
||||
@@ -2033,12 +2040,13 @@ export function secretService(db: Db) {
|
||||
versionSelector: SecretVersionSelector;
|
||||
}> = [];
|
||||
const pathPrefix = target.pathPrefix ?? "env";
|
||||
const bindingDb = options?.db ?? db;
|
||||
for (const [key, rawBinding] of Object.entries(record)) {
|
||||
const parsed = envBindingSchema.safeParse(rawBinding);
|
||||
if (!parsed.success) continue;
|
||||
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
||||
if (binding.type !== "secret_ref") continue;
|
||||
await assertSecretInCompany(companyId, binding.secretId);
|
||||
await assertSecretInCompany(companyId, binding.secretId, bindingDb);
|
||||
refs.push({
|
||||
secretId: binding.secretId,
|
||||
configPath: `${pathPrefix}.${key}`,
|
||||
@@ -2046,8 +2054,8 @@ export function secretService(db: Db) {
|
||||
});
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
const writeBindings = async (targetDb: SecretBindingDb) => {
|
||||
await targetDb
|
||||
.delete(companySecretBindings)
|
||||
.where(
|
||||
and(
|
||||
@@ -2058,7 +2066,7 @@ export function secretService(db: Db) {
|
||||
),
|
||||
);
|
||||
if (refs.length === 0) return;
|
||||
await tx.insert(companySecretBindings).values(
|
||||
await targetDb.insert(companySecretBindings).values(
|
||||
refs.map((ref) => ({
|
||||
companyId,
|
||||
secretId: ref.secretId,
|
||||
@@ -2069,7 +2077,13 @@ export function secretService(db: Db) {
|
||||
required: true,
|
||||
})),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (options?.db) {
|
||||
await writeBindings(options.db);
|
||||
} else {
|
||||
await db.transaction(async (tx) => writeBindings(tx));
|
||||
}
|
||||
return refs;
|
||||
},
|
||||
|
||||
|
||||
@@ -389,6 +389,87 @@ describe("RoutineHistoryTab", () => {
|
||||
expect(container.textContent).toContain("1 key changed");
|
||||
});
|
||||
|
||||
it("labels secret-ref env diffs by changed secret instead of binding kind", async () => {
|
||||
const current = createRevision({
|
||||
id: "revision-2",
|
||||
revisionNumber: 2,
|
||||
snapshot: snapshotV1({
|
||||
env: { GH_TOKEN: { type: "secret_ref", secretId: "secret-2", version: "latest" } },
|
||||
}),
|
||||
});
|
||||
const old = createRevision({
|
||||
id: "revision-1",
|
||||
revisionNumber: 1,
|
||||
snapshot: snapshotV1({
|
||||
env: { GH_TOKEN: { type: "secret_ref", secretId: "secret-1", version: "latest" } },
|
||||
}),
|
||||
});
|
||||
const secrets: CompanySecret[] = [
|
||||
{
|
||||
id: "secret-1",
|
||||
companyId: "company-1",
|
||||
key: "old_token",
|
||||
name: "old-token",
|
||||
provider: "local_encrypted",
|
||||
status: "active",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 1,
|
||||
description: null,
|
||||
lastResolvedAt: null,
|
||||
lastRotatedAt: null,
|
||||
deletedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "secret-2",
|
||||
companyId: "company-1",
|
||||
key: "new_token",
|
||||
name: "new-token",
|
||||
provider: "local_encrypted",
|
||||
status: "active",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 1,
|
||||
description: null,
|
||||
lastResolvedAt: null,
|
||||
lastRotatedAt: null,
|
||||
deletedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
|
||||
},
|
||||
];
|
||||
mockRoutinesApi.listRevisions.mockResolvedValue([current, old]);
|
||||
await render({ secrets });
|
||||
|
||||
const oldRow = container.querySelector(
|
||||
"[data-testid='revision-row-1']",
|
||||
) as HTMLButtonElement | null;
|
||||
await act(async () => {
|
||||
oldRow?.click();
|
||||
});
|
||||
await flush();
|
||||
const compareButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Compare with current",
|
||||
);
|
||||
await act(async () => {
|
||||
compareButton?.click();
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("Env GH_TOKEN secret");
|
||||
expect(container.textContent).not.toContain("Env GH_TOKEN binding kind");
|
||||
});
|
||||
|
||||
it("invokes onRestored with the restore response so the editor can rehydrate (PAP-3588)", async () => {
|
||||
const current = createRevision({ id: "revision-2", revisionNumber: 2 });
|
||||
const old = createRevision({
|
||||
|
||||
@@ -1223,7 +1223,7 @@ function compareEnv(
|
||||
const newRef = asSecretRef(newBinding)!;
|
||||
if (oldRef.secretId !== newRef.secretId) {
|
||||
changes.push({
|
||||
field: `Env ${key} binding kind`,
|
||||
field: `Env ${key} secret`,
|
||||
oldValue: describeEnvBinding(oldBinding, secrets),
|
||||
newValue: describeEnvBinding(newBinding, secrets),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user