import { randomUUID } from "node:crypto"; function normalizeScope(input) { return { scopeKind: input.scopeKind, scopeId: input.scopeId, namespace: input.namespace ?? "default", stateKey: input.stateKey, }; } function stateMapKey(input) { const normalized = normalizeScope(input); return `${normalized.scopeKind}|${normalized.scopeId ?? ""}|${normalized.namespace}|${normalized.stateKey}`; } function allowsEvent(filter, event) { if (!filter) return true; if (filter.companyId && filter.companyId !== String(event.payload?.companyId ?? "")) return false; if (filter.projectId && filter.projectId !== String(event.payload?.projectId ?? "")) return false; if (filter.agentId && filter.agentId !== String(event.payload?.agentId ?? "")) return false; return true; } function requireCapability(manifest, allowed, capability) { if (allowed.has(capability)) return; throw new Error(`Plugin '${manifest.id}' is missing required capability '${capability}' in test harness`); } function requireCompanyId(companyId) { if (!companyId) throw new Error("companyId is required for this operation"); return companyId; } function isInCompany(record, companyId) { return Boolean(record && record.companyId === companyId); } /** * Create an in-memory host harness for plugin worker tests. * * The harness enforces declared capabilities and simulates host APIs, so tests * can validate plugin behavior without spinning up the Paperclip server runtime. */ export function createTestHarness(options) { const manifest = options.manifest; const capabilitySet = new Set(options.capabilities ?? manifest.capabilities); let currentConfig = { ...(options.config ?? {}) }; const logs = []; const activity = []; const metrics = []; const state = new Map(); const entities = new Map(); const entityExternalIndex = new Map(); const companies = new Map(); const projects = new Map(); const issues = new Map(); const issueComments = new Map(); const agents = new Map(); const goals = new Map(); const projectWorkspaces = new Map(); const sessions = new Map(); const sessionEventCallbacks = new Map(); const events = []; const jobs = new Map(); const launchers = new Map(); const dataHandlers = new Map(); const actionHandlers = new Map(); const toolHandlers = new Map(); const ctx = { manifest, config: { async get() { return { ...currentConfig }; }, }, events: { on(name, filterOrFn, maybeFn) { requireCapability(manifest, capabilitySet, "events.subscribe"); let registration; if (typeof filterOrFn === "function") { registration = { name, fn: filterOrFn }; } else { if (!maybeFn) throw new Error("event handler is required"); registration = { name, filter: filterOrFn, fn: maybeFn }; } events.push(registration); return () => { const idx = events.indexOf(registration); if (idx !== -1) events.splice(idx, 1); }; }, async emit(name, companyId, payload) { requireCapability(manifest, capabilitySet, "events.emit"); await harness.emit(`plugin.${manifest.id}.${name}`, payload, { companyId }); }, }, jobs: { register(key, fn) { requireCapability(manifest, capabilitySet, "jobs.schedule"); jobs.set(key, fn); }, }, launchers: { register(launcher) { launchers.set(launcher.id, launcher); }, }, http: { async fetch(url, init) { requireCapability(manifest, capabilitySet, "http.outbound"); return fetch(url, init); }, }, secrets: { async resolve(secretRef) { requireCapability(manifest, capabilitySet, "secrets.read-ref"); return `resolved:${secretRef}`; }, }, activity: { async log(entry) { requireCapability(manifest, capabilitySet, "activity.log.write"); activity.push(entry); }, }, state: { async get(input) { requireCapability(manifest, capabilitySet, "plugin.state.read"); return state.has(stateMapKey(input)) ? state.get(stateMapKey(input)) : null; }, async set(input, value) { requireCapability(manifest, capabilitySet, "plugin.state.write"); state.set(stateMapKey(input), value); }, async delete(input) { requireCapability(manifest, capabilitySet, "plugin.state.write"); state.delete(stateMapKey(input)); }, }, entities: { async upsert(input) { const externalKey = input.externalId ? `${input.entityType}|${input.scopeKind}|${input.scopeId ?? ""}|${input.externalId}` : null; const existingId = externalKey ? entityExternalIndex.get(externalKey) : undefined; const existing = existingId ? entities.get(existingId) : undefined; const now = new Date().toISOString(); const previousExternalKey = existing?.externalId ? `${existing.entityType}|${existing.scopeKind}|${existing.scopeId ?? ""}|${existing.externalId}` : null; const record = existing ? { ...existing, entityType: input.entityType, scopeKind: input.scopeKind, scopeId: input.scopeId ?? null, externalId: input.externalId ?? null, title: input.title ?? null, status: input.status ?? null, data: input.data, updatedAt: now, } : { id: randomUUID(), entityType: input.entityType, scopeKind: input.scopeKind, scopeId: input.scopeId ?? null, externalId: input.externalId ?? null, title: input.title ?? null, status: input.status ?? null, data: input.data, createdAt: now, updatedAt: now, }; entities.set(record.id, record); if (previousExternalKey && previousExternalKey !== externalKey) { entityExternalIndex.delete(previousExternalKey); } if (externalKey) entityExternalIndex.set(externalKey, record.id); return record; }, async list(query) { let out = [...entities.values()]; if (query.entityType) out = out.filter((r) => r.entityType === query.entityType); if (query.scopeKind) out = out.filter((r) => r.scopeKind === query.scopeKind); if (query.scopeId) out = out.filter((r) => r.scopeId === query.scopeId); if (query.externalId) out = out.filter((r) => r.externalId === query.externalId); if (query.offset) out = out.slice(query.offset); if (query.limit) out = out.slice(0, query.limit); return out; }, }, projects: { async list(input) { requireCapability(manifest, capabilitySet, "projects.read"); const companyId = requireCompanyId(input?.companyId); let out = [...projects.values()]; out = out.filter((project) => project.companyId === companyId); if (input?.offset) out = out.slice(input.offset); if (input?.limit) out = out.slice(0, input.limit); return out; }, async get(projectId, companyId) { requireCapability(manifest, capabilitySet, "projects.read"); const project = projects.get(projectId); return isInCompany(project, companyId) ? project : null; }, async listWorkspaces(projectId, companyId) { requireCapability(manifest, capabilitySet, "project.workspaces.read"); if (!isInCompany(projects.get(projectId), companyId)) return []; return projectWorkspaces.get(projectId) ?? []; }, async getPrimaryWorkspace(projectId, companyId) { requireCapability(manifest, capabilitySet, "project.workspaces.read"); if (!isInCompany(projects.get(projectId), companyId)) return null; const workspaces = projectWorkspaces.get(projectId) ?? []; return workspaces.find((workspace) => workspace.isPrimary) ?? null; }, async getWorkspaceForIssue(issueId, companyId) { requireCapability(manifest, capabilitySet, "project.workspaces.read"); const issue = issues.get(issueId); if (!isInCompany(issue, companyId)) return null; const projectId = issue?.projectId; if (!projectId) return null; if (!isInCompany(projects.get(projectId), companyId)) return null; const workspaces = projectWorkspaces.get(projectId) ?? []; return workspaces.find((workspace) => workspace.isPrimary) ?? null; }, }, companies: { async list(input) { requireCapability(manifest, capabilitySet, "companies.read"); let out = [...companies.values()]; if (input?.offset) out = out.slice(input.offset); if (input?.limit) out = out.slice(0, input.limit); return out; }, async get(companyId) { requireCapability(manifest, capabilitySet, "companies.read"); return companies.get(companyId) ?? null; }, }, issues: { async list(input) { requireCapability(manifest, capabilitySet, "issues.read"); const companyId = requireCompanyId(input?.companyId); let out = [...issues.values()]; out = out.filter((issue) => issue.companyId === companyId); if (input?.projectId) out = out.filter((issue) => issue.projectId === input.projectId); if (input?.assigneeAgentId) out = out.filter((issue) => issue.assigneeAgentId === input.assigneeAgentId); if (input?.status) out = out.filter((issue) => issue.status === input.status); if (input?.offset) out = out.slice(input.offset); if (input?.limit) out = out.slice(0, input.limit); return out; }, async get(issueId, companyId) { requireCapability(manifest, capabilitySet, "issues.read"); const issue = issues.get(issueId); return isInCompany(issue, companyId) ? issue : null; }, async create(input) { requireCapability(manifest, capabilitySet, "issues.create"); const now = new Date(); const record = { id: randomUUID(), companyId: input.companyId, projectId: input.projectId ?? null, projectWorkspaceId: null, goalId: input.goalId ?? null, parentId: input.parentId ?? null, title: input.title, description: input.description ?? null, status: "todo", priority: input.priority ?? "medium", assigneeAgentId: input.assigneeAgentId ?? null, assigneeUserId: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, createdByAgentId: null, createdByUserId: null, issueNumber: null, identifier: null, requestDepth: 0, billingCode: null, assigneeAdapterOverrides: null, executionWorkspaceId: null, executionWorkspacePreference: null, executionWorkspaceSettings: null, startedAt: null, completedAt: null, cancelledAt: null, hiddenAt: null, createdAt: now, updatedAt: now, }; issues.set(record.id, record); return record; }, async update(issueId, patch, companyId) { requireCapability(manifest, capabilitySet, "issues.update"); const record = issues.get(issueId); if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`); const updated = { ...record, ...patch, updatedAt: new Date(), }; issues.set(issueId, updated); return updated; }, async listComments(issueId, companyId) { requireCapability(manifest, capabilitySet, "issue.comments.read"); if (!isInCompany(issues.get(issueId), companyId)) return []; return issueComments.get(issueId) ?? []; }, async createComment(issueId, body, companyId) { requireCapability(manifest, capabilitySet, "issue.comments.create"); const parentIssue = issues.get(issueId); if (!isInCompany(parentIssue, companyId)) { throw new Error(`Issue not found: ${issueId}`); } const now = new Date(); const comment = { id: randomUUID(), companyId: parentIssue.companyId, issueId, authorAgentId: null, authorUserId: null, body, createdAt: now, updatedAt: now, }; const current = issueComments.get(issueId) ?? []; current.push(comment); issueComments.set(issueId, current); return comment; }, documents: { async list(issueId, companyId) { requireCapability(manifest, capabilitySet, "issue.documents.read"); if (!isInCompany(issues.get(issueId), companyId)) return []; return []; }, async get(issueId, _key, companyId) { requireCapability(manifest, capabilitySet, "issue.documents.read"); if (!isInCompany(issues.get(issueId), companyId)) return null; return null; }, async upsert(input) { requireCapability(manifest, capabilitySet, "issue.documents.write"); const parentIssue = issues.get(input.issueId); if (!isInCompany(parentIssue, input.companyId)) { throw new Error(`Issue not found: ${input.issueId}`); } throw new Error("documents.upsert is not implemented in test context"); }, async delete(issueId, _key, companyId) { requireCapability(manifest, capabilitySet, "issue.documents.write"); const parentIssue = issues.get(issueId); if (!isInCompany(parentIssue, companyId)) { throw new Error(`Issue not found: ${issueId}`); } }, }, }, agents: { async list(input) { requireCapability(manifest, capabilitySet, "agents.read"); const companyId = requireCompanyId(input?.companyId); let out = [...agents.values()]; out = out.filter((agent) => agent.companyId === companyId); if (input?.status) out = out.filter((agent) => agent.status === input.status); if (input?.offset) out = out.slice(input.offset); if (input?.limit) out = out.slice(0, input.limit); return out; }, async get(agentId, companyId) { requireCapability(manifest, capabilitySet, "agents.read"); const agent = agents.get(agentId); return isInCompany(agent, companyId) ? agent : null; }, async pause(agentId, companyId) { requireCapability(manifest, capabilitySet, "agents.pause"); const cid = requireCompanyId(companyId); const agent = agents.get(agentId); if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); if (agent.status === "terminated") throw new Error("Cannot pause terminated agent"); const updated = { ...agent, status: "paused", updatedAt: new Date() }; agents.set(agentId, updated); return updated; }, async resume(agentId, companyId) { requireCapability(manifest, capabilitySet, "agents.resume"); const cid = requireCompanyId(companyId); const agent = agents.get(agentId); if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); if (agent.status === "terminated") throw new Error("Cannot resume terminated agent"); if (agent.status === "pending_approval") throw new Error("Pending approval agents cannot be resumed"); const updated = { ...agent, status: "idle", updatedAt: new Date() }; agents.set(agentId, updated); return updated; }, async invoke(agentId, companyId, opts) { requireCapability(manifest, capabilitySet, "agents.invoke"); const cid = requireCompanyId(companyId); const agent = agents.get(agentId); if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") { throw new Error(`Agent is not invokable in its current state: ${agent.status}`); } return { runId: randomUUID() }; }, sessions: { async create(agentId, companyId, opts) { requireCapability(manifest, capabilitySet, "agent.sessions.create"); const cid = requireCompanyId(companyId); const agent = agents.get(agentId); if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); const session = { sessionId: randomUUID(), agentId, companyId: cid, status: "active", createdAt: new Date().toISOString(), }; sessions.set(session.sessionId, session); return session; }, async list(agentId, companyId) { requireCapability(manifest, capabilitySet, "agent.sessions.list"); const cid = requireCompanyId(companyId); return [...sessions.values()].filter((s) => s.agentId === agentId && s.companyId === cid && s.status === "active"); }, async sendMessage(sessionId, companyId, opts) { requireCapability(manifest, capabilitySet, "agent.sessions.send"); const session = sessions.get(sessionId); if (!session || session.status !== "active") throw new Error(`Session not found or closed: ${sessionId}`); if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`); if (opts.onEvent) { sessionEventCallbacks.set(sessionId, opts.onEvent); } return { runId: randomUUID() }; }, async close(sessionId, companyId) { requireCapability(manifest, capabilitySet, "agent.sessions.close"); const session = sessions.get(sessionId); if (!session) throw new Error(`Session not found: ${sessionId}`); if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`); session.status = "closed"; sessionEventCallbacks.delete(sessionId); }, }, }, goals: { async list(input) { requireCapability(manifest, capabilitySet, "goals.read"); const companyId = requireCompanyId(input?.companyId); let out = [...goals.values()]; out = out.filter((goal) => goal.companyId === companyId); if (input?.level) out = out.filter((goal) => goal.level === input.level); if (input?.status) out = out.filter((goal) => goal.status === input.status); if (input?.offset) out = out.slice(input.offset); if (input?.limit) out = out.slice(0, input.limit); return out; }, async get(goalId, companyId) { requireCapability(manifest, capabilitySet, "goals.read"); const goal = goals.get(goalId); return isInCompany(goal, companyId) ? goal : null; }, async create(input) { requireCapability(manifest, capabilitySet, "goals.create"); const now = new Date(); const record = { id: randomUUID(), companyId: input.companyId, title: input.title, description: input.description ?? null, level: input.level ?? "task", status: input.status ?? "planned", parentId: input.parentId ?? null, ownerAgentId: input.ownerAgentId ?? null, createdAt: now, updatedAt: now, }; goals.set(record.id, record); return record; }, async update(goalId, patch, companyId) { requireCapability(manifest, capabilitySet, "goals.update"); const record = goals.get(goalId); if (!isInCompany(record, companyId)) throw new Error(`Goal not found: ${goalId}`); const updated = { ...record, ...patch, updatedAt: new Date(), }; goals.set(goalId, updated); return updated; }, }, data: { register(key, handler) { dataHandlers.set(key, handler); }, }, actions: { register(key, handler) { actionHandlers.set(key, handler); }, }, streams: (() => { const channelCompanyMap = new Map(); return { open(channel, companyId) { channelCompanyMap.set(channel, companyId); }, emit(_channel, _event) { // No-op in test harness — events are not forwarded }, close(channel) { channelCompanyMap.delete(channel); }, }; })(), tools: { register(name, _decl, fn) { requireCapability(manifest, capabilitySet, "agent.tools.register"); toolHandlers.set(name, fn); }, }, metrics: { async write(name, value, tags) { requireCapability(manifest, capabilitySet, "metrics.write"); metrics.push({ name, value, tags }); }, }, logger: { info(message, meta) { logs.push({ level: "info", message, meta }); }, warn(message, meta) { logs.push({ level: "warn", message, meta }); }, error(message, meta) { logs.push({ level: "error", message, meta }); }, debug(message, meta) { logs.push({ level: "debug", message, meta }); }, }, }; const harness = { ctx, seed(input) { for (const row of input.companies ?? []) companies.set(row.id, row); for (const row of input.projects ?? []) projects.set(row.id, row); for (const row of input.issues ?? []) issues.set(row.id, row); for (const row of input.issueComments ?? []) { const list = issueComments.get(row.issueId) ?? []; list.push(row); issueComments.set(row.issueId, list); } for (const row of input.agents ?? []) agents.set(row.id, row); for (const row of input.goals ?? []) goals.set(row.id, row); }, setConfig(config) { currentConfig = { ...config }; }, async emit(eventType, payload, base) { const event = { eventId: base?.eventId ?? randomUUID(), eventType, companyId: base?.companyId ?? "test-company", occurredAt: base?.occurredAt ?? new Date().toISOString(), actorId: base?.actorId, actorType: base?.actorType, entityId: base?.entityId, entityType: base?.entityType, payload, }; for (const handler of events) { const exactMatch = handler.name === event.eventType; const wildcardPluginAll = handler.name === "plugin.*" && String(event.eventType).startsWith("plugin."); const wildcardPluginOne = String(handler.name).endsWith(".*") && String(event.eventType).startsWith(String(handler.name).slice(0, -1)); if (!exactMatch && !wildcardPluginAll && !wildcardPluginOne) continue; if (!allowsEvent(handler.filter, event)) continue; await handler.fn(event); } }, async runJob(jobKey, partial = {}) { const handler = jobs.get(jobKey); if (!handler) throw new Error(`No job handler registered for '${jobKey}'`); await handler({ jobKey, runId: partial.runId ?? randomUUID(), trigger: partial.trigger ?? "manual", scheduledAt: partial.scheduledAt ?? new Date().toISOString(), }); }, async getData(key, params = {}) { const handler = dataHandlers.get(key); if (!handler) throw new Error(`No data handler registered for '${key}'`); return await handler(params); }, async performAction(key, params = {}) { const handler = actionHandlers.get(key); if (!handler) throw new Error(`No action handler registered for '${key}'`); return await handler(params); }, async executeTool(name, params, runCtx = {}) { const handler = toolHandlers.get(name); if (!handler) throw new Error(`No tool handler registered for '${name}'`); const ctxToPass = { agentId: runCtx.agentId ?? "agent-test", runId: runCtx.runId ?? randomUUID(), companyId: runCtx.companyId ?? "company-test", projectId: runCtx.projectId ?? "project-test", }; return await handler(params, ctxToPass); }, getState(input) { return state.get(stateMapKey(input)); }, simulateSessionEvent(sessionId, event) { const cb = sessionEventCallbacks.get(sessionId); if (!cb) throw new Error(`No active session event callback for session: ${sessionId}`); cb({ ...event, sessionId }); }, logs, activity, metrics, }; return harness; } //# sourceMappingURL=testing.js.map