mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
fix: audit plugin bridge config and job attribution
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
aed4478c81
commit
9ec5052236
17 files changed
+748
-33
No files matched your search
@@ -0,0 +1,17 @@
|
||||
ALTER TABLE "plugin_job_runs"
|
||||
ADD COLUMN IF NOT EXISTS "triggered_by_actor_type" text;
|
||||
|
||||
ALTER TABLE "plugin_job_runs"
|
||||
ADD COLUMN IF NOT EXISTS "triggered_by_actor_id" text;
|
||||
|
||||
ALTER TABLE "plugin_job_runs"
|
||||
ADD COLUMN IF NOT EXISTS "triggered_by_agent_id" uuid REFERENCES "agents"("id");
|
||||
|
||||
ALTER TABLE "plugin_job_runs"
|
||||
ADD COLUMN IF NOT EXISTS "triggered_by_user_id" text;
|
||||
|
||||
ALTER TABLE "plugin_job_runs"
|
||||
ADD COLUMN IF NOT EXISTS "triggered_by_run_id" uuid REFERENCES "heartbeat_runs"("id");
|
||||
|
||||
ALTER TABLE "plugin_job_runs"
|
||||
ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
|
||||
@@ -1338,6 +1338,13 @@
|
||||
"when": 1784916886226,
|
||||
"tag": "0192_task_watchdog_stop_snapshots",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 193,
|
||||
"version": "7",
|
||||
"when": 1785164780000,
|
||||
"tag": "0193_plugin_job_run_attribution",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { agents } from "./agents.js";
|
||||
import { companies } from "./companies.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { plugins } from "./plugins.js";
|
||||
import type { PluginJobStatus, PluginJobRunStatus, PluginJobRunTrigger } from "@paperclipai/shared";
|
||||
|
||||
@@ -85,6 +87,18 @@ export const pluginJobRuns = pgTable(
|
||||
companyId: uuid("company_id").references(() => companies.id, { onDelete: "cascade" }),
|
||||
/** What caused this run to start (`"scheduled"` or `"manual"`). */
|
||||
trigger: text("trigger").$type<PluginJobRunTrigger>().notNull(),
|
||||
/** Host-derived actor type that triggered this run, when not scheduler-owned. */
|
||||
triggeredByActorType: text("triggered_by_actor_type"),
|
||||
/** Host-derived actor id that triggered this run, when not scheduler-owned. */
|
||||
triggeredByActorId: text("triggered_by_actor_id"),
|
||||
/** Host-derived agent id that triggered this run, when applicable. */
|
||||
triggeredByAgentId: uuid("triggered_by_agent_id").references(() => agents.id),
|
||||
/** Host-derived board/user id that triggered this run, when applicable. */
|
||||
triggeredByUserId: text("triggered_by_user_id"),
|
||||
/** Host-derived heartbeat run id that triggered this run, when applicable. */
|
||||
triggeredByRunId: uuid("triggered_by_run_id").references(() => heartbeatRuns.id),
|
||||
/** Responsible user resolved by the host for this run, when known. */
|
||||
responsibleUserId: text("responsible_user_id"),
|
||||
/** Current lifecycle state of this run. */
|
||||
status: text("status").$type<PluginJobRunStatus>().notNull().default("pending"),
|
||||
/** Wall-clock duration in milliseconds. Null until the run finishes. */
|
||||
|
||||
@@ -871,8 +871,22 @@ export interface PluginJobRunRecord {
|
||||
jobId: string;
|
||||
/** FK to `plugins.id`. */
|
||||
pluginId: string;
|
||||
/** Company scope for company-owned runs; null for instance-scope rows. */
|
||||
companyId: string | null;
|
||||
/** What triggered this run. */
|
||||
trigger: "schedule" | "manual" | "retry";
|
||||
/** Host-derived actor type that triggered the run, when available. */
|
||||
triggeredByActorType: "agent" | "user" | "system" | "plugin" | null;
|
||||
/** Host-derived actor id that triggered the run, when available. */
|
||||
triggeredByActorId: string | null;
|
||||
/** Host-derived agent id that triggered the run, when applicable. */
|
||||
triggeredByAgentId: string | null;
|
||||
/** Host-derived board/user id that triggered the run, when applicable. */
|
||||
triggeredByUserId: string | null;
|
||||
/** Host-derived heartbeat run id that triggered the run, when applicable. */
|
||||
triggeredByRunId: string | null;
|
||||
/** Responsible user resolved by the host for this run, when known. */
|
||||
responsibleUserId: string | null;
|
||||
/** Current run status. */
|
||||
status: "pending" | "queued" | "running" | "succeeded" | "failed" | "cancelled";
|
||||
/** Run duration in milliseconds. */
|
||||
|
||||
@@ -231,4 +231,65 @@ describeEmbeddedPostgres("logActivity responsible-user stamping", () => {
|
||||
|
||||
expect(row?.responsibleUserId).toBe("key-user");
|
||||
});
|
||||
|
||||
it("persists plugin audit run attribution from heartbeat responsible user", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const pluginId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
defaultResponsibleUserId: "default-user",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "running",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
responsibleUserId: "run-user",
|
||||
});
|
||||
|
||||
await logActivity(db, activityInput({
|
||||
companyId,
|
||||
actorType: "agent",
|
||||
actorId: agentId,
|
||||
agentId,
|
||||
runId,
|
||||
action: "plugin.bridge.action_performed",
|
||||
entityType: "plugin",
|
||||
entityId: pluginId,
|
||||
details: { pluginId, actionKey: "sync" },
|
||||
}));
|
||||
|
||||
const row = await db
|
||||
.select({
|
||||
agentId: activityLog.agentId,
|
||||
runId: activityLog.runId,
|
||||
responsibleUserId: activityLog.responsibleUserId,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.companyId, companyId))
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
expect(row).toMatchObject({
|
||||
agentId,
|
||||
runId,
|
||||
responsibleUserId: "run-user",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const mockRegistry = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getByKey: vi.fn(),
|
||||
getConfig: vi.fn(),
|
||||
upsertConfig: vi.fn(),
|
||||
getCompanySettings: vi.fn(),
|
||||
upsertCompanySettings: vi.fn(),
|
||||
@@ -23,6 +24,8 @@ const mockSecretService = vi.hoisted(() => ({
|
||||
syncSecretRefsForTarget: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/plugin-registry.js", () => ({
|
||||
pluginRegistryService: () => mockRegistry,
|
||||
}));
|
||||
@@ -31,9 +34,13 @@ vi.mock("../services/plugin-lifecycle.js", () => ({
|
||||
pluginLifecycleManager: () => mockLifecycle,
|
||||
}));
|
||||
|
||||
vi.mock("../services/activity-log.js", () => ({
|
||||
logActivity: vi.fn(),
|
||||
}));
|
||||
vi.mock("../services/activity-log.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/activity-log.js")>();
|
||||
return {
|
||||
...actual,
|
||||
logActivity: mockLogActivity,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../services/secrets.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
@@ -353,6 +360,67 @@ describe.sequential("plugin install and upgrade authz", () => {
|
||||
});
|
||||
}, 20_000);
|
||||
|
||||
it("audits plugin config reads and tests without raw config values", async () => {
|
||||
readyPlugin();
|
||||
mockRegistry.getConfig.mockResolvedValue({
|
||||
id: "config-1",
|
||||
pluginId,
|
||||
companyId: companyA,
|
||||
configJson: { apiToken: "stored-secret-value" },
|
||||
});
|
||||
const call = vi.fn().mockResolvedValue({ ok: true, warnings: ["reachable"] });
|
||||
const { app } = await createApp(boardActor({ runId: runA }), {}, {
|
||||
bridgeDeps: {
|
||||
workerManager: { call, isRunning: vi.fn(() => true) },
|
||||
},
|
||||
});
|
||||
|
||||
const readRes = await request(app)
|
||||
.get(`/api/plugins/${pluginId}/config?companyId=${companyA}`);
|
||||
const testRes = await request(app)
|
||||
.post(`/api/plugins/${pluginId}/config/test`)
|
||||
.send({ companyId: companyA, configJson: { apiToken: "submitted-secret-value" } });
|
||||
|
||||
expect(readRes.status).toBe(200);
|
||||
expect(testRes.status).toBe(200);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
companyId: companyA,
|
||||
actorType: "user",
|
||||
actorId: "user-1",
|
||||
runId: runA,
|
||||
action: "plugin.config.read",
|
||||
entityType: "plugin",
|
||||
entityId: pluginId,
|
||||
details: expect.objectContaining({
|
||||
pluginId,
|
||||
pluginKey: "paperclip.example",
|
||||
companyId: companyA,
|
||||
configPresent: true,
|
||||
configKeyCount: 1,
|
||||
}),
|
||||
}));
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
companyId: companyA,
|
||||
actorType: "user",
|
||||
actorId: "user-1",
|
||||
runId: runA,
|
||||
action: "plugin.config.tested",
|
||||
entityType: "plugin",
|
||||
entityId: pluginId,
|
||||
details: expect.objectContaining({
|
||||
pluginId,
|
||||
companyId: companyA,
|
||||
supported: true,
|
||||
valid: true,
|
||||
warningCount: 1,
|
||||
configKeyCount: 1,
|
||||
}),
|
||||
}));
|
||||
const auditCalls = JSON.stringify(mockLogActivity.mock.calls);
|
||||
expect(auditCalls).not.toContain("stored-secret-value");
|
||||
expect(auditCalls).not.toContain("submitted-secret-value");
|
||||
}, 20_000);
|
||||
|
||||
it("rejects plugin config saves that reference another company's secret before syncing bindings", async () => {
|
||||
readyPlugin();
|
||||
mockSecretService.getById.mockResolvedValue({ id: secretId, companyId: companyB, status: "active" });
|
||||
@@ -715,6 +783,25 @@ describe.sequential("plugin tool and bridge authz", () => {
|
||||
params: { view: "compact" },
|
||||
renderEnvironment: null,
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
companyId: companyA,
|
||||
actorType: "user",
|
||||
actorId: "user-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
action: "plugin.bridge.data_read",
|
||||
entityType: "plugin",
|
||||
entityId: pluginId,
|
||||
details: expect.objectContaining({
|
||||
pluginId,
|
||||
pluginKey: "paperclip.example",
|
||||
companyId: companyA,
|
||||
bridgeMethod: "getData",
|
||||
dataKey: "health",
|
||||
routeStyle: "url",
|
||||
outcome: "success",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("allows omitted-company bridge calls for instance admins as global plugin actions", async () => {
|
||||
@@ -873,6 +960,25 @@ describe.sequential("plugin tool and bridge authz", () => {
|
||||
},
|
||||
renderEnvironment: null,
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
companyId: companyA,
|
||||
actorType: "agent",
|
||||
actorId: agentA,
|
||||
agentId: agentA,
|
||||
runId: runA,
|
||||
action: "plugin.bridge.action_performed",
|
||||
entityType: "plugin",
|
||||
entityId: pluginId,
|
||||
details: expect.objectContaining({
|
||||
pluginId,
|
||||
pluginKey: "paperclip.example",
|
||||
companyId: companyA,
|
||||
bridgeMethod: "performAction",
|
||||
actionKey: "sync",
|
||||
routeStyle: "legacy",
|
||||
outcome: "success",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects agent plugin actions outside the authenticated company scope", async () => {
|
||||
@@ -926,6 +1032,41 @@ describe.sequential("plugin tool and bridge authz", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("audits company-scoped plugin log reads", async () => {
|
||||
readyPlugin();
|
||||
const limit = vi.fn(() => Promise.resolve([
|
||||
{ id: "log-1", pluginId, companyId: companyA, level: "info", message: "ok", meta: null },
|
||||
]));
|
||||
const orderBy = vi.fn(() => ({ limit }));
|
||||
const where = vi.fn(() => ({ orderBy }));
|
||||
const from = vi.fn(() => ({ where }));
|
||||
const db = { select: vi.fn(() => ({ from })) };
|
||||
const { app } = await createApp(boardActor({ companyIds: [companyA], runId: runA }), {}, { db });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/plugins/${pluginId}/logs?companyId=${companyA}&limit=10&level=info`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
companyId: companyA,
|
||||
actorType: "user",
|
||||
actorId: "user-1",
|
||||
runId: runA,
|
||||
action: "plugin.logs.read",
|
||||
entityType: "plugin",
|
||||
entityId: pluginId,
|
||||
details: expect.objectContaining({
|
||||
pluginId,
|
||||
pluginKey: "paperclip.example",
|
||||
companyId: companyA,
|
||||
limit: 10,
|
||||
level: "info",
|
||||
resultCount: 1,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects manual job triggers for non-admin board users", async () => {
|
||||
const scheduler = { triggerJob: vi.fn() };
|
||||
const jobStore = { getJobByIdForPlugin: vi.fn() };
|
||||
@@ -945,22 +1086,52 @@ describe.sequential("plugin tool and bridge authz", () => {
|
||||
it("allows manual job triggers for instance admins", async () => {
|
||||
readyPlugin();
|
||||
const scheduler = { triggerJob: vi.fn().mockResolvedValue({ runId: "run-1", jobId: "job-1" }) };
|
||||
const jobStore = { getJobByIdForPlugin: vi.fn().mockResolvedValue({ id: "job-1" }) };
|
||||
const jobStore = { getJobByIdForPlugin: vi.fn().mockResolvedValue({ id: "job-1", jobKey: "sync" }) };
|
||||
const { app } = await createApp(boardActor({
|
||||
userId: "admin-1",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [],
|
||||
companyIds: [companyA],
|
||||
runId: runA,
|
||||
}), {}, {
|
||||
jobDeps: { scheduler, jobStore },
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/plugins/${pluginId}/jobs/job-1/trigger`)
|
||||
.send({});
|
||||
.send({ companyId: companyA });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ runId: "run-1", jobId: "job-1" });
|
||||
expect(scheduler.triggerJob).toHaveBeenCalledWith("job-1", "manual");
|
||||
expect(scheduler.triggerJob).toHaveBeenCalledWith("job-1", "manual", {
|
||||
companyId: companyA,
|
||||
attribution: {
|
||||
triggeredByActorType: "user",
|
||||
triggeredByActorId: "admin-1",
|
||||
triggeredByAgentId: null,
|
||||
triggeredByUserId: "admin-1",
|
||||
triggeredByRunId: runA,
|
||||
responsibleUserId: "admin-1",
|
||||
},
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
companyId: companyA,
|
||||
actorType: "user",
|
||||
actorId: "admin-1",
|
||||
agentId: null,
|
||||
runId: runA,
|
||||
action: "plugin.job.triggered",
|
||||
entityType: "plugin_job",
|
||||
entityId: "job-1",
|
||||
details: expect.objectContaining({
|
||||
pluginId,
|
||||
pluginKey: "paperclip.example",
|
||||
companyId: companyA,
|
||||
jobId: "job-1",
|
||||
jobKey: "sync",
|
||||
trigger: "manual",
|
||||
pluginJobRunId: "run-1",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
// ─── Agent JWT tool execution (cherry-picked from #5549) ─────────────────────
|
||||
|
||||
@@ -18,6 +18,8 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
assertCheckoutOwner: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/plugin-registry.js", () => ({
|
||||
pluginRegistryService: () => mockRegistry,
|
||||
}));
|
||||
@@ -31,7 +33,7 @@ vi.mock("../services/issues.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../services/activity-log.js", () => ({
|
||||
logActivity: vi.fn(),
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
vi.mock("../services/live-events.js", () => ({
|
||||
@@ -156,6 +158,25 @@ describe.sequential("plugin scoped API routes", () => {
|
||||
actor: expect.objectContaining({ actorType: "user", actorId: "user-1" }),
|
||||
}));
|
||||
expect(workerManager.call.mock.calls[0]?.[2].headers.authorization).toBeUndefined();
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: "user-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
action: "plugin.api_route.proxied",
|
||||
entityType: "plugin",
|
||||
entityId: pluginId,
|
||||
details: expect.objectContaining({
|
||||
pluginId,
|
||||
pluginKey: apiRoutes.id,
|
||||
companyId,
|
||||
routeKey: "summary.get",
|
||||
method: "GET",
|
||||
status: 201,
|
||||
outcome: "success",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("only forwards allowlisted response headers from plugin routes", async () => {
|
||||
@@ -251,6 +272,25 @@ describe.sequential("plugin scoped API routes", () => {
|
||||
actor: expect.objectContaining({ actorType: "agent", agentId, runId }),
|
||||
companyId,
|
||||
}));
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
companyId,
|
||||
actorType: "agent",
|
||||
actorId: agentId,
|
||||
agentId,
|
||||
runId,
|
||||
action: "plugin.api_route.proxied",
|
||||
entityType: "plugin",
|
||||
entityId: pluginId,
|
||||
details: expect.objectContaining({
|
||||
pluginId,
|
||||
pluginKey: apiRoutes.id,
|
||||
companyId,
|
||||
routeKey: "issue.advance",
|
||||
method: "POST",
|
||||
status: 200,
|
||||
outcome: "success",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("allows non-assignee agents on in-progress required checkout routes without claiming checkout ownership", async () => {
|
||||
|
||||
@@ -2,8 +2,10 @@ import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
pluginEntities,
|
||||
pluginJobs,
|
||||
pluginJobRuns,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
plugins,
|
||||
} from "@paperclipai/db";
|
||||
import { buildHostServices, flushPluginLogBuffer } from "../services/plugin-host-services.js";
|
||||
import { pluginJobStore } from "../services/plugin-job-store.js";
|
||||
import { pluginRegistryService } from "../services/plugin-registry.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
@@ -59,6 +62,8 @@ describeEmbeddedPostgres("plugin tenant isolation (company_id FK)", () => {
|
||||
await db.delete(pluginLogs);
|
||||
await db.delete(pluginWebhookDeliveries);
|
||||
await db.delete(plugins);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
@@ -188,6 +193,68 @@ describeEmbeddedPostgres("plugin tenant isolation (company_id FK)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("persists host-derived attribution on plugin_job_runs", async () => {
|
||||
const pluginId = await seedPlugin();
|
||||
const companyId = await seedCompany();
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const jobId = randomUUID();
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Plugin Runner",
|
||||
role: "engineer",
|
||||
status: "running",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
responsibleUserId: "run-user",
|
||||
});
|
||||
await db.insert(pluginJobs).values({
|
||||
id: jobId,
|
||||
pluginId,
|
||||
jobKey: "manual-sync",
|
||||
schedule: "* * * * *",
|
||||
});
|
||||
|
||||
const run = await pluginJobStore(db).createRun({
|
||||
jobId,
|
||||
pluginId,
|
||||
companyId,
|
||||
trigger: "manual",
|
||||
triggeredByActorType: "agent",
|
||||
triggeredByActorId: agentId,
|
||||
triggeredByAgentId: agentId,
|
||||
triggeredByRunId: runId,
|
||||
responsibleUserId: "run-user",
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(pluginJobRuns)
|
||||
.where(eq(pluginJobRuns.id, run.id))
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
expect(row).toMatchObject({
|
||||
companyId,
|
||||
trigger: "manual",
|
||||
triggeredByActorType: "agent",
|
||||
triggeredByActorId: agentId,
|
||||
triggeredByAgentId: agentId,
|
||||
triggeredByUserId: null,
|
||||
triggeredByRunId: runId,
|
||||
responsibleUserId: "run-user",
|
||||
});
|
||||
});
|
||||
|
||||
it("plugin_entities unique index is scoped per company — two tenants can share (pluginId, entityType, externalId)", async () => {
|
||||
const pluginId = await seedPlugin();
|
||||
const companyA = await seedCompany();
|
||||
|
||||
@@ -4892,9 +4892,17 @@ registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/plugins/{pluginId}/logs",
|
||||
tags: ["plugins"],
|
||||
summary: "Get plugin logs",
|
||||
request: { params: z.object({ pluginId: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
summary: "Get company-scoped plugin logs",
|
||||
request: {
|
||||
params: z.object({ pluginId: z.string() }),
|
||||
query: z.object({
|
||||
companyId: z.string(),
|
||||
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||
level: z.string().optional(),
|
||||
since: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
@@ -4965,8 +4973,11 @@ registry.registerPath({
|
||||
path: "/api/plugins/{pluginId}/jobs/{jobId}/trigger",
|
||||
tags: ["plugins"],
|
||||
summary: "Trigger a plugin job",
|
||||
request: { params: z.object({ pluginId: z.string(), jobId: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
request: {
|
||||
params: z.object({ pluginId: z.string(), jobId: z.string() }),
|
||||
body: jsonBody(z.object({ companyId: z.string().optional() }).optional()),
|
||||
},
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
|
||||
@@ -53,11 +53,11 @@ import {
|
||||
pluginLoader,
|
||||
REPO_ROOT,
|
||||
} from "../services/plugin-loader.js";
|
||||
import { logActivity } from "../services/activity-log.js";
|
||||
import { logActivity, resolveResponsibleUserIdForActivity } from "../services/activity-log.js";
|
||||
import { publishGlobalLiveEvent } from "../services/live-events.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import type { PluginJobScheduler } from "../services/plugin-job-scheduler.js";
|
||||
import type { PluginJobStore } from "../services/plugin-job-store.js";
|
||||
import type { JobRunAttributionInput, PluginJobStore } from "../services/plugin-job-store.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
import type { PluginStreamBus } from "../services/plugin-stream-bus.js";
|
||||
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
|
||||
@@ -691,7 +691,19 @@ export function pluginRoutes(
|
||||
entityId: string,
|
||||
details: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const companyIds = await resolvePluginAuditCompanyIds(req);
|
||||
await logPluginAuditActivity(req, action, entityId, details);
|
||||
}
|
||||
|
||||
async function logPluginAuditActivity(
|
||||
req: Request,
|
||||
action: string,
|
||||
entityId: string,
|
||||
details: Record<string, unknown>,
|
||||
options: { companyId?: string | null; entityType?: string } = {},
|
||||
): Promise<void> {
|
||||
const companyIds = options.companyId
|
||||
? [options.companyId]
|
||||
: await resolvePluginAuditCompanyIds(req);
|
||||
if (companyIds.length === 0) return;
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
@@ -704,12 +716,71 @@ export function pluginRoutes(
|
||||
runId: actor.runId,
|
||||
agentApiKeyId: actor.agentApiKeyId,
|
||||
action,
|
||||
entityType: "plugin",
|
||||
entityType: options.entityType ?? "plugin",
|
||||
entityId,
|
||||
details,
|
||||
})));
|
||||
}
|
||||
|
||||
async function logResolvedPluginActivity(
|
||||
req: Request,
|
||||
action: string,
|
||||
plugin: { id: string; pluginKey: string },
|
||||
details: Record<string, unknown>,
|
||||
options: { companyId?: string | null; entityType?: string; entityId?: string } = {},
|
||||
): Promise<void> {
|
||||
await logPluginAuditActivity(req, action, options.entityId ?? plugin.id, {
|
||||
pluginId: plugin.id,
|
||||
pluginKey: plugin.pluginKey,
|
||||
...details,
|
||||
}, {
|
||||
companyId: options.companyId,
|
||||
entityType: options.entityType,
|
||||
});
|
||||
}
|
||||
|
||||
async function buildPluginJobRunAttribution(
|
||||
req: Request,
|
||||
companyId: string | null,
|
||||
jobId: string,
|
||||
): Promise<JobRunAttributionInput> {
|
||||
const actor = getActorInfo(req);
|
||||
const responsibleUserId = companyId
|
||||
? await resolveResponsibleUserIdForActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
agentApiKeyId: actor.agentApiKeyId,
|
||||
action: "plugin.job.triggered",
|
||||
entityType: "plugin_job",
|
||||
entityId: jobId,
|
||||
})
|
||||
: actor.actorType === "user"
|
||||
? actor.actorId
|
||||
: null;
|
||||
|
||||
return {
|
||||
triggeredByActorType: actor.actorType,
|
||||
triggeredByActorId: actor.actorId,
|
||||
triggeredByAgentId: actor.agentId,
|
||||
triggeredByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
triggeredByRunId: actor.runId,
|
||||
responsibleUserId,
|
||||
};
|
||||
}
|
||||
|
||||
function readOptionalCompanyId(req: Request, value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw badRequest('"companyId" must be a non-empty string when provided');
|
||||
}
|
||||
const companyId = value.trim();
|
||||
assertCompanyAccess(req, companyId);
|
||||
return companyId;
|
||||
}
|
||||
|
||||
function assertPluginBridgeScope(req: Request, companyId: unknown): string | undefined {
|
||||
if (companyId === undefined || companyId === null) {
|
||||
assertInstanceAdmin(req);
|
||||
@@ -1351,6 +1422,36 @@ export function pluginRoutes(
|
||||
(res as any).err = rootError;
|
||||
}
|
||||
|
||||
async function logPluginBridgeDataRead(
|
||||
req: Request,
|
||||
plugin: { id: string; pluginKey: string },
|
||||
input: { companyId?: string; dataKey: string; routeStyle: "legacy" | "url"; outcome: "success" | "error"; bridgeCode?: string },
|
||||
): Promise<void> {
|
||||
await logResolvedPluginActivity(req, "plugin.bridge.data_read", plugin, {
|
||||
companyId: input.companyId ?? null,
|
||||
bridgeMethod: "getData",
|
||||
dataKey: input.dataKey,
|
||||
routeStyle: input.routeStyle,
|
||||
outcome: input.outcome,
|
||||
...(input.bridgeCode ? { bridgeCode: input.bridgeCode } : {}),
|
||||
}, { companyId: input.companyId ?? null });
|
||||
}
|
||||
|
||||
async function logPluginBridgeAction(
|
||||
req: Request,
|
||||
plugin: { id: string; pluginKey: string },
|
||||
input: { companyId?: string; actionKey: string; routeStyle: "legacy" | "url"; outcome: "success" | "error"; bridgeCode?: string },
|
||||
): Promise<void> {
|
||||
await logResolvedPluginActivity(req, "plugin.bridge.action_performed", plugin, {
|
||||
companyId: input.companyId ?? null,
|
||||
bridgeMethod: "performAction",
|
||||
actionKey: input.actionKey,
|
||||
routeStyle: input.routeStyle,
|
||||
outcome: input.outcome,
|
||||
...(input.bridgeCode ? { bridgeCode: input.bridgeCode } : {}),
|
||||
}, { companyId: input.companyId ?? null });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plugins/:pluginId/bridge/data
|
||||
*
|
||||
@@ -1431,6 +1532,12 @@ export function pluginRoutes(
|
||||
renderEnvironment: body.renderEnvironment ?? null,
|
||||
},
|
||||
);
|
||||
await logPluginBridgeDataRead(req, plugin, {
|
||||
companyId,
|
||||
dataKey: body.key,
|
||||
routeStyle: "legacy",
|
||||
outcome: "success",
|
||||
});
|
||||
res.json({ data: result });
|
||||
} catch (err) {
|
||||
const bridgeError = mapRpcErrorToBridgeError(err);
|
||||
@@ -1440,6 +1547,13 @@ export function pluginRoutes(
|
||||
bridgeMethod: "getData",
|
||||
dataKey: body.key,
|
||||
});
|
||||
await logPluginBridgeDataRead(req, plugin, {
|
||||
companyId,
|
||||
dataKey: body.key,
|
||||
routeStyle: "legacy",
|
||||
outcome: "error",
|
||||
bridgeCode: bridgeError.code,
|
||||
});
|
||||
res.status(502).json(bridgeError);
|
||||
}
|
||||
});
|
||||
@@ -1524,6 +1638,12 @@ export function pluginRoutes(
|
||||
renderEnvironment: body.renderEnvironment ?? null,
|
||||
},
|
||||
);
|
||||
await logPluginBridgeAction(req, plugin, {
|
||||
companyId,
|
||||
actionKey: body.key,
|
||||
routeStyle: "legacy",
|
||||
outcome: "success",
|
||||
});
|
||||
res.json({ data: result });
|
||||
} catch (err) {
|
||||
const bridgeError = mapRpcErrorToBridgeError(err);
|
||||
@@ -1533,6 +1653,13 @@ export function pluginRoutes(
|
||||
bridgeMethod: "performAction",
|
||||
actionKey: body.key,
|
||||
});
|
||||
await logPluginBridgeAction(req, plugin, {
|
||||
companyId,
|
||||
actionKey: body.key,
|
||||
routeStyle: "legacy",
|
||||
outcome: "error",
|
||||
bridgeCode: bridgeError.code,
|
||||
});
|
||||
res.status(502).json(bridgeError);
|
||||
}
|
||||
});
|
||||
@@ -1618,6 +1745,12 @@ export function pluginRoutes(
|
||||
renderEnvironment: body?.renderEnvironment ?? null,
|
||||
},
|
||||
);
|
||||
await logPluginBridgeDataRead(req, plugin, {
|
||||
companyId,
|
||||
dataKey: key,
|
||||
routeStyle: "url",
|
||||
outcome: "success",
|
||||
});
|
||||
res.json({ data: result });
|
||||
} catch (err) {
|
||||
const bridgeError = mapRpcErrorToBridgeError(err);
|
||||
@@ -1627,6 +1760,13 @@ export function pluginRoutes(
|
||||
bridgeMethod: "getData",
|
||||
dataKey: key,
|
||||
});
|
||||
await logPluginBridgeDataRead(req, plugin, {
|
||||
companyId,
|
||||
dataKey: key,
|
||||
routeStyle: "url",
|
||||
outcome: "error",
|
||||
bridgeCode: bridgeError.code,
|
||||
});
|
||||
res.status(502).json(bridgeError);
|
||||
}
|
||||
});
|
||||
@@ -1708,6 +1848,12 @@ export function pluginRoutes(
|
||||
renderEnvironment: body?.renderEnvironment ?? null,
|
||||
},
|
||||
);
|
||||
await logPluginBridgeAction(req, plugin, {
|
||||
companyId,
|
||||
actionKey: key,
|
||||
routeStyle: "url",
|
||||
outcome: "success",
|
||||
});
|
||||
res.json({ data: result });
|
||||
} catch (err) {
|
||||
const bridgeError = mapRpcErrorToBridgeError(err);
|
||||
@@ -1717,6 +1863,13 @@ export function pluginRoutes(
|
||||
bridgeMethod: "performAction",
|
||||
actionKey: key,
|
||||
});
|
||||
await logPluginBridgeAction(req, plugin, {
|
||||
companyId,
|
||||
actionKey: key,
|
||||
routeStyle: "url",
|
||||
outcome: "error",
|
||||
bridgeCode: bridgeError.code,
|
||||
});
|
||||
res.status(502).json(bridgeError);
|
||||
}
|
||||
});
|
||||
@@ -1852,6 +2005,8 @@ export function pluginRoutes(
|
||||
return;
|
||||
}
|
||||
|
||||
let scopedApiAudit: { companyId: string; routeKey: string; routePath: string; method: string } | null = null;
|
||||
|
||||
try {
|
||||
assertScopedApiAuth(req, match.route);
|
||||
const companyId = await resolveScopedApiCompanyId(match.route, match.params, req);
|
||||
@@ -1860,6 +2015,12 @@ export function pluginRoutes(
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, companyId);
|
||||
scopedApiAudit = {
|
||||
companyId,
|
||||
routeKey: match.route.routeKey,
|
||||
routePath: match.route.path,
|
||||
method: req.method,
|
||||
};
|
||||
await enforceScopedApiCheckout(req, match.route, match.params, companyId);
|
||||
if (req.method !== "GET" && req.headers["content-type"] && !req.is("application/json")) {
|
||||
res.status(415).json({ error: "Plugin API routes accept JSON requests only" });
|
||||
@@ -1899,6 +2060,16 @@ export function pluginRoutes(
|
||||
const status = Number.isInteger(result.status) && Number(result.status) >= 200 && Number(result.status) <= 599
|
||||
? Number(result.status)
|
||||
: 200;
|
||||
if (scopedApiAudit) {
|
||||
await logResolvedPluginActivity(req, "plugin.api_route.proxied", plugin, {
|
||||
companyId: scopedApiAudit.companyId,
|
||||
routeKey: scopedApiAudit.routeKey,
|
||||
routePath: scopedApiAudit.routePath,
|
||||
method: scopedApiAudit.method,
|
||||
status,
|
||||
outcome: status >= 400 ? "error" : "success",
|
||||
}, { companyId: scopedApiAudit.companyId });
|
||||
}
|
||||
applyPluginScopedApiResponseHeaders(res, result.headers);
|
||||
if (status === 204) {
|
||||
res.status(status).end();
|
||||
@@ -1918,6 +2089,16 @@ export function pluginRoutes(
|
||||
: err instanceof JsonRpcCallError
|
||||
? 502
|
||||
: 500;
|
||||
if (scopedApiAudit) {
|
||||
await logResolvedPluginActivity(req, "plugin.api_route.proxied", plugin, {
|
||||
companyId: scopedApiAudit.companyId,
|
||||
routeKey: scopedApiAudit.routeKey,
|
||||
routePath: scopedApiAudit.routePath,
|
||||
method: scopedApiAudit.method,
|
||||
status,
|
||||
outcome: "error",
|
||||
}, { companyId: scopedApiAudit.companyId });
|
||||
}
|
||||
res.status(status).json({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -2162,15 +2343,18 @@ export function pluginRoutes(
|
||||
const limit = Math.min(Math.max(parseInt(req.query.limit as string, 10) || 25, 1), 500);
|
||||
const level = req.query.level as string | undefined;
|
||||
const since = req.query.since as string | undefined;
|
||||
const companyId = requirePluginConfigCompanyId(req, req.query.companyId);
|
||||
|
||||
const conditions = [eq(pluginLogs.pluginId, plugin.id)];
|
||||
const conditions = [eq(pluginLogs.pluginId, plugin.id), eq(pluginLogs.companyId, companyId)];
|
||||
if (level) {
|
||||
conditions.push(eq(pluginLogs.level, level));
|
||||
}
|
||||
let sinceIso: string | null = null;
|
||||
if (since) {
|
||||
const sinceDate = new Date(since);
|
||||
if (!isNaN(sinceDate.getTime())) {
|
||||
conditions.push(gte(pluginLogs.createdAt, sinceDate));
|
||||
sinceIso = sinceDate.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2181,6 +2365,13 @@ export function pluginRoutes(
|
||||
.orderBy(desc(pluginLogs.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
await logResolvedPluginActivity(req, "plugin.logs.read", plugin, {
|
||||
companyId,
|
||||
limit,
|
||||
level: level ?? null,
|
||||
since: sinceIso,
|
||||
resultCount: rows.length,
|
||||
}, { companyId });
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
@@ -2263,6 +2454,13 @@ export function pluginRoutes(
|
||||
}
|
||||
|
||||
const config = await registry.getConfig(plugin.id, companyId);
|
||||
await logResolvedPluginActivity(req, "plugin.config.read", plugin, {
|
||||
companyId,
|
||||
configPresent: Boolean(config),
|
||||
configKeyCount: config?.configJson && typeof config.configJson === "object"
|
||||
? Object.keys(config.configJson).length
|
||||
: 0,
|
||||
}, { companyId });
|
||||
res.json(config);
|
||||
});
|
||||
|
||||
@@ -2458,8 +2656,9 @@ export function pluginRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const secretRefs = extractSecretRefBindingsFromConfig(body.configJson, schema);
|
||||
|
||||
try {
|
||||
const secretRefs = extractSecretRefBindingsFromConfig(body.configJson, schema);
|
||||
await validatePluginSecretRefsForCompany(companyId, secretRefs);
|
||||
|
||||
const result = await bridgeDeps.workerManager.call(
|
||||
@@ -2474,11 +2673,29 @@ export function pluginRoutes(
|
||||
const warningText = result.warnings?.length
|
||||
? `Warnings: ${result.warnings.join("; ")}`
|
||||
: undefined;
|
||||
await logResolvedPluginActivity(req, "plugin.config.tested", plugin, {
|
||||
companyId,
|
||||
supported: true,
|
||||
valid: true,
|
||||
warningCount: result.warnings?.length ?? 0,
|
||||
errorCount: 0,
|
||||
secretRefCount: secretRefs.length,
|
||||
configKeyCount: Object.keys(body.configJson).length,
|
||||
}, { companyId });
|
||||
res.json({ valid: true, message: warningText });
|
||||
} else {
|
||||
const errorText = result.errors?.length
|
||||
? result.errors.join("; ")
|
||||
: "Configuration validation failed.";
|
||||
await logResolvedPluginActivity(req, "plugin.config.tested", plugin, {
|
||||
companyId,
|
||||
supported: true,
|
||||
valid: false,
|
||||
warningCount: result.warnings?.length ?? 0,
|
||||
errorCount: result.errors?.length ?? 0,
|
||||
secretRefCount: secretRefs.length,
|
||||
configKeyCount: Object.keys(body.configJson).length,
|
||||
}, { companyId });
|
||||
res.json({ valid: false, message: errorText });
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -2487,6 +2704,16 @@ export function pluginRoutes(
|
||||
err instanceof JsonRpcCallError &&
|
||||
err.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED
|
||||
) {
|
||||
await logResolvedPluginActivity(req, "plugin.config.tested", plugin, {
|
||||
companyId,
|
||||
supported: false,
|
||||
valid: false,
|
||||
warningCount: 0,
|
||||
errorCount: 0,
|
||||
secretRefCount: secretRefs.length,
|
||||
configKeyCount: Object.keys(body.configJson).length,
|
||||
outcome: "unsupported",
|
||||
}, { companyId });
|
||||
res.json({
|
||||
valid: false,
|
||||
supported: false,
|
||||
@@ -2497,6 +2724,15 @@ export function pluginRoutes(
|
||||
|
||||
// Worker unavailable or other RPC errors
|
||||
const bridgeError = mapRpcErrorToBridgeError(err);
|
||||
await logResolvedPluginActivity(req, "plugin.config.tested", plugin, {
|
||||
companyId,
|
||||
supported: true,
|
||||
valid: false,
|
||||
secretRefCount: secretRefs.length,
|
||||
configKeyCount: Object.keys(body.configJson).length,
|
||||
outcome: "bridge_error",
|
||||
bridgeCode: bridgeError.code,
|
||||
}, { companyId });
|
||||
res.status(502).json(bridgeError);
|
||||
}
|
||||
});
|
||||
@@ -2618,6 +2854,8 @@ export function pluginRoutes(
|
||||
}
|
||||
|
||||
const { pluginId, jobId } = req.params;
|
||||
const body = req.body as { companyId?: unknown } | undefined;
|
||||
const companyId = readOptionalCompanyId(req, body?.companyId);
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
if (!plugin) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
@@ -2631,7 +2869,18 @@ export function pluginRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await jobDeps.scheduler.triggerJob(jobId, "manual");
|
||||
const attribution = await buildPluginJobRunAttribution(req, companyId ?? null, job.id);
|
||||
const result = await jobDeps.scheduler.triggerJob(jobId, "manual", {
|
||||
companyId: companyId ?? null,
|
||||
attribution,
|
||||
});
|
||||
await logResolvedPluginActivity(req, "plugin.job.triggered", plugin, {
|
||||
companyId: companyId ?? null,
|
||||
jobId: job.id,
|
||||
jobKey: typeof job.jobKey === "string" ? job.jobKey : null,
|
||||
trigger: "manual",
|
||||
pluginJobRunId: result.runId,
|
||||
}, { companyId: companyId ?? null, entityType: "plugin_job", entityId: job.id });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
import { and, eq, lte, or } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { pluginJobs, pluginJobRuns } from "@paperclipai/db";
|
||||
import type { PluginJobStore } from "./plugin-job-store.js";
|
||||
import type { JobRunAttributionInput, PluginJobStore } from "./plugin-job-store.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import { parseCron, nextCronTick, validateCron } from "./cron.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
@@ -87,6 +87,13 @@ export interface TriggerJobResult {
|
||||
jobId: string;
|
||||
}
|
||||
|
||||
export interface TriggerJobOptions {
|
||||
/** Host-authorized company scope for manual runs; null/omitted means instance scope. */
|
||||
companyId?: string | null;
|
||||
/** Host-derived attribution for manual/retry runs. */
|
||||
attribution?: JobRunAttributionInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic information about the scheduler.
|
||||
*/
|
||||
@@ -157,7 +164,7 @@ export interface PluginJobScheduler {
|
||||
* @returns The created run info
|
||||
* @throws {Error} if the job is not found, not active, or already running
|
||||
*/
|
||||
triggerJob(jobId: string, trigger?: "manual" | "retry"): Promise<TriggerJobResult>;
|
||||
triggerJob(jobId: string, trigger?: "manual" | "retry", options?: TriggerJobOptions): Promise<TriggerJobResult>;
|
||||
|
||||
/**
|
||||
* Run a single scheduler tick immediately (for testing).
|
||||
@@ -439,6 +446,7 @@ export function createPluginJobScheduler(
|
||||
async function triggerJob(
|
||||
jobId: string,
|
||||
trigger: "manual" | "retry" = "manual",
|
||||
triggerOptions: TriggerJobOptions = {},
|
||||
): Promise<TriggerJobResult> {
|
||||
const job = await jobStore.getJobById(jobId);
|
||||
if (!job) {
|
||||
@@ -486,7 +494,9 @@ export function createPluginJobScheduler(
|
||||
const run = await jobStore.createRun({
|
||||
jobId,
|
||||
pluginId: job.pluginId,
|
||||
companyId: triggerOptions.companyId ?? null,
|
||||
trigger,
|
||||
...triggerOptions.attribution,
|
||||
});
|
||||
|
||||
// Dispatch in background — don't block the caller
|
||||
|
||||
@@ -52,14 +52,31 @@ type JobDefinitionStatus = PluginJobRecord["status"];
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface JobRunAttributionInput {
|
||||
/** Host-derived actor type that triggered this run. */
|
||||
triggeredByActorType?: "agent" | "user" | "system" | "plugin" | null;
|
||||
/** Host-derived actor id that triggered this run. */
|
||||
triggeredByActorId?: string | null;
|
||||
/** Host-derived agent id that triggered this run, when applicable. */
|
||||
triggeredByAgentId?: string | null;
|
||||
/** Host-derived board/user id that triggered this run, when applicable. */
|
||||
triggeredByUserId?: string | null;
|
||||
/** Host-derived heartbeat run id that triggered this run, when applicable. */
|
||||
triggeredByRunId?: string | null;
|
||||
/** Responsible user resolved by the host for this run, when known. */
|
||||
responsibleUserId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for creating a job run record.
|
||||
*/
|
||||
export interface CreateJobRunInput {
|
||||
export interface CreateJobRunInput extends JobRunAttributionInput {
|
||||
/** FK to the plugin_jobs row. */
|
||||
jobId: string;
|
||||
/** FK to the plugins row. */
|
||||
pluginId: string;
|
||||
/** Company scope for company-owned manual runs; null/omitted means instance scope. */
|
||||
companyId?: string | null;
|
||||
/** What triggered this run. */
|
||||
trigger: PluginJobRunTrigger;
|
||||
}
|
||||
@@ -356,7 +373,14 @@ export function pluginJobStore(db: Db) {
|
||||
.values({
|
||||
jobId: input.jobId,
|
||||
pluginId: input.pluginId,
|
||||
companyId: input.companyId ?? null,
|
||||
trigger: input.trigger,
|
||||
triggeredByActorType: input.triggeredByActorType ?? null,
|
||||
triggeredByActorId: input.triggeredByActorId ?? null,
|
||||
triggeredByAgentId: input.triggeredByAgentId ?? null,
|
||||
triggeredByUserId: input.triggeredByUserId ?? null,
|
||||
triggeredByRunId: input.triggeredByRunId ?? null,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
status: "queued",
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -698,6 +698,14 @@ export function pluginRegistryService(db: Db) {
|
||||
jobId: string,
|
||||
trigger: PluginJobRunTrigger,
|
||||
companyId: string | null,
|
||||
attribution?: {
|
||||
triggeredByActorType?: "agent" | "user" | "system" | "plugin" | null;
|
||||
triggeredByActorId?: string | null;
|
||||
triggeredByAgentId?: string | null;
|
||||
triggeredByUserId?: string | null;
|
||||
triggeredByRunId?: string | null;
|
||||
responsibleUserId?: string | null;
|
||||
},
|
||||
) => {
|
||||
return db
|
||||
.insert(pluginJobRuns)
|
||||
@@ -706,6 +714,12 @@ export function pluginRegistryService(db: Db) {
|
||||
jobId,
|
||||
companyId,
|
||||
trigger,
|
||||
triggeredByActorType: attribution?.triggeredByActorType ?? null,
|
||||
triggeredByActorId: attribution?.triggeredByActorId ?? null,
|
||||
triggeredByAgentId: attribution?.triggeredByAgentId ?? null,
|
||||
triggeredByUserId: attribution?.triggeredByUserId ?? null,
|
||||
triggeredByRunId: attribution?.triggeredByRunId ?? null,
|
||||
responsibleUserId: attribution?.responsibleUserId ?? null,
|
||||
status: "pending",
|
||||
})
|
||||
.returning()
|
||||
|
||||
@@ -301,13 +301,13 @@ export const pluginsApi = {
|
||||
* Fetch recent log entries for a plugin.
|
||||
*
|
||||
* @param pluginId - UUID of the plugin.
|
||||
* @param options - Optional filters: limit, level, since.
|
||||
* @param options - Company scope plus optional filters: limit, level, since.
|
||||
*/
|
||||
logs: (pluginId: string, options?: { limit?: number; level?: string; since?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
if (options?.level) params.set("level", options.level);
|
||||
if (options?.since) params.set("since", options.since);
|
||||
logs: (pluginId: string, options: { companyId: string; limit?: number; level?: string; since?: string }) => {
|
||||
const params = new URLSearchParams({ companyId: options.companyId });
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
if (options.level) params.set("level", options.level);
|
||||
if (options.since) params.set("since", options.since);
|
||||
const qs = params.toString();
|
||||
return api.get<Array<{ id: string; pluginId: string; level: string; message: string; meta: Record<string, unknown> | null; createdAt: string }>>(
|
||||
`/plugins/${pluginId}/logs${qs ? `?${qs}` : ""}`,
|
||||
|
||||
@@ -82,6 +82,13 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
||||
"company.archived": "archived",
|
||||
"company.reactivated": "reactivated",
|
||||
"company.budget_updated": "updated budget for",
|
||||
"plugin.config.read": "read configuration for",
|
||||
"plugin.config.tested": "tested configuration for",
|
||||
"plugin.bridge.data_read": "read bridge data from",
|
||||
"plugin.bridge.action_performed": "performed bridge action on",
|
||||
"plugin.api_route.proxied": "proxied API route for",
|
||||
"plugin.job.triggered": "triggered job for",
|
||||
"plugin.logs.read": "read logs for",
|
||||
};
|
||||
|
||||
const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
|
||||
@@ -129,6 +136,13 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
|
||||
"approval.created": "requested approval",
|
||||
"approval.approved": "approved",
|
||||
"approval.rejected": "rejected",
|
||||
"plugin.config.read": "read plugin configuration",
|
||||
"plugin.config.tested": "tested plugin configuration",
|
||||
"plugin.bridge.data_read": "read plugin bridge data",
|
||||
"plugin.bridge.action_performed": "performed plugin bridge action",
|
||||
"plugin.api_route.proxied": "proxied a plugin API route",
|
||||
"plugin.job.triggered": "triggered a plugin job",
|
||||
"plugin.logs.read": "read plugin logs",
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
|
||||
@@ -404,7 +404,7 @@ export const queryKeys = {
|
||||
localFolders: (pluginId: string, companyId: string) =>
|
||||
["plugins", pluginId, "companies", companyId, "local-folders"] as const,
|
||||
dashboard: (pluginId: string) => ["plugins", pluginId, "dashboard"] as const,
|
||||
logs: (pluginId: string) => ["plugins", pluginId, "logs"] as const,
|
||||
logs: (pluginId: string, companyId: string) => ["plugins", pluginId, "companies", companyId, "logs"] as const,
|
||||
},
|
||||
adapters: {
|
||||
all: ["adapters"] as const,
|
||||
|
||||
@@ -86,9 +86,11 @@ export function PluginSettings() {
|
||||
});
|
||||
|
||||
const { data: recentLogs } = useQuery({
|
||||
queryKey: queryKeys.plugins.logs(pluginId!),
|
||||
queryFn: () => pluginsApi.logs(pluginId!, { limit: 50 }),
|
||||
enabled: !!pluginId && plugin?.status === "ready",
|
||||
queryKey: pluginId && selectedCompanyId
|
||||
? queryKeys.plugins.logs(pluginId, selectedCompanyId)
|
||||
: ["plugins", pluginId ?? "__missing_plugin__", "companies", "__missing_company__", "logs"] as const,
|
||||
queryFn: () => pluginsApi.logs(pluginId!, { companyId: selectedCompanyId!, limit: 50 }),
|
||||
enabled: !!pluginId && !!selectedCompanyId && plugin?.status === "ready",
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user