mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies by keeping task ownership, approvals, and operator control inside one control plane. > - Agent permissions and plugin-hosted company settings sit on the boundary between autonomy and governance. > - V1 needs scoped task assignment rules, plugin extension points, and clearer company access surfaces without weakening company boundaries. > - The branch builds the core authorization service, plugin SDK/host APIs, and UI simplifications needed to support those controls. > - Paperclip EE plugin surfaces were intentionally moved out of this core PR per review direction, so this PR now carries only the public core/plugin infrastructure work. > - The latest updates preserve the PAP-9937 branch changes that belong in this PR, remove the `design/` artifacts, and exclude the experimental `plugin-briefs` package. > - Greptile feedback was applied through the authorization/audit paths and the final cleanup commit was re-reviewed at 5/5 with no unresolved Greptile threads. > - The benefit is safer assignment control with extension hooks for richer permission products while preserving simple defaults for normal operators. ## What Changed - Added scoped task-assignment authorization decisions and routed issue/agent assignment mutations through the authorization service. - Added plugin SDK and host APIs for company settings slots, authorization policy/grant management, assignment previews, and bridge invocation scope propagation. - Simplified core company access UI and moved advanced controls behind plugin-provided settings surfaces. - Added retry-now affordances for blocked issue next-step notices. - Added protected-assignment enforcement for persisted agent/project/issue policies, including explicit-grant fallback behavior. - Added incremental principal-access compatibility backfill for active agent memberships and role-default human permission grants. - Added the Markdown code block wrap action fix from the latest branch changes. - Removed `design/` artifacts from the PR and removed `packages/plugins/plugin-briefs` from the final diff. - Addressed Greptile feedback for plugin actor sanitization, legacy membership handling, audit pagination, unknown grant-scope metadata, and startup test mocks. ## Verification - `pnpm exec vitest run server/src/__tests__/access-service.test.ts server/src/__tests__/company-portability.test.ts` -> 2 files passed, 54 tests passed. - `pnpm exec vitest run server/src/__tests__/server-startup-feedback-export.test.ts server/src/__tests__/access-service.test.ts server/src/__tests__/company-portability.test.ts` -> 3 files passed, 62 tests passed. - `pnpm exec vitest run server/src/__tests__/authorization-service.test.ts server/src/__tests__/plugin-access-authorization-host-services.test.ts server/src/__tests__/server-startup-feedback-export.test.ts` -> 3 files passed, 28 tests passed. - `pnpm --filter @paperclipai/server typecheck` -> passed. - `git diff --check` -> passed. - `node ./scripts/check-docker-deps-stage.mjs` -> passed. - `CI=true pnpm install --frozen-lockfile --ignore-scripts` -> passed with no lockfile update. - `pnpm exec vitest run ui/src/components/MarkdownBody.interaction.test.tsx` -> 1 test passed. - `git ls-files design packages/plugins/plugin-briefs | wc -l` -> 0. - GitHub CI on `40cd83b53` -> all checks passed, merge state `CLEAN`. - Greptile on `40cd83b53` -> 5/5, 102 files reviewed, 0 comments/annotations added, 0 unresolved review threads. - Confirmed the PR diff contains no `design/`, `packages/plugins/plugin-briefs`, `pnpm-lock.yaml`, or `.github/workflows` changes. ## Risks - Medium: task assignment authorization paths are behaviorally stricter for protected/private policy data, so existing plugin-authored policies may block assignment until explicit grants or approval flows are configured. - Medium: plugin-host authorization APIs expand the surface area available to trusted plugins and need careful review for company scoping. - Low: startup now performs a principal-access compatibility backfill, but the migration and runtime backfill use conflict-tolerant inserts. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled workflow with shell, git, and GitHub CLI access. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2424 lines
100 KiB
TypeScript
2424 lines
100 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { pluginOperationIssueOriginKind } from "@paperclipai/shared";
|
|
import type {
|
|
PaperclipPluginManifestV1,
|
|
PluginCapability,
|
|
PluginEventType,
|
|
PluginIssueOriginKind,
|
|
PluginManagedAgentResolution,
|
|
PluginManagedRoutineResolution,
|
|
PluginManagedSkillResolution,
|
|
CompanySkill,
|
|
Company,
|
|
Project,
|
|
Routine,
|
|
RoutineRun,
|
|
Issue,
|
|
IssueComment,
|
|
IssueThreadInteraction,
|
|
CreateIssueThreadInteraction,
|
|
IssueDocument,
|
|
Agent,
|
|
Goal,
|
|
} from "@paperclipai/shared";
|
|
import type {
|
|
EventFilter,
|
|
PluginContext,
|
|
PluginEntityRecord,
|
|
PluginEntityUpsert,
|
|
PluginJobContext,
|
|
PluginLauncherRegistration,
|
|
PluginEvent,
|
|
ScopeKey,
|
|
ToolResult,
|
|
ToolRunContext,
|
|
PluginWorkspace,
|
|
PluginExecutionWorkspaceMetadata,
|
|
AgentSession,
|
|
AgentSessionEvent,
|
|
PluginLocalFolderEntry,
|
|
PluginLocalFolderStatus,
|
|
PluginAccessMember,
|
|
PrincipalPermissionGrant,
|
|
PermissionKey,
|
|
PrincipalType,
|
|
} from "./types.js";
|
|
import type {
|
|
PluginEnvironmentValidateConfigParams,
|
|
PluginEnvironmentValidationResult,
|
|
PluginEnvironmentProbeParams,
|
|
PluginEnvironmentProbeResult,
|
|
PluginEnvironmentLease,
|
|
PluginEnvironmentAcquireLeaseParams,
|
|
PluginEnvironmentResumeLeaseParams,
|
|
PluginEnvironmentReleaseLeaseParams,
|
|
PluginEnvironmentDestroyLeaseParams,
|
|
PluginEnvironmentRealizeWorkspaceParams,
|
|
PluginEnvironmentRealizeWorkspaceResult,
|
|
PluginEnvironmentExecuteParams,
|
|
PluginEnvironmentExecuteResult,
|
|
} from "./protocol.js";
|
|
|
|
export interface TestHarnessOptions {
|
|
/** Plugin manifest used to seed capability checks and metadata. */
|
|
manifest: PaperclipPluginManifestV1;
|
|
/** Optional capability override. Defaults to `manifest.capabilities`. */
|
|
capabilities?: PluginCapability[];
|
|
/** Initial config returned by `ctx.config.get()`. */
|
|
config?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface TestHarnessLogEntry {
|
|
level: "info" | "warn" | "error" | "debug";
|
|
message: string;
|
|
meta?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface TestHarness {
|
|
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
|
|
ctx: PluginContext;
|
|
/** Seed host entities for `ctx.companies/projects/issues/agents/goals/access/authorization` reads. */
|
|
seed(input: {
|
|
companies?: Company[];
|
|
projects?: Project[];
|
|
issues?: Issue[];
|
|
issueComments?: IssueComment[];
|
|
agents?: Agent[];
|
|
goals?: Goal[];
|
|
projectWorkspaces?: PluginWorkspace[];
|
|
executionWorkspaces?: PluginExecutionWorkspaceMetadata[];
|
|
accessMembers?: PluginAccessMember[];
|
|
principalGrants?: PrincipalPermissionGrant[];
|
|
}): void;
|
|
setConfig(config: Record<string, unknown>): void;
|
|
/** Dispatch a host or plugin event to registered handlers. */
|
|
emit(eventType: PluginEventType | `plugin.${string}`, payload: unknown, base?: Partial<PluginEvent>): Promise<void>;
|
|
/** Execute a previously-registered scheduled job handler. */
|
|
runJob(jobKey: string, partial?: Partial<PluginJobContext>): Promise<void>;
|
|
/** Invoke a `ctx.data.register(...)` handler by key. */
|
|
getData<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
|
/** Invoke a `ctx.actions.register(...)` handler by key. */
|
|
performAction<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
|
/** Execute a registered tool handler via `ctx.tools.execute(...)`. */
|
|
executeTool<T = ToolResult>(name: string, params: unknown, runCtx?: Partial<ToolRunContext>): Promise<T>;
|
|
/** Read raw in-memory state for assertions. */
|
|
getState(input: ScopeKey): unknown;
|
|
/** Simulate a streaming event arriving for an active session. */
|
|
simulateSessionEvent(sessionId: string, event: Omit<AgentSessionEvent, "sessionId">): void;
|
|
logs: TestHarnessLogEntry[];
|
|
activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record<string, unknown> }>;
|
|
metrics: Array<{ name: string; value: number; tags?: Record<string, string> }>;
|
|
telemetry: Array<{ eventName: string; dimensions?: Record<string, string | number | boolean> }>;
|
|
dbQueries: Array<{ sql: string; params?: unknown[] }>;
|
|
dbExecutes: Array<{ sql: string; params?: unknown[] }>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Environment test harness types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Recorded environment lifecycle event for assertion helpers. */
|
|
export interface EnvironmentEventRecord {
|
|
type:
|
|
| "validateConfig"
|
|
| "probe"
|
|
| "acquireLease"
|
|
| "resumeLease"
|
|
| "releaseLease"
|
|
| "destroyLease"
|
|
| "realizeWorkspace"
|
|
| "execute";
|
|
driverKey: string;
|
|
environmentId: string;
|
|
timestamp: string;
|
|
params: Record<string, unknown>;
|
|
result?: unknown;
|
|
error?: string;
|
|
}
|
|
|
|
/** Options for creating an environment-aware test harness. */
|
|
export interface EnvironmentTestHarnessOptions extends TestHarnessOptions {
|
|
/** Environment driver hooks provided by the plugin under test. */
|
|
environmentDriver: {
|
|
driverKey: string;
|
|
onValidateConfig?: (params: PluginEnvironmentValidateConfigParams) => Promise<PluginEnvironmentValidationResult>;
|
|
onProbe?: (params: PluginEnvironmentProbeParams) => Promise<PluginEnvironmentProbeResult>;
|
|
onAcquireLease?: (params: PluginEnvironmentAcquireLeaseParams) => Promise<PluginEnvironmentLease>;
|
|
onResumeLease?: (params: PluginEnvironmentResumeLeaseParams) => Promise<PluginEnvironmentLease>;
|
|
onReleaseLease?: (params: PluginEnvironmentReleaseLeaseParams) => Promise<void>;
|
|
onDestroyLease?: (params: PluginEnvironmentDestroyLeaseParams) => Promise<void>;
|
|
onRealizeWorkspace?: (params: PluginEnvironmentRealizeWorkspaceParams) => Promise<PluginEnvironmentRealizeWorkspaceResult>;
|
|
onExecute?: (params: PluginEnvironmentExecuteParams) => Promise<PluginEnvironmentExecuteResult>;
|
|
};
|
|
}
|
|
|
|
/** Extended test harness with environment driver simulation. */
|
|
export interface EnvironmentTestHarness extends TestHarness {
|
|
/** Recorded environment lifecycle events for assertion. */
|
|
environmentEvents: EnvironmentEventRecord[];
|
|
/** Invoke the environment driver's validateConfig hook. */
|
|
validateConfig(params: PluginEnvironmentValidateConfigParams): Promise<PluginEnvironmentValidationResult>;
|
|
/** Invoke the environment driver's probe hook. */
|
|
probe(params: PluginEnvironmentProbeParams): Promise<PluginEnvironmentProbeResult>;
|
|
/** Invoke the environment driver's acquireLease hook. */
|
|
acquireLease(params: PluginEnvironmentAcquireLeaseParams): Promise<PluginEnvironmentLease>;
|
|
/** Invoke the environment driver's resumeLease hook. */
|
|
resumeLease(params: PluginEnvironmentResumeLeaseParams): Promise<PluginEnvironmentLease>;
|
|
/** Invoke the environment driver's releaseLease hook. */
|
|
releaseLease(params: PluginEnvironmentReleaseLeaseParams): Promise<void>;
|
|
/** Invoke the environment driver's destroyLease hook. */
|
|
destroyLease(params: PluginEnvironmentDestroyLeaseParams): Promise<void>;
|
|
/** Invoke the environment driver's realizeWorkspace hook. */
|
|
realizeWorkspace(params: PluginEnvironmentRealizeWorkspaceParams): Promise<PluginEnvironmentRealizeWorkspaceResult>;
|
|
/** Invoke the environment driver's execute hook. */
|
|
execute(params: PluginEnvironmentExecuteParams): Promise<PluginEnvironmentExecuteResult>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Environment event assertion helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Filter environment events by type. */
|
|
export function filterEnvironmentEvents(
|
|
events: EnvironmentEventRecord[],
|
|
type: EnvironmentEventRecord["type"],
|
|
): EnvironmentEventRecord[] {
|
|
return events.filter((e) => e.type === type);
|
|
}
|
|
|
|
/** Assert that environment events occurred in the expected order. */
|
|
export function assertEnvironmentEventOrder(
|
|
events: EnvironmentEventRecord[],
|
|
expectedOrder: EnvironmentEventRecord["type"][],
|
|
): void {
|
|
const actual = events.map((e) => e.type);
|
|
const matched: EnvironmentEventRecord["type"][] = [];
|
|
let cursor = 0;
|
|
for (const eventType of actual) {
|
|
if (cursor < expectedOrder.length && eventType === expectedOrder[cursor]) {
|
|
matched.push(eventType);
|
|
cursor++;
|
|
}
|
|
}
|
|
if (matched.length !== expectedOrder.length) {
|
|
throw new Error(
|
|
`Environment event order mismatch.\nExpected: ${JSON.stringify(expectedOrder)}\nActual: ${JSON.stringify(actual)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Assert that a full lease lifecycle (acquire → release) occurred for an environment. */
|
|
export function assertLeaseLifecycle(
|
|
events: EnvironmentEventRecord[],
|
|
environmentId: string,
|
|
): { acquire: EnvironmentEventRecord; release: EnvironmentEventRecord } {
|
|
const acquire = events.find((e) => e.type === "acquireLease" && e.environmentId === environmentId);
|
|
const release = events.find((e) => (e.type === "releaseLease" || e.type === "destroyLease") && e.environmentId === environmentId);
|
|
if (!acquire) throw new Error(`No acquireLease event found for environment ${environmentId}`);
|
|
if (!release) throw new Error(`No releaseLease/destroyLease event found for environment ${environmentId}`);
|
|
if (acquire.timestamp > release.timestamp) {
|
|
throw new Error(`acquireLease occurred after release for environment ${environmentId}`);
|
|
}
|
|
return { acquire, release };
|
|
}
|
|
|
|
/** Assert that workspace realization occurred between lease acquire and release. */
|
|
export function assertWorkspaceRealizationLifecycle(
|
|
events: EnvironmentEventRecord[],
|
|
environmentId: string,
|
|
): EnvironmentEventRecord {
|
|
const lifecycle = assertLeaseLifecycle(events, environmentId);
|
|
const realize = events.find(
|
|
(e) => e.type === "realizeWorkspace" && e.environmentId === environmentId,
|
|
);
|
|
if (!realize) throw new Error(`No realizeWorkspace event found for environment ${environmentId}`);
|
|
if (realize.timestamp < lifecycle.acquire.timestamp) {
|
|
throw new Error(`realizeWorkspace occurred before acquireLease for environment ${environmentId}`);
|
|
}
|
|
if (realize.timestamp > lifecycle.release.timestamp) {
|
|
throw new Error(`realizeWorkspace occurred after release for environment ${environmentId}`);
|
|
}
|
|
return realize;
|
|
}
|
|
|
|
/** Assert that an execute call occurred within the lease lifecycle. */
|
|
export function assertExecutionLifecycle(
|
|
events: EnvironmentEventRecord[],
|
|
environmentId: string,
|
|
): EnvironmentEventRecord[] {
|
|
const lifecycle = assertLeaseLifecycle(events, environmentId);
|
|
const execEvents = events.filter(
|
|
(e) => e.type === "execute" && e.environmentId === environmentId,
|
|
);
|
|
if (execEvents.length === 0) {
|
|
throw new Error(`No execute events found for environment ${environmentId}`);
|
|
}
|
|
for (const exec of execEvents) {
|
|
if (exec.timestamp < lifecycle.acquire.timestamp || exec.timestamp > lifecycle.release.timestamp) {
|
|
throw new Error(`Execute event occurred outside lease lifecycle for environment ${environmentId}`);
|
|
}
|
|
}
|
|
return execEvents;
|
|
}
|
|
|
|
/** Assert that an event recorded an error. */
|
|
export function assertEnvironmentError(
|
|
events: EnvironmentEventRecord[],
|
|
type: EnvironmentEventRecord["type"],
|
|
environmentId?: string,
|
|
): EnvironmentEventRecord {
|
|
const match = events.find(
|
|
(e) => e.type === type && e.error != null && (!environmentId || e.environmentId === environmentId),
|
|
);
|
|
if (!match) {
|
|
throw new Error(`No error event of type '${type}'${environmentId ? ` for environment ${environmentId}` : ""}`);
|
|
}
|
|
return match;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fake environment plugin driver
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Options for creating a fake environment driver for contract testing. */
|
|
export interface FakeEnvironmentDriverOptions {
|
|
driverKey?: string;
|
|
/** Simulated acquire delay in ms. */
|
|
acquireDelayMs?: number;
|
|
/** If true, probe will return `ok: false`. */
|
|
probeFailure?: boolean;
|
|
/** If true, acquireLease will throw. */
|
|
acquireFailure?: string;
|
|
/** If true, execute will return a non-zero exit code. */
|
|
executeFailure?: boolean;
|
|
/** Custom metadata returned on lease acquire. */
|
|
leaseMetadata?: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Create a fake environment driver suitable for contract testing.
|
|
*
|
|
* This returns a driver hooks object compatible with `EnvironmentTestHarnessOptions.environmentDriver`.
|
|
* It simulates the full environment lifecycle with configurable failure injection.
|
|
*/
|
|
export function createFakeEnvironmentDriver(options: FakeEnvironmentDriverOptions = {}): EnvironmentTestHarnessOptions["environmentDriver"] {
|
|
const driverKey = options.driverKey ?? "fake";
|
|
const leases = new Map<string, { providerLeaseId: string; metadata: Record<string, unknown> }>();
|
|
let leaseCounter = 0;
|
|
|
|
return {
|
|
driverKey,
|
|
async onValidateConfig(params) {
|
|
if (!params.config || typeof params.config !== "object") {
|
|
return { ok: false, errors: ["Config must be an object"] };
|
|
}
|
|
return { ok: true, normalizedConfig: params.config };
|
|
},
|
|
async onProbe(_params) {
|
|
if (options.probeFailure) {
|
|
return { ok: false, summary: "Simulated probe failure", diagnostics: [{ severity: "error", message: "Probe failed" }] };
|
|
}
|
|
return { ok: true, summary: "Fake environment is healthy" };
|
|
},
|
|
async onAcquireLease(params) {
|
|
if (options.acquireFailure) {
|
|
throw new Error(options.acquireFailure);
|
|
}
|
|
if (options.acquireDelayMs) {
|
|
await new Promise((resolve) => setTimeout(resolve, options.acquireDelayMs));
|
|
}
|
|
const providerLeaseId = `fake-lease-${++leaseCounter}`;
|
|
const metadata = { ...options.leaseMetadata, acquiredAt: new Date().toISOString(), runId: params.runId };
|
|
leases.set(providerLeaseId, { providerLeaseId, metadata });
|
|
return { providerLeaseId, metadata };
|
|
},
|
|
async onResumeLease(params) {
|
|
const existing = leases.get(params.providerLeaseId);
|
|
if (!existing) {
|
|
throw new Error(`Lease ${params.providerLeaseId} not found — cannot resume`);
|
|
}
|
|
return { providerLeaseId: existing.providerLeaseId, metadata: { ...existing.metadata, resumed: true } };
|
|
},
|
|
async onReleaseLease(params) {
|
|
if (params.providerLeaseId) {
|
|
leases.delete(params.providerLeaseId);
|
|
}
|
|
},
|
|
async onDestroyLease(params) {
|
|
if (params.providerLeaseId) {
|
|
leases.delete(params.providerLeaseId);
|
|
}
|
|
},
|
|
async onRealizeWorkspace(params) {
|
|
return {
|
|
cwd: params.workspace.localPath ?? params.workspace.remotePath ?? "/tmp/fake-workspace",
|
|
metadata: { realized: true },
|
|
};
|
|
},
|
|
async onExecute(params) {
|
|
if (options.executeFailure) {
|
|
return { exitCode: 1, timedOut: false, stdout: "", stderr: "Simulated execution failure" };
|
|
}
|
|
return {
|
|
exitCode: 0,
|
|
timedOut: false,
|
|
stdout: `Executed: ${params.command} ${(params.args ?? []).join(" ")}`.trim(),
|
|
stderr: "",
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
type EventRegistration = {
|
|
name: PluginEventType | `plugin.${string}`;
|
|
filter?: EventFilter;
|
|
fn: (event: PluginEvent) => Promise<void>;
|
|
};
|
|
|
|
function normalizeScope(input: ScopeKey): Required<Pick<ScopeKey, "scopeKind" | "stateKey">> & Pick<ScopeKey, "scopeId" | "namespace"> {
|
|
return {
|
|
scopeKind: input.scopeKind,
|
|
scopeId: input.scopeId,
|
|
namespace: input.namespace ?? "default",
|
|
stateKey: input.stateKey,
|
|
};
|
|
}
|
|
|
|
function stateMapKey(input: ScopeKey): string {
|
|
const normalized = normalizeScope(input);
|
|
return `${normalized.scopeKind}|${normalized.scopeId ?? ""}|${normalized.namespace}|${normalized.stateKey}`;
|
|
}
|
|
|
|
function allowsEvent(filter: EventFilter | undefined, event: PluginEvent): boolean {
|
|
if (!filter) return true;
|
|
if (filter.companyId && filter.companyId !== String((event.payload as Record<string, unknown> | undefined)?.companyId ?? "")) return false;
|
|
if (filter.projectId && filter.projectId !== String((event.payload as Record<string, unknown> | undefined)?.projectId ?? "")) return false;
|
|
if (filter.agentId && filter.agentId !== String((event.payload as Record<string, unknown> | undefined)?.agentId ?? "")) return false;
|
|
return true;
|
|
}
|
|
|
|
function requireCapability(manifest: PaperclipPluginManifestV1, allowed: Set<PluginCapability>, capability: PluginCapability) {
|
|
if (allowed.has(capability)) return;
|
|
throw new Error(`Plugin '${manifest.id}' is missing required capability '${capability}' in test harness`);
|
|
}
|
|
|
|
function requireCompanyId(companyId?: string): string {
|
|
if (!companyId) throw new Error("companyId is required for this operation");
|
|
return companyId;
|
|
}
|
|
|
|
function isInCompany<T extends { companyId: string | null | undefined }>(
|
|
record: T | null | undefined,
|
|
companyId: string,
|
|
): record is T {
|
|
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: TestHarnessOptions): TestHarness {
|
|
const manifest = options.manifest;
|
|
const capabilitySet = new Set(options.capabilities ?? manifest.capabilities);
|
|
let currentConfig = { ...(options.config ?? {}) };
|
|
|
|
const logs: TestHarnessLogEntry[] = [];
|
|
const activity: TestHarness["activity"] = [];
|
|
const metrics: TestHarness["metrics"] = [];
|
|
const telemetry: TestHarness["telemetry"] = [];
|
|
const dbQueries: TestHarness["dbQueries"] = [];
|
|
const dbExecutes: TestHarness["dbExecutes"] = [];
|
|
|
|
const state = new Map<string, unknown>();
|
|
const entities = new Map<string, PluginEntityRecord>();
|
|
const entityExternalIndex = new Map<string, string>();
|
|
const companies = new Map<string, Company>();
|
|
const projects = new Map<string, Project>();
|
|
const routines = new Map<string, Routine>();
|
|
const routineRuns = new Map<string, RoutineRun>();
|
|
const issues = new Map<string, Issue>();
|
|
const blockedByIssueIds = new Map<string, string[]>();
|
|
const issueComments = new Map<string, IssueComment[]>();
|
|
const issueInteractions = new Map<string, IssueThreadInteraction[]>();
|
|
const issueDocuments = new Map<string, IssueDocument>();
|
|
const agents = new Map<string, Agent>();
|
|
const goals = new Map<string, Goal>();
|
|
const accessMembers = new Map<string, PluginAccessMember>();
|
|
const principalGrants = new Map<string, PrincipalPermissionGrant[]>();
|
|
|
|
function principalGrantsKey(companyId: string, principalType: PrincipalType, principalId: string) {
|
|
return `${companyId}:${principalType}:${principalId}`;
|
|
}
|
|
function getPrincipalGrants(companyId: string, principalType: PrincipalType, principalId: string) {
|
|
return principalGrants.get(principalGrantsKey(companyId, principalType, principalId)) ?? [];
|
|
}
|
|
function setPrincipalGrants(
|
|
companyId: string,
|
|
principalType: PrincipalType,
|
|
principalId: string,
|
|
grants: Array<{ permissionKey: PermissionKey; scope?: Record<string, unknown> | null }>,
|
|
) {
|
|
const stamped = grants.map((grant) => ({
|
|
principalType,
|
|
principalId,
|
|
permissionKey: grant.permissionKey,
|
|
scope: grant.scope && typeof grant.scope === "object" ? grant.scope : null,
|
|
})) as PrincipalPermissionGrant[];
|
|
principalGrants.set(principalGrantsKey(companyId, principalType, principalId), stamped);
|
|
const member = [...accessMembers.values()].find(
|
|
(entry) =>
|
|
entry.companyId === companyId
|
|
&& entry.principalType === principalType
|
|
&& entry.principalId === principalId,
|
|
);
|
|
if (member) {
|
|
accessMembers.set(member.id, { ...member, grants: stamped, updatedAt: new Date().toISOString() });
|
|
}
|
|
return stamped;
|
|
}
|
|
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
|
|
const executionWorkspaces = new Map<string, PluginExecutionWorkspaceMetadata>();
|
|
const localFolderStatuses = new Map<string, PluginLocalFolderStatus>();
|
|
const localFolderFiles = new Map<string, string>();
|
|
|
|
const sessions = new Map<string, AgentSession>();
|
|
const sessionEventCallbacks = new Map<string, (event: AgentSessionEvent) => void>();
|
|
|
|
const events: EventRegistration[] = [];
|
|
const jobs = new Map<string, (job: PluginJobContext) => Promise<void>>();
|
|
const launchers = new Map<string, PluginLauncherRegistration>();
|
|
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
|
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
|
const toolHandlers = new Map<string, (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>>();
|
|
|
|
function localFolderKey(companyId: string, folderKey: string): string {
|
|
return `${companyId}:${folderKey}`;
|
|
}
|
|
|
|
function localFolderFileKey(companyId: string, folderKey: string, relativePath: string): string {
|
|
return `${localFolderKey(companyId, folderKey)}:${relativePath}`;
|
|
}
|
|
|
|
function normalizeLocalFolderRelativePath(relativePath: string): string {
|
|
const parts: string[] = [];
|
|
for (const segment of relativePath.split(/[\\/]+/)) {
|
|
if (!segment || segment === ".") continue;
|
|
if (segment === "..") throw new Error("Local folder path traversal is not allowed");
|
|
parts.push(segment);
|
|
}
|
|
return parts.join("/");
|
|
}
|
|
|
|
function notConfiguredLocalFolderStatus(folderKey: string): PluginLocalFolderStatus {
|
|
return {
|
|
folderKey,
|
|
configured: false,
|
|
path: null,
|
|
realPath: null,
|
|
access: "readWrite",
|
|
readable: false,
|
|
writable: false,
|
|
requiredDirectories: [],
|
|
requiredFiles: [],
|
|
missingDirectories: [],
|
|
missingFiles: [],
|
|
healthy: false,
|
|
problems: [{ code: "not_configured", message: "No local folder path is configured." }],
|
|
checkedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function issueRelationSummary(issueId: string) {
|
|
const issue = issues.get(issueId);
|
|
if (!issue) throw new Error(`Issue not found: ${issueId}`);
|
|
const summarize = (candidateId: string) => {
|
|
const related = issues.get(candidateId);
|
|
if (!related || related.companyId !== issue.companyId) return null;
|
|
return {
|
|
id: related.id,
|
|
identifier: related.identifier,
|
|
title: related.title,
|
|
status: related.status,
|
|
priority: related.priority,
|
|
assigneeAgentId: related.assigneeAgentId,
|
|
assigneeUserId: related.assigneeUserId,
|
|
};
|
|
};
|
|
const blockedBy = (blockedByIssueIds.get(issueId) ?? [])
|
|
.map(summarize)
|
|
.filter((value): value is NonNullable<typeof value> => value !== null);
|
|
const blocks = [...blockedByIssueIds.entries()]
|
|
.filter(([, blockers]) => blockers.includes(issueId))
|
|
.map(([blockedIssueId]) => summarize(blockedIssueId))
|
|
.filter((value): value is NonNullable<typeof value> => value !== null);
|
|
return { blockedBy, blocks };
|
|
}
|
|
|
|
const defaultPluginOriginKind: PluginIssueOriginKind = `plugin:${manifest.id}`;
|
|
|
|
function managedAgentDeclaration(agentKey: string) {
|
|
const declaration = manifest.agents?.find((agent) => agent.agentKey === agentKey);
|
|
if (!declaration) throw new Error(`Managed agent declaration not found: ${agentKey}`);
|
|
return declaration;
|
|
}
|
|
|
|
function isManagedAgent(agent: Agent, agentKey: string) {
|
|
const marker = agent.metadata?.paperclipManagedResource;
|
|
return Boolean(
|
|
marker
|
|
&& typeof marker === "object"
|
|
&& !Array.isArray(marker)
|
|
&& (marker as Record<string, unknown>).pluginKey === manifest.id
|
|
&& (marker as Record<string, unknown>).resourceKind === "agent"
|
|
&& (marker as Record<string, unknown>).resourceKey === agentKey,
|
|
);
|
|
}
|
|
|
|
function managedAgentMetadata(agentKey: string, existing?: Record<string, unknown> | null) {
|
|
return {
|
|
...(existing ?? {}),
|
|
paperclipManagedResource: {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "agent",
|
|
resourceKey: agentKey,
|
|
},
|
|
};
|
|
}
|
|
|
|
function managedResolution(
|
|
agentKey: string,
|
|
companyId: string,
|
|
agent: Agent | null,
|
|
status: PluginManagedAgentResolution["status"],
|
|
): PluginManagedAgentResolution {
|
|
return {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "agent",
|
|
resourceKey: agentKey,
|
|
companyId,
|
|
agentId: agent?.id ?? null,
|
|
agent,
|
|
status,
|
|
approvalId: null,
|
|
};
|
|
}
|
|
function normalizePluginOriginKind(originKind: unknown = defaultPluginOriginKind): PluginIssueOriginKind {
|
|
if (originKind == null || originKind === "") return defaultPluginOriginKind;
|
|
if (typeof originKind !== "string") throw new Error("Plugin issue originKind must be a string");
|
|
if (originKind === defaultPluginOriginKind || originKind.startsWith(`${defaultPluginOriginKind}:`)) {
|
|
return originKind as PluginIssueOriginKind;
|
|
}
|
|
throw new Error(`Plugin may only use originKind values under ${defaultPluginOriginKind}`);
|
|
}
|
|
|
|
const ctx: PluginContext = {
|
|
manifest,
|
|
config: {
|
|
async get() {
|
|
return { ...currentConfig };
|
|
},
|
|
},
|
|
localFolders: {
|
|
declarations() {
|
|
return manifest.localFolders ?? [];
|
|
},
|
|
async configure(input) {
|
|
requireCapability(manifest, capabilitySet, "local.folders");
|
|
const status = {
|
|
folderKey: input.folderKey,
|
|
configured: true,
|
|
path: input.path,
|
|
realPath: input.path,
|
|
access: input.access ?? "readWrite",
|
|
readable: true,
|
|
writable: input.access === "read" ? false : true,
|
|
requiredDirectories: input.requiredDirectories ?? [],
|
|
requiredFiles: input.requiredFiles ?? [],
|
|
missingDirectories: [],
|
|
missingFiles: [],
|
|
healthy: true,
|
|
problems: [],
|
|
checkedAt: new Date().toISOString(),
|
|
} satisfies PluginLocalFolderStatus;
|
|
localFolderStatuses.set(localFolderKey(input.companyId, input.folderKey), status);
|
|
return status;
|
|
},
|
|
async status(companyId, folderKey) {
|
|
requireCapability(manifest, capabilitySet, "local.folders");
|
|
return localFolderStatuses.get(localFolderKey(companyId, folderKey)) ?? notConfiguredLocalFolderStatus(folderKey);
|
|
},
|
|
async list(companyId, folderKey, options) {
|
|
requireCapability(manifest, capabilitySet, "local.folders");
|
|
const status = localFolderStatuses.get(localFolderKey(companyId, folderKey));
|
|
if (!status?.configured) throw new Error("Local folder is not configured");
|
|
const prefix = normalizeLocalFolderRelativePath(options?.relativePath ?? "");
|
|
const prefixWithSlash = prefix ? `${prefix}/` : "";
|
|
const entries = new Map<string, PluginLocalFolderEntry>();
|
|
for (const [key, contents] of localFolderFiles) {
|
|
const filePrefix = `${localFolderKey(companyId, folderKey)}:`;
|
|
if (!key.startsWith(filePrefix)) continue;
|
|
const filePath = key.slice(filePrefix.length);
|
|
if (prefix && filePath !== prefix && !filePath.startsWith(prefixWithSlash)) continue;
|
|
const remainder = prefix ? filePath.slice(prefixWithSlash.length) : filePath;
|
|
const [name] = remainder.split("/");
|
|
if (!name) continue;
|
|
const entryPath = prefix ? `${prefix}/${name}` : name;
|
|
const isNested = remainder.includes("/");
|
|
if (!options?.recursive && isNested) {
|
|
entries.set(entryPath, {
|
|
path: entryPath,
|
|
name,
|
|
kind: "directory",
|
|
size: null,
|
|
modifiedAt: null,
|
|
});
|
|
continue;
|
|
}
|
|
entries.set(filePath, {
|
|
path: filePath,
|
|
name: filePath.split("/").pop() ?? filePath,
|
|
kind: "file",
|
|
size: Buffer.byteLength(contents, "utf8"),
|
|
modifiedAt: null,
|
|
});
|
|
}
|
|
const maxEntries = options?.maxEntries && options.maxEntries > 0 ? options.maxEntries : entries.size;
|
|
const allEntries = [...entries.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
return {
|
|
folderKey,
|
|
relativePath: options?.relativePath ?? null,
|
|
entries: allEntries.slice(0, maxEntries),
|
|
truncated: allEntries.length > maxEntries,
|
|
};
|
|
},
|
|
async readText(companyId, folderKey, relativePath) {
|
|
requireCapability(manifest, capabilitySet, "local.folders");
|
|
const normalizedPath = normalizeLocalFolderRelativePath(relativePath);
|
|
const contents = localFolderFiles.get(localFolderFileKey(companyId, folderKey, normalizedPath));
|
|
if (contents === undefined) throw new Error(`Local folder file not found: ${relativePath}`);
|
|
return contents;
|
|
},
|
|
async writeTextAtomic(companyId, folderKey, relativePath, contents) {
|
|
requireCapability(manifest, capabilitySet, "local.folders");
|
|
const status = localFolderStatuses.get(localFolderKey(companyId, folderKey)) ?? {
|
|
folderKey,
|
|
configured: true,
|
|
path: `memory://${manifest.id}/${companyId}/${folderKey}`,
|
|
realPath: `memory://${manifest.id}/${companyId}/${folderKey}`,
|
|
access: "readWrite",
|
|
readable: true,
|
|
writable: true,
|
|
requiredDirectories: [],
|
|
requiredFiles: [],
|
|
missingDirectories: [],
|
|
missingFiles: [],
|
|
healthy: true,
|
|
problems: [],
|
|
checkedAt: new Date().toISOString(),
|
|
} satisfies PluginLocalFolderStatus;
|
|
if (status.access !== "readWrite" || !status.writable) {
|
|
throw new Error("Local folder is not configured for writes");
|
|
}
|
|
localFolderStatuses.set(localFolderKey(companyId, folderKey), status);
|
|
localFolderFiles.set(localFolderFileKey(companyId, folderKey, normalizeLocalFolderRelativePath(relativePath)), contents);
|
|
return status;
|
|
},
|
|
async deleteFile(companyId, folderKey, relativePath) {
|
|
requireCapability(manifest, capabilitySet, "local.folders");
|
|
const status = localFolderStatuses.get(localFolderKey(companyId, folderKey)) ?? notConfiguredLocalFolderStatus(folderKey);
|
|
if (status.configured && (status.access !== "readWrite" || !status.writable)) {
|
|
throw new Error("Local folder is not configured for writes");
|
|
}
|
|
localFolderFiles.delete(localFolderFileKey(companyId, folderKey, normalizeLocalFolderRelativePath(relativePath)));
|
|
return status;
|
|
},
|
|
},
|
|
events: {
|
|
on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise<void>), maybeFn?: (event: PluginEvent) => Promise<void>): () => void {
|
|
requireCapability(manifest, capabilitySet, "events.subscribe");
|
|
let registration: EventRegistration;
|
|
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);
|
|
},
|
|
},
|
|
db: {
|
|
namespace: manifest.database ? `test_${manifest.id.replace(/[^a-z0-9_]+/g, "_")}` : "",
|
|
async query(sql, params) {
|
|
requireCapability(manifest, capabilitySet, "database.namespace.read");
|
|
dbQueries.push({ sql, params });
|
|
return [];
|
|
},
|
|
async execute(sql, params) {
|
|
requireCapability(manifest, capabilitySet, "database.namespace.write");
|
|
dbExecutes.push({ sql, params });
|
|
return { rowCount: 0 };
|
|
},
|
|
},
|
|
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: PluginEntityUpsert) {
|
|
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: PluginEntityRecord = 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 as unknown as Record<string, unknown>)?.projectId as string | undefined;
|
|
if (!projectId) return null;
|
|
if (!isInCompany(projects.get(projectId), companyId)) return null;
|
|
const workspaces = projectWorkspaces.get(projectId) ?? [];
|
|
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
|
|
},
|
|
managed: {
|
|
async get(projectKey, companyId) {
|
|
requireCapability(manifest, capabilitySet, "projects.managed");
|
|
const declaration = manifest.projects?.find((project) => project.projectKey === projectKey);
|
|
if (!declaration) {
|
|
return {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "project",
|
|
resourceKey: projectKey,
|
|
companyId,
|
|
projectId: null,
|
|
project: null,
|
|
status: "missing",
|
|
};
|
|
}
|
|
const externalId = `${manifest.id}:project:${projectKey}`;
|
|
const existingEntity = [...entities.values()].find((entity) =>
|
|
entity.entityType === "managed_resource"
|
|
&& entity.scopeKind === "company"
|
|
&& entity.scopeId === companyId
|
|
&& entity.externalId === externalId
|
|
);
|
|
const existingProject = existingEntity ? projects.get(String(existingEntity.data?.projectId ?? "")) : null;
|
|
if (existingProject && isInCompany(existingProject, companyId)) {
|
|
return {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "project",
|
|
resourceKey: projectKey,
|
|
companyId,
|
|
projectId: existingProject.id,
|
|
project: existingProject,
|
|
status: "resolved",
|
|
};
|
|
}
|
|
const now = new Date();
|
|
const project = {
|
|
id: `project-${projects.size + 1}`,
|
|
companyId,
|
|
urlKey: declaration.projectKey,
|
|
goalId: null,
|
|
goalIds: [],
|
|
goals: [],
|
|
name: declaration.displayName,
|
|
description: declaration.description ?? null,
|
|
status: declaration.status ?? "in_progress",
|
|
leadAgentId: null,
|
|
targetDate: null,
|
|
color: declaration.color ?? null,
|
|
env: null,
|
|
pauseReason: null,
|
|
pausedAt: null,
|
|
executionWorkspacePolicy: null,
|
|
codebase: {
|
|
workspaceId: null,
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
defaultRef: null,
|
|
repoName: null,
|
|
localFolder: null,
|
|
managedFolder: `/tmp/${declaration.projectKey}`,
|
|
effectiveLocalFolder: `/tmp/${declaration.projectKey}`,
|
|
origin: "managed_checkout",
|
|
},
|
|
workspaces: [],
|
|
primaryWorkspace: null,
|
|
managedByPlugin: {
|
|
id: `managed-${projects.size + 1}`,
|
|
pluginId: manifest.id,
|
|
pluginKey: manifest.id,
|
|
pluginDisplayName: manifest.displayName,
|
|
resourceKind: "project",
|
|
resourceKey: projectKey,
|
|
defaultsJson: { displayName: declaration.displayName, settings: declaration.settings ?? {} },
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
archivedAt: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
} as Project;
|
|
projects.set(project.id, project);
|
|
const externalKey = `managed_resource|company|${companyId}|${externalId}`;
|
|
const nowIso = now.toISOString();
|
|
const record: PluginEntityRecord = {
|
|
id: randomUUID(),
|
|
entityType: "managed_resource",
|
|
scopeKind: "company",
|
|
scopeId: companyId,
|
|
externalId,
|
|
title: declaration.displayName,
|
|
status: null,
|
|
data: { resourceKind: "project", resourceKey: projectKey, projectId: project.id },
|
|
createdAt: nowIso,
|
|
updatedAt: nowIso,
|
|
};
|
|
entities.set(record.id, record);
|
|
entityExternalIndex.set(externalKey, record.id);
|
|
return {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "project",
|
|
resourceKey: projectKey,
|
|
companyId,
|
|
projectId: project.id,
|
|
project,
|
|
status: "created",
|
|
};
|
|
},
|
|
async reconcile(projectKey, companyId) {
|
|
return this.get(projectKey, companyId);
|
|
},
|
|
async reset(projectKey, companyId) {
|
|
const resolved = await this.get(projectKey, companyId);
|
|
return { ...resolved, status: resolved.project ? "reset" : resolved.status };
|
|
},
|
|
},
|
|
},
|
|
executionWorkspaces: {
|
|
async get(workspaceId, companyId) {
|
|
requireCapability(manifest, capabilitySet, "execution.workspaces.read");
|
|
const workspace = executionWorkspaces.get(workspaceId);
|
|
return workspace?.companyId === companyId ? workspace : null;
|
|
},
|
|
},
|
|
routines: {
|
|
managed: {
|
|
async get(routineKey, companyId) {
|
|
requireCapability(manifest, capabilitySet, "routines.managed");
|
|
const declaration = manifest.routines?.find((routine) => routine.routineKey === routineKey);
|
|
if (!declaration) {
|
|
return {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "routine",
|
|
resourceKey: routineKey,
|
|
companyId,
|
|
routineId: null,
|
|
routine: null,
|
|
status: "missing",
|
|
missingRefs: [],
|
|
} satisfies PluginManagedRoutineResolution;
|
|
}
|
|
const externalId = `${manifest.id}:routine:${routineKey}`;
|
|
const existingEntity = [...entities.values()].find((entity) =>
|
|
entity.entityType === "managed_resource"
|
|
&& entity.scopeKind === "company"
|
|
&& entity.scopeId === companyId
|
|
&& entity.externalId === externalId
|
|
);
|
|
const existingRoutine = existingEntity ? routines.get(String(existingEntity.data?.routineId ?? "")) : null;
|
|
if (existingRoutine && isInCompany(existingRoutine, companyId)) {
|
|
return {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "routine",
|
|
resourceKey: routineKey,
|
|
companyId,
|
|
routineId: existingRoutine.id,
|
|
routine: existingRoutine,
|
|
status: "resolved",
|
|
missingRefs: [],
|
|
} satisfies PluginManagedRoutineResolution;
|
|
}
|
|
return {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "routine",
|
|
resourceKey: routineKey,
|
|
companyId,
|
|
routineId: null,
|
|
routine: null,
|
|
status: "missing",
|
|
missingRefs: [],
|
|
} satisfies PluginManagedRoutineResolution;
|
|
},
|
|
async reconcile(routineKey, companyId, overrides) {
|
|
const existing = await this.get(routineKey, companyId);
|
|
if (existing.routine) return existing;
|
|
const declaration = manifest.routines?.find((routine) => routine.routineKey === routineKey);
|
|
if (!declaration) return existing;
|
|
const now = new Date();
|
|
const agentRef = declaration.assigneeRef;
|
|
const projectRef = declaration.projectRef;
|
|
const assigneeAgentId = overrides?.assigneeAgentId
|
|
?? (agentRef?.resourceKind === "agent"
|
|
? [...agents.values()].find((agent) => isInCompany(agent, companyId) && isManagedAgent(agent, agentRef.resourceKey))?.id
|
|
: null)
|
|
?? null;
|
|
const projectId = overrides?.projectId
|
|
?? (projectRef?.resourceKind === "project"
|
|
? [...projects.values()].find((project) => (
|
|
isInCompany(project, companyId)
|
|
&& project.managedByPlugin?.pluginKey === manifest.id
|
|
&& project.managedByPlugin?.resourceKey === projectRef.resourceKey
|
|
))?.id
|
|
: null)
|
|
?? null;
|
|
const missingRefs: NonNullable<PluginManagedRoutineResolution["missingRefs"]> = [];
|
|
if (agentRef && !assigneeAgentId) missingRefs.push({ ...agentRef, pluginKey: manifest.id });
|
|
if (projectRef && !projectId) missingRefs.push({ ...projectRef, pluginKey: manifest.id });
|
|
if (missingRefs.length > 0) {
|
|
return {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "routine",
|
|
resourceKey: routineKey,
|
|
companyId,
|
|
routineId: null,
|
|
routine: null,
|
|
status: "missing_refs",
|
|
missingRefs,
|
|
} satisfies PluginManagedRoutineResolution;
|
|
}
|
|
const routine = {
|
|
id: `routine-${routines.size + 1}`,
|
|
companyId,
|
|
projectId,
|
|
goalId: declaration.goalId ?? null,
|
|
parentIssueId: null,
|
|
title: declaration.title,
|
|
description: declaration.description ?? null,
|
|
assigneeAgentId,
|
|
priority: declaration.priority ?? "medium",
|
|
status: declaration.status ?? (assigneeAgentId ? "active" : "paused"),
|
|
concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active",
|
|
catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed",
|
|
variables: declaration.variables ?? [],
|
|
latestRevisionId: null,
|
|
latestRevisionNumber: 1,
|
|
createdByAgentId: null,
|
|
createdByUserId: null,
|
|
updatedByAgentId: null,
|
|
updatedByUserId: null,
|
|
lastTriggeredAt: null,
|
|
lastEnqueuedAt: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
managedByPlugin: {
|
|
id: `managed-routine-${routines.size + 1}`,
|
|
pluginId: manifest.id,
|
|
pluginKey: manifest.id,
|
|
pluginDisplayName: manifest.displayName,
|
|
resourceKind: "routine",
|
|
resourceKey: routineKey,
|
|
defaultsJson: { title: declaration.title, issueTemplate: declaration.issueTemplate ?? null },
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
} as Routine;
|
|
routines.set(routine.id, routine);
|
|
const nowIso = now.toISOString();
|
|
const record: PluginEntityRecord = {
|
|
id: randomUUID(),
|
|
entityType: "managed_resource",
|
|
scopeKind: "company",
|
|
scopeId: companyId,
|
|
externalId: `${manifest.id}:routine:${routineKey}`,
|
|
title: declaration.title,
|
|
status: null,
|
|
data: { resourceKind: "routine", resourceKey: routineKey, routineId: routine.id },
|
|
createdAt: nowIso,
|
|
updatedAt: nowIso,
|
|
};
|
|
entities.set(record.id, record);
|
|
return {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "routine",
|
|
resourceKey: routineKey,
|
|
companyId,
|
|
routineId: routine.id,
|
|
routine,
|
|
status: "created",
|
|
missingRefs: [],
|
|
} satisfies PluginManagedRoutineResolution;
|
|
},
|
|
async reset(routineKey, companyId, overrides) {
|
|
const resolved = await this.reconcile(routineKey, companyId, overrides);
|
|
return { ...resolved, status: resolved.routine ? "reset" : resolved.status } satisfies PluginManagedRoutineResolution;
|
|
},
|
|
async update(routineKey, companyId, patch) {
|
|
const resolved = await this.get(routineKey, companyId);
|
|
if (!resolved.routine) throw new Error(`Managed routine not found: ${routineKey}`);
|
|
const next = {
|
|
...resolved.routine,
|
|
...(patch.status !== undefined ? { status: patch.status } : {}),
|
|
updatedAt: new Date(),
|
|
};
|
|
routines.set(next.id, next);
|
|
return next;
|
|
},
|
|
async run(routineKey, companyId) {
|
|
const resolved = await this.get(routineKey, companyId);
|
|
if (!resolved.routine) throw new Error(`Managed routine not found: ${routineKey}`);
|
|
const now = new Date();
|
|
const run = {
|
|
id: `routine-run-${routineRuns.size + 1}`,
|
|
companyId,
|
|
routineId: resolved.routine.id,
|
|
triggerId: null,
|
|
source: "manual",
|
|
status: "queued",
|
|
triggeredAt: now,
|
|
idempotencyKey: null,
|
|
triggerPayload: null,
|
|
dispatchFingerprint: null,
|
|
linkedIssueId: null,
|
|
coalescedIntoRunId: null,
|
|
failureReason: null,
|
|
completedAt: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
} satisfies RoutineRun;
|
|
routineRuns.set(run.id, run);
|
|
routines.set(resolved.routine.id, {
|
|
...resolved.routine,
|
|
lastTriggeredAt: now,
|
|
lastEnqueuedAt: now,
|
|
updatedAt: now,
|
|
});
|
|
return run;
|
|
},
|
|
},
|
|
},
|
|
skills: {
|
|
managed: {
|
|
async get(skillKey, companyId) {
|
|
requireCapability(manifest, capabilitySet, "skills.managed");
|
|
const declaration = manifest.skills?.find((skill) => skill.skillKey === skillKey);
|
|
if (!declaration) {
|
|
return {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "skill",
|
|
resourceKey: skillKey,
|
|
companyId,
|
|
skillId: null,
|
|
skill: null,
|
|
status: "missing",
|
|
defaultDrift: null,
|
|
} satisfies PluginManagedSkillResolution;
|
|
}
|
|
const externalId = `${manifest.id}:skill:${skillKey}`;
|
|
const existingEntity = [...entities.values()].find((entity) =>
|
|
entity.entityType === "managed_resource"
|
|
&& entity.scopeKind === "company"
|
|
&& entity.scopeId === companyId
|
|
&& entity.externalId === externalId
|
|
);
|
|
const existingSkill = existingEntity?.data?.skill as CompanySkill | undefined;
|
|
if (existingSkill && existingSkill.companyId === companyId) {
|
|
return {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "skill",
|
|
resourceKey: skillKey,
|
|
companyId,
|
|
skillId: existingSkill.id,
|
|
skill: existingSkill,
|
|
status: "resolved",
|
|
defaultDrift: null,
|
|
} satisfies PluginManagedSkillResolution;
|
|
}
|
|
return {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "skill",
|
|
resourceKey: skillKey,
|
|
companyId,
|
|
skillId: null,
|
|
skill: null,
|
|
status: "missing",
|
|
defaultDrift: null,
|
|
} satisfies PluginManagedSkillResolution;
|
|
},
|
|
async reconcile(skillKey, companyId) {
|
|
const existing = await this.get(skillKey, companyId);
|
|
if (existing.skill) return existing;
|
|
const declaration = manifest.skills?.find((skill) => skill.skillKey === skillKey);
|
|
if (!declaration) return existing;
|
|
const now = new Date();
|
|
const skill = {
|
|
id: randomUUID(),
|
|
companyId,
|
|
key: `plugin/${manifest.id.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}/${skillKey}`,
|
|
slug: declaration.slug ?? skillKey,
|
|
name: declaration.displayName,
|
|
description: declaration.description ?? null,
|
|
markdown: declaration.markdown ?? `# ${declaration.displayName}\n`,
|
|
sourceType: "catalog",
|
|
sourceLocator: null,
|
|
sourceRef: null,
|
|
trustLevel: "markdown_only",
|
|
compatibility: "compatible",
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
|
metadata: {
|
|
sourceKind: "catalog",
|
|
pluginManagedResource: {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "skill",
|
|
resourceKey: skillKey,
|
|
},
|
|
},
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
} satisfies CompanySkill;
|
|
const nowIso = now.toISOString();
|
|
const record: PluginEntityRecord = {
|
|
id: randomUUID(),
|
|
entityType: "managed_resource",
|
|
scopeKind: "company",
|
|
scopeId: companyId,
|
|
externalId: `${manifest.id}:skill:${skillKey}`,
|
|
title: declaration.displayName,
|
|
status: null,
|
|
data: { resourceKind: "skill", resourceKey: skillKey, skillId: skill.id, skill },
|
|
createdAt: nowIso,
|
|
updatedAt: nowIso,
|
|
};
|
|
entities.set(record.id, record);
|
|
return {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "skill",
|
|
resourceKey: skillKey,
|
|
companyId,
|
|
skillId: skill.id,
|
|
skill,
|
|
status: "created",
|
|
defaultDrift: null,
|
|
} satisfies PluginManagedSkillResolution;
|
|
},
|
|
async reset(skillKey, companyId) {
|
|
requireCapability(manifest, capabilitySet, "skills.managed");
|
|
const existing = await this.get(skillKey, companyId);
|
|
const declaration = manifest.skills?.find((skill) => skill.skillKey === skillKey);
|
|
if (!declaration) return existing;
|
|
const now = new Date();
|
|
const skill = {
|
|
id: existing.skill?.id ?? randomUUID(),
|
|
companyId,
|
|
key: `plugin/${manifest.id.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}/${skillKey}`,
|
|
slug: declaration.slug ?? skillKey,
|
|
name: declaration.displayName,
|
|
description: declaration.description ?? null,
|
|
markdown: declaration.markdown ?? `# ${declaration.displayName}\n`,
|
|
sourceType: "catalog",
|
|
sourceLocator: null,
|
|
sourceRef: null,
|
|
trustLevel: "markdown_only",
|
|
compatibility: "compatible",
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
|
metadata: {
|
|
sourceKind: "catalog",
|
|
pluginManagedResource: {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "skill",
|
|
resourceKey: skillKey,
|
|
},
|
|
},
|
|
createdAt: existing.skill?.createdAt ?? now,
|
|
updatedAt: now,
|
|
} satisfies CompanySkill;
|
|
const nowIso = now.toISOString();
|
|
const existingEntity = [...entities.values()].find((entity) =>
|
|
entity.entityType === "managed_resource" &&
|
|
entity.scopeKind === "company" &&
|
|
entity.scopeId === companyId &&
|
|
entity.externalId === `${manifest.id}:skill:${skillKey}`,
|
|
);
|
|
const record: PluginEntityRecord = {
|
|
id: existingEntity?.id ?? randomUUID(),
|
|
entityType: "managed_resource",
|
|
scopeKind: "company",
|
|
scopeId: companyId,
|
|
externalId: `${manifest.id}:skill:${skillKey}`,
|
|
title: declaration.displayName,
|
|
status: null,
|
|
data: { resourceKind: "skill", resourceKey: skillKey, skillId: skill.id, skill },
|
|
createdAt: existingEntity?.createdAt ?? nowIso,
|
|
updatedAt: nowIso,
|
|
};
|
|
entities.set(record.id, record);
|
|
return {
|
|
pluginKey: manifest.id,
|
|
resourceKind: "skill",
|
|
resourceKey: skillKey,
|
|
companyId,
|
|
skillId: skill.id,
|
|
skill,
|
|
status: "reset",
|
|
defaultDrift: null,
|
|
} satisfies PluginManagedSkillResolution;
|
|
},
|
|
},
|
|
},
|
|
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?.originKind) {
|
|
if (input.originKind.startsWith("plugin:")) normalizePluginOriginKind(input.originKind);
|
|
out = out.filter((issue) => issue.originKind === input.originKind);
|
|
}
|
|
if (input?.originKindPrefix) {
|
|
const prefix = input.originKindPrefix;
|
|
out = out.filter((issue) =>
|
|
typeof issue.originKind === "string" && issue.originKind.startsWith(prefix),
|
|
);
|
|
}
|
|
if (input?.originId) out = out.filter((issue) => issue.originId === input.originId);
|
|
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 originKind = normalizePluginOriginKind(
|
|
input.surfaceVisibility === "plugin_operation" && !input.originKind
|
|
? pluginOperationIssueOriginKind(manifest.id)
|
|
: input.originKind,
|
|
);
|
|
const record: Issue = {
|
|
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: input.status ?? "todo",
|
|
workMode: "standard",
|
|
priority: input.priority ?? "medium",
|
|
assigneeAgentId: input.assigneeAgentId ?? null,
|
|
assigneeUserId: input.assigneeUserId ?? null,
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
createdByAgentId: null,
|
|
createdByUserId: null,
|
|
issueNumber: null,
|
|
identifier: null,
|
|
originKind,
|
|
originId: input.originId ?? null,
|
|
originRunId: input.originRunId ?? null,
|
|
requestDepth: input.requestDepth ?? 0,
|
|
billingCode: input.billingCode ?? null,
|
|
assigneeAdapterOverrides: input.assigneeAdapterOverrides ?? null,
|
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
|
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
|
|
executionWorkspaceSettings: input.executionWorkspaceSettings ?? null,
|
|
startedAt: null,
|
|
completedAt: null,
|
|
cancelledAt: null,
|
|
hiddenAt: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
issues.set(record.id, record);
|
|
if (input.blockedByIssueIds) blockedByIssueIds.set(record.id, [...new Set(input.blockedByIssueIds)]);
|
|
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 { blockedByIssueIds: nextBlockedByIssueIds, ...issuePatch } = patch;
|
|
if (issuePatch.originKind !== undefined) {
|
|
issuePatch.originKind = normalizePluginOriginKind(issuePatch.originKind);
|
|
}
|
|
const updated: Issue = {
|
|
...record,
|
|
...issuePatch,
|
|
updatedAt: new Date(),
|
|
};
|
|
issues.set(issueId, updated);
|
|
if (nextBlockedByIssueIds !== undefined) {
|
|
blockedByIssueIds.set(issueId, [...new Set(nextBlockedByIssueIds)]);
|
|
}
|
|
return updated;
|
|
},
|
|
async assertCheckoutOwner(input) {
|
|
requireCapability(manifest, capabilitySet, "issues.checkout");
|
|
const record = issues.get(input.issueId);
|
|
if (!isInCompany(record, input.companyId)) throw new Error(`Issue not found: ${input.issueId}`);
|
|
if (
|
|
record.status !== "in_progress" ||
|
|
record.assigneeAgentId !== input.actorAgentId ||
|
|
(record.checkoutRunId !== null && record.checkoutRunId !== input.actorRunId)
|
|
) {
|
|
throw new Error("Issue run ownership conflict");
|
|
}
|
|
return {
|
|
issueId: record.id,
|
|
status: record.status,
|
|
assigneeAgentId: record.assigneeAgentId,
|
|
checkoutRunId: record.checkoutRunId,
|
|
adoptedFromRunId: null,
|
|
};
|
|
},
|
|
async requestWakeup(issueId, companyId) {
|
|
requireCapability(manifest, capabilitySet, "issues.wakeup");
|
|
const record = issues.get(issueId);
|
|
if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`);
|
|
if (!record.assigneeAgentId) throw new Error("Issue has no assigned agent to wake");
|
|
if (["backlog", "done", "cancelled"].includes(record.status)) {
|
|
throw new Error(`Issue is not wakeable in status: ${record.status}`);
|
|
}
|
|
const unresolved = issueRelationSummary(issueId).blockedBy.filter((blocker) => blocker.status !== "done");
|
|
if (unresolved.length > 0) throw new Error("Issue is blocked by unresolved blockers");
|
|
return { queued: true, runId: randomUUID() };
|
|
},
|
|
async requestWakeups(issueIds, companyId) {
|
|
requireCapability(manifest, capabilitySet, "issues.wakeup");
|
|
const results = [];
|
|
for (const issueId of issueIds) {
|
|
const record = issues.get(issueId);
|
|
if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`);
|
|
if (!record.assigneeAgentId) throw new Error("Issue has no assigned agent to wake");
|
|
if (["backlog", "done", "cancelled"].includes(record.status)) {
|
|
throw new Error(`Issue is not wakeable in status: ${record.status}`);
|
|
}
|
|
const unresolved = issueRelationSummary(issueId).blockedBy.filter((blocker) => blocker.status !== "done");
|
|
if (unresolved.length > 0) throw new Error("Issue is blocked by unresolved blockers");
|
|
results.push({ issueId, queued: true, runId: randomUUID() });
|
|
}
|
|
return results;
|
|
},
|
|
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, options) {
|
|
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: IssueComment = {
|
|
id: randomUUID(),
|
|
companyId: parentIssue.companyId,
|
|
issueId,
|
|
authorType: options?.authorAgentId ? "agent" : "system",
|
|
authorAgentId: options?.authorAgentId ?? null,
|
|
authorUserId: null,
|
|
body,
|
|
presentation: null,
|
|
metadata: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
const current = issueComments.get(issueId) ?? [];
|
|
current.push(comment);
|
|
issueComments.set(issueId, current);
|
|
return comment;
|
|
},
|
|
async createInteraction(issueId, interaction, companyId, options) {
|
|
requireCapability(manifest, capabilitySet, "issue.interactions.create");
|
|
const parentIssue = issues.get(issueId);
|
|
if (!isInCompany(parentIssue, companyId)) {
|
|
throw new Error(`Issue not found: ${issueId}`);
|
|
}
|
|
const now = new Date();
|
|
const current = issueInteractions.get(issueId) ?? [];
|
|
if (interaction.idempotencyKey) {
|
|
const existing = current.find((entry) => entry.idempotencyKey === interaction.idempotencyKey);
|
|
if (existing) return existing;
|
|
}
|
|
const created: IssueThreadInteraction = {
|
|
id: randomUUID(),
|
|
companyId: parentIssue.companyId,
|
|
issueId,
|
|
kind: interaction.kind,
|
|
status: "pending",
|
|
continuationPolicy: interaction.continuationPolicy ?? "wake_assignee",
|
|
idempotencyKey: interaction.idempotencyKey ?? null,
|
|
sourceCommentId: interaction.sourceCommentId ?? null,
|
|
sourceRunId: interaction.sourceRunId ?? null,
|
|
title: interaction.title ?? null,
|
|
summary: interaction.summary ?? null,
|
|
createdByAgentId: options?.authorAgentId ?? null,
|
|
createdByUserId: null,
|
|
payload: interaction.payload,
|
|
result: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
} as IssueThreadInteraction;
|
|
current.push(created);
|
|
issueInteractions.set(issueId, current);
|
|
return created;
|
|
},
|
|
async suggestTasks(issueId, interaction, companyId, options) {
|
|
return this.createInteraction(issueId, { ...interaction, kind: "suggest_tasks" }, companyId, options) as Promise<any>;
|
|
},
|
|
async askUserQuestions(issueId, interaction, companyId, options) {
|
|
return this.createInteraction(issueId, { ...interaction, kind: "ask_user_questions" }, companyId, options) as Promise<any>;
|
|
},
|
|
async requestConfirmation(issueId, interaction, companyId, options) {
|
|
return this.createInteraction(issueId, { ...interaction, kind: "request_confirmation" }, companyId, options) as Promise<any>;
|
|
},
|
|
documents: {
|
|
async list(issueId, companyId) {
|
|
requireCapability(manifest, capabilitySet, "issue.documents.read");
|
|
if (!isInCompany(issues.get(issueId), companyId)) return [];
|
|
return [...issueDocuments.values()]
|
|
.filter((document) => document.issueId === issueId && document.companyId === companyId)
|
|
.map(({ body: _body, ...summary }) => summary);
|
|
},
|
|
async get(issueId, key, companyId) {
|
|
requireCapability(manifest, capabilitySet, "issue.documents.read");
|
|
if (!isInCompany(issues.get(issueId), companyId)) return null;
|
|
return issueDocuments.get(`${issueId}|${key}`) ?? 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}`);
|
|
}
|
|
const now = new Date();
|
|
const existing = issueDocuments.get(`${input.issueId}|${input.key}`);
|
|
const document: IssueDocument = {
|
|
id: existing?.id ?? randomUUID(),
|
|
companyId: input.companyId,
|
|
issueId: input.issueId,
|
|
key: input.key,
|
|
title: input.title ?? existing?.title ?? null,
|
|
format: "markdown",
|
|
latestRevisionId: randomUUID(),
|
|
latestRevisionNumber: (existing?.latestRevisionNumber ?? 0) + 1,
|
|
createdByAgentId: existing?.createdByAgentId ?? null,
|
|
createdByUserId: existing?.createdByUserId ?? null,
|
|
updatedByAgentId: null,
|
|
updatedByUserId: null,
|
|
lockedAt: existing?.lockedAt ?? null,
|
|
lockedByAgentId: existing?.lockedByAgentId ?? null,
|
|
lockedByUserId: existing?.lockedByUserId ?? null,
|
|
createdAt: existing?.createdAt ?? now,
|
|
updatedAt: now,
|
|
body: input.body,
|
|
};
|
|
issueDocuments.set(`${input.issueId}|${input.key}`, document);
|
|
return document;
|
|
},
|
|
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}`);
|
|
}
|
|
issueDocuments.delete(`${issueId}|${_key}`);
|
|
},
|
|
},
|
|
relations: {
|
|
async get(issueId, companyId) {
|
|
requireCapability(manifest, capabilitySet, "issue.relations.read");
|
|
if (!isInCompany(issues.get(issueId), companyId)) throw new Error(`Issue not found: ${issueId}`);
|
|
return issueRelationSummary(issueId);
|
|
},
|
|
async setBlockedBy(issueId, nextBlockedByIssueIds, companyId) {
|
|
requireCapability(manifest, capabilitySet, "issue.relations.write");
|
|
if (!isInCompany(issues.get(issueId), companyId)) throw new Error(`Issue not found: ${issueId}`);
|
|
blockedByIssueIds.set(issueId, [...new Set(nextBlockedByIssueIds)]);
|
|
return issueRelationSummary(issueId);
|
|
},
|
|
async addBlockers(issueId, blockerIssueIds, companyId) {
|
|
requireCapability(manifest, capabilitySet, "issue.relations.write");
|
|
if (!isInCompany(issues.get(issueId), companyId)) throw new Error(`Issue not found: ${issueId}`);
|
|
const next = new Set(blockedByIssueIds.get(issueId) ?? []);
|
|
for (const blockerIssueId of blockerIssueIds) next.add(blockerIssueId);
|
|
blockedByIssueIds.set(issueId, [...next]);
|
|
return issueRelationSummary(issueId);
|
|
},
|
|
async removeBlockers(issueId, blockerIssueIds, companyId) {
|
|
requireCapability(manifest, capabilitySet, "issue.relations.write");
|
|
if (!isInCompany(issues.get(issueId), companyId)) throw new Error(`Issue not found: ${issueId}`);
|
|
const removals = new Set(blockerIssueIds);
|
|
blockedByIssueIds.set(
|
|
issueId,
|
|
(blockedByIssueIds.get(issueId) ?? []).filter((blockerIssueId) => !removals.has(blockerIssueId)),
|
|
);
|
|
return issueRelationSummary(issueId);
|
|
},
|
|
},
|
|
async getSubtree(issueId, companyId, options) {
|
|
requireCapability(manifest, capabilitySet, "issue.subtree.read");
|
|
const root = issues.get(issueId);
|
|
if (!isInCompany(root, companyId)) throw new Error(`Issue not found: ${issueId}`);
|
|
const includeRoot = options?.includeRoot !== false;
|
|
const allIds = [root.id];
|
|
let frontier = [root.id];
|
|
while (frontier.length > 0) {
|
|
const children = [...issues.values()]
|
|
.filter((issue) => issue.companyId === companyId && frontier.includes(issue.parentId ?? ""))
|
|
.map((issue) => issue.id)
|
|
.filter((id) => !allIds.includes(id));
|
|
allIds.push(...children);
|
|
frontier = children;
|
|
}
|
|
const issueIds = includeRoot ? allIds : allIds.filter((id) => id !== root.id);
|
|
const subtreeIssues = issueIds.map((id) => issues.get(id)).filter((candidate): candidate is Issue => Boolean(candidate));
|
|
return {
|
|
rootIssueId: root.id,
|
|
companyId,
|
|
issueIds,
|
|
issues: subtreeIssues,
|
|
...(options?.includeRelations
|
|
? { relations: Object.fromEntries(issueIds.map((id) => [id, issueRelationSummary(id)])) }
|
|
: {}),
|
|
...(options?.includeDocuments ? { documents: Object.fromEntries(issueIds.map((id) => [id, []])) } : {}),
|
|
...(options?.includeActiveRuns ? { activeRuns: Object.fromEntries(issueIds.map((id) => [id, []])) } : {}),
|
|
...(options?.includeAssignees ? { assignees: {} } : {}),
|
|
};
|
|
},
|
|
summaries: {
|
|
async getOrchestration(input) {
|
|
requireCapability(manifest, capabilitySet, "issues.orchestration.read");
|
|
const root = issues.get(input.issueId);
|
|
if (!isInCompany(root, input.companyId)) throw new Error(`Issue not found: ${input.issueId}`);
|
|
const subtreeIssueIds = [root.id];
|
|
if (input.includeSubtree) {
|
|
let frontier = [root.id];
|
|
while (frontier.length > 0) {
|
|
const children = [...issues.values()]
|
|
.filter((issue) => issue.companyId === input.companyId && frontier.includes(issue.parentId ?? ""))
|
|
.map((issue) => issue.id)
|
|
.filter((id) => !subtreeIssueIds.includes(id));
|
|
subtreeIssueIds.push(...children);
|
|
frontier = children;
|
|
}
|
|
}
|
|
return {
|
|
issueId: root.id,
|
|
companyId: input.companyId,
|
|
subtreeIssueIds,
|
|
relations: Object.fromEntries(subtreeIssueIds.map((id) => [id, issueRelationSummary(id)])),
|
|
approvals: [],
|
|
runs: [],
|
|
costs: {
|
|
costCents: 0,
|
|
inputTokens: 0,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 0,
|
|
billingCode: input.billingCode ?? null,
|
|
},
|
|
openBudgetIncidents: [],
|
|
invocationBlocks: [],
|
|
};
|
|
},
|
|
},
|
|
},
|
|
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 = { ...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 = { ...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() };
|
|
},
|
|
managed: {
|
|
async get(agentKey, companyId) {
|
|
requireCapability(manifest, capabilitySet, "agents.managed");
|
|
const cid = requireCompanyId(companyId);
|
|
managedAgentDeclaration(agentKey);
|
|
const agent = [...agents.values()].find((candidate) =>
|
|
candidate.companyId === cid &&
|
|
candidate.status !== "terminated" &&
|
|
isManagedAgent(candidate, agentKey),
|
|
) ?? null;
|
|
return managedResolution(agentKey, cid, agent, agent ? "resolved" : "missing");
|
|
},
|
|
async reconcile(agentKey, companyId) {
|
|
requireCapability(manifest, capabilitySet, "agents.managed");
|
|
const cid = requireCompanyId(companyId);
|
|
const declaration = managedAgentDeclaration(agentKey);
|
|
const existingAgent = [...agents.values()].find((candidate) =>
|
|
candidate.companyId === cid &&
|
|
candidate.status !== "terminated" &&
|
|
isManagedAgent(candidate, agentKey),
|
|
) ?? null;
|
|
const existing = managedResolution(agentKey, cid, existingAgent, existingAgent ? "resolved" : "missing");
|
|
if (existing.agent) return existing;
|
|
const now = new Date();
|
|
const created: Agent = {
|
|
id: randomUUID(),
|
|
companyId: cid,
|
|
name: declaration.displayName,
|
|
urlKey: declaration.displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
|
|
role: (declaration.role ?? "general") as Agent["role"],
|
|
title: declaration.title ?? null,
|
|
icon: declaration.icon ?? null,
|
|
status: declaration.status ?? "idle",
|
|
reportsTo: null,
|
|
capabilities: declaration.capabilities ?? null,
|
|
adapterType: (declaration.adapterType ?? "process") as Agent["adapterType"],
|
|
adapterConfig: declaration.adapterConfig ?? {},
|
|
runtimeConfig: declaration.runtimeConfig ?? {},
|
|
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
|
|
spentMonthlyCents: 0,
|
|
pauseReason: null,
|
|
pausedAt: null,
|
|
permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) },
|
|
lastHeartbeatAt: null,
|
|
metadata: managedAgentMetadata(agentKey),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
agents.set(created.id, created);
|
|
return managedResolution(agentKey, cid, created, "created");
|
|
},
|
|
async reset(agentKey, companyId) {
|
|
requireCapability(manifest, capabilitySet, "agents.managed");
|
|
const cid = requireCompanyId(companyId);
|
|
const declaration = managedAgentDeclaration(agentKey);
|
|
let agent = [...agents.values()].find((candidate) =>
|
|
candidate.companyId === cid &&
|
|
candidate.status !== "terminated" &&
|
|
isManagedAgent(candidate, agentKey),
|
|
) ?? null;
|
|
if (!agent) {
|
|
const now = new Date();
|
|
agent = {
|
|
id: randomUUID(),
|
|
companyId: cid,
|
|
name: declaration.displayName,
|
|
urlKey: declaration.displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
|
|
role: (declaration.role ?? "general") as Agent["role"],
|
|
title: declaration.title ?? null,
|
|
icon: declaration.icon ?? null,
|
|
status: declaration.status ?? "idle",
|
|
reportsTo: null,
|
|
capabilities: declaration.capabilities ?? null,
|
|
adapterType: (declaration.adapterType ?? "process") as Agent["adapterType"],
|
|
adapterConfig: declaration.adapterConfig ?? {},
|
|
runtimeConfig: declaration.runtimeConfig ?? {},
|
|
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
|
|
spentMonthlyCents: 0,
|
|
pauseReason: null,
|
|
pausedAt: null,
|
|
permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) },
|
|
lastHeartbeatAt: null,
|
|
metadata: managedAgentMetadata(agentKey),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
agents.set(agent.id, agent);
|
|
}
|
|
const resolved = managedResolution(agentKey, cid, agent, "resolved");
|
|
if (!resolved.agent) return resolved;
|
|
const updated: Agent = {
|
|
...resolved.agent,
|
|
name: declaration.displayName,
|
|
role: (declaration.role ?? "general") as Agent["role"],
|
|
title: declaration.title ?? null,
|
|
icon: declaration.icon ?? null,
|
|
capabilities: declaration.capabilities ?? null,
|
|
adapterType: (declaration.adapterType ?? "process") as Agent["adapterType"],
|
|
adapterConfig: declaration.adapterConfig ?? {},
|
|
runtimeConfig: declaration.runtimeConfig ?? {},
|
|
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
|
|
permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) },
|
|
metadata: managedAgentMetadata(agentKey, resolved.agent.metadata),
|
|
updatedAt: new Date(),
|
|
};
|
|
agents.set(updated.id, updated);
|
|
return managedResolution(agentKey, cid, updated, "reset");
|
|
},
|
|
},
|
|
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: AgentSession = {
|
|
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: Goal = {
|
|
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: Goal = {
|
|
...record,
|
|
...patch,
|
|
updatedAt: new Date(),
|
|
};
|
|
goals.set(goalId, updated);
|
|
return updated;
|
|
},
|
|
},
|
|
access: {
|
|
members: {
|
|
async list(input) {
|
|
requireCapability(manifest, capabilitySet, "access.members.read");
|
|
const cid = requireCompanyId(input.companyId);
|
|
const includeArchived = input.includeArchived === true;
|
|
return [...accessMembers.values()]
|
|
.filter((member) => member.companyId === cid)
|
|
.filter((member) => includeArchived || member.status !== ("archived" as PluginAccessMember["status"]))
|
|
.map((member) => ({
|
|
...member,
|
|
grants: getPrincipalGrants(cid, member.principalType, member.principalId),
|
|
}));
|
|
},
|
|
async get(memberId, companyId) {
|
|
requireCapability(manifest, capabilitySet, "access.members.read");
|
|
const cid = requireCompanyId(companyId);
|
|
const member = accessMembers.get(memberId);
|
|
if (!member || member.companyId !== cid) return null;
|
|
return {
|
|
...member,
|
|
grants: getPrincipalGrants(cid, member.principalType, member.principalId),
|
|
};
|
|
},
|
|
async update(memberId, patch, companyId) {
|
|
requireCapability(manifest, capabilitySet, "access.members.write");
|
|
const cid = requireCompanyId(companyId);
|
|
const member = accessMembers.get(memberId);
|
|
if (!member || member.companyId !== cid) {
|
|
throw new Error(`Membership not found: ${memberId}`);
|
|
}
|
|
const updated: PluginAccessMember = {
|
|
...member,
|
|
membershipRole: patch.membershipRole === undefined ? member.membershipRole : patch.membershipRole,
|
|
status: patch.status === undefined ? member.status : patch.status,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
accessMembers.set(memberId, updated);
|
|
return {
|
|
...updated,
|
|
grants: getPrincipalGrants(cid, updated.principalType, updated.principalId),
|
|
};
|
|
},
|
|
},
|
|
invites: {
|
|
async list(input) {
|
|
requireCapability(manifest, capabilitySet, "access.invites.read");
|
|
requireCompanyId(input.companyId);
|
|
return { invites: [], nextOffset: null };
|
|
},
|
|
async create(input) {
|
|
requireCapability(manifest, capabilitySet, "access.invites.write");
|
|
requireCompanyId(input.companyId);
|
|
throw new Error("Invite creation is not implemented in the plugin test harness");
|
|
},
|
|
async revoke(inviteId, companyId) {
|
|
requireCapability(manifest, capabilitySet, "access.invites.write");
|
|
requireCompanyId(companyId);
|
|
throw new Error(`Invite not found: ${inviteId}`);
|
|
},
|
|
},
|
|
},
|
|
authorization: {
|
|
grants: {
|
|
async list(input) {
|
|
requireCapability(manifest, capabilitySet, "authorization.grants.read");
|
|
const cid = requireCompanyId(input.companyId);
|
|
if (input.principalType && input.principalId) {
|
|
return getPrincipalGrants(cid, input.principalType, input.principalId);
|
|
}
|
|
const out: PrincipalPermissionGrant[] = [];
|
|
for (const [key, grants] of principalGrants.entries()) {
|
|
if (!key.startsWith(`${cid}:`)) continue;
|
|
for (const grant of grants) {
|
|
if (input.principalType && grant.principalType !== input.principalType) continue;
|
|
if (input.principalId && grant.principalId !== input.principalId) continue;
|
|
out.push(grant);
|
|
}
|
|
}
|
|
return out;
|
|
},
|
|
async set(input) {
|
|
requireCapability(manifest, capabilitySet, "authorization.grants.write");
|
|
const cid = requireCompanyId(input.companyId);
|
|
return setPrincipalGrants(cid, input.principalType, input.principalId, input.grants);
|
|
},
|
|
},
|
|
policies: {
|
|
async summary(companyId) {
|
|
requireCapability(manifest, capabilitySet, "authorization.policies.read");
|
|
const cid = requireCompanyId(companyId);
|
|
const members = [...accessMembers.values()].filter((member) => member.companyId === cid);
|
|
let grantCount = 0;
|
|
for (const [key, grants] of principalGrants.entries()) {
|
|
if (key.startsWith(`${cid}:`)) grantCount += grants.length;
|
|
}
|
|
return {
|
|
companyId: cid,
|
|
permissionsMode: "simple",
|
|
memberCount: members.length,
|
|
activeMemberCount: members.filter((member) => member.status === "active").length,
|
|
grantCount,
|
|
advancedPolicyAvailable: false,
|
|
};
|
|
},
|
|
async get(input) {
|
|
requireCapability(manifest, capabilitySet, "authorization.policies.read");
|
|
requireCompanyId(input.companyId);
|
|
return null;
|
|
},
|
|
async update(input) {
|
|
requireCapability(manifest, capabilitySet, "authorization.policies.write");
|
|
const cid = requireCompanyId(input.companyId);
|
|
return {
|
|
companyId: cid,
|
|
resourceType: input.resourceType,
|
|
resourceId: input.resourceId,
|
|
policy: input.policy,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
},
|
|
async previewAssignment(input) {
|
|
requireCapability(manifest, capabilitySet, "authorization.policies.read");
|
|
requireCompanyId(input.companyId);
|
|
return {
|
|
allowed: true,
|
|
action: "issue.assign",
|
|
explanation: "Allowed by simple company-wide defaults in the plugin test harness.",
|
|
reason: "simple_mode",
|
|
};
|
|
},
|
|
async explainAssignment(input) {
|
|
requireCapability(manifest, capabilitySet, "authorization.policies.read");
|
|
requireCompanyId(input.companyId);
|
|
return {
|
|
allowed: true,
|
|
action: "issue.assign",
|
|
explanation: "Allowed by simple company-wide defaults in the plugin test harness.",
|
|
reason: "simple_mode",
|
|
};
|
|
},
|
|
},
|
|
audit: {
|
|
async search(input) {
|
|
requireCapability(manifest, capabilitySet, "authorization.audit.read");
|
|
requireCompanyId(input.companyId);
|
|
return [];
|
|
},
|
|
},
|
|
},
|
|
data: {
|
|
register(key, handler) {
|
|
dataHandlers.set(key, handler);
|
|
},
|
|
},
|
|
actions: {
|
|
register(key, handler) {
|
|
actionHandlers.set(key, handler);
|
|
},
|
|
},
|
|
streams: (() => {
|
|
const channelCompanyMap = new Map<string, string>();
|
|
return {
|
|
open(channel: string, companyId: string) {
|
|
channelCompanyMap.set(channel, companyId);
|
|
},
|
|
emit(_channel: string, _event: unknown) {
|
|
// No-op in test harness — events are not forwarded
|
|
},
|
|
close(channel: string) {
|
|
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 });
|
|
},
|
|
},
|
|
telemetry: {
|
|
async track(eventName, dimensions) {
|
|
requireCapability(manifest, capabilitySet, "telemetry.track");
|
|
telemetry.push({ eventName, dimensions });
|
|
},
|
|
},
|
|
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: TestHarness = {
|
|
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);
|
|
if (row.blockedBy) {
|
|
blockedByIssueIds.set(row.id, row.blockedBy.map((blocker) => blocker.id));
|
|
}
|
|
}
|
|
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);
|
|
for (const row of input.projectWorkspaces ?? []) {
|
|
const list = projectWorkspaces.get(row.projectId) ?? [];
|
|
list.push(row);
|
|
projectWorkspaces.set(row.projectId, list);
|
|
}
|
|
for (const row of input.executionWorkspaces ?? []) executionWorkspaces.set(row.id, row);
|
|
for (const row of input.accessMembers ?? []) accessMembers.set(row.id, row);
|
|
for (const row of input.principalGrants ?? []) {
|
|
const list = principalGrants.get(principalGrantsKey(row.companyId, row.principalType, row.principalId)) ?? [];
|
|
list.push(row);
|
|
principalGrants.set(principalGrantsKey(row.companyId, row.principalType, row.principalId), list);
|
|
}
|
|
},
|
|
setConfig(config) {
|
|
currentConfig = { ...config };
|
|
},
|
|
async emit(eventType, payload, base) {
|
|
const event: PluginEvent = {
|
|
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<T = unknown>(key: string, params: Record<string, unknown> = {}) {
|
|
const handler = dataHandlers.get(key);
|
|
if (!handler) throw new Error(`No data handler registered for '${key}'`);
|
|
return await handler(params) as T;
|
|
},
|
|
async performAction<T = unknown>(key: string, params: Record<string, unknown> = {}) {
|
|
const handler = actionHandlers.get(key);
|
|
if (!handler) throw new Error(`No action handler registered for '${key}'`);
|
|
return await handler(params) as T;
|
|
},
|
|
async executeTool<T = ToolResult>(name: string, params: unknown, runCtx: Partial<ToolRunContext> = {}) {
|
|
const handler = toolHandlers.get(name);
|
|
if (!handler) throw new Error(`No tool handler registered for '${name}'`);
|
|
const ctxToPass: ToolRunContext = {
|
|
agentId: runCtx.agentId ?? "agent-test",
|
|
runId: runCtx.runId ?? randomUUID(),
|
|
companyId: runCtx.companyId ?? "company-test",
|
|
projectId: runCtx.projectId ?? "project-test",
|
|
};
|
|
return await handler(params, ctxToPass) as T;
|
|
},
|
|
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,
|
|
telemetry,
|
|
dbQueries,
|
|
dbExecutes,
|
|
};
|
|
|
|
return harness;
|
|
}
|
|
|
|
/**
|
|
* Create an environment-aware test harness that wraps the base harness with
|
|
* environment driver simulation and lifecycle event recording.
|
|
*
|
|
* Use this to test environment plugins through the full host contract:
|
|
* validateConfig → probe → acquireLease → realizeWorkspace → execute → releaseLease.
|
|
*/
|
|
export function createEnvironmentTestHarness(options: EnvironmentTestHarnessOptions): EnvironmentTestHarness {
|
|
const base = createTestHarness(options);
|
|
const environmentEvents: EnvironmentEventRecord[] = [];
|
|
const driver = options.environmentDriver;
|
|
|
|
function record(
|
|
type: EnvironmentEventRecord["type"],
|
|
params: Record<string, unknown>,
|
|
result?: unknown,
|
|
error?: string,
|
|
): EnvironmentEventRecord {
|
|
const event: EnvironmentEventRecord = {
|
|
type,
|
|
driverKey: (params as { driverKey?: string }).driverKey ?? driver.driverKey,
|
|
environmentId: (params as { environmentId?: string }).environmentId ?? "unknown",
|
|
timestamp: new Date().toISOString(),
|
|
params,
|
|
result,
|
|
error,
|
|
};
|
|
environmentEvents.push(event);
|
|
return event;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async function callHook<R>(
|
|
type: EnvironmentEventRecord["type"],
|
|
hook: ((...args: any[]) => Promise<R>) | undefined,
|
|
params: unknown,
|
|
hookName: string,
|
|
): Promise<R> {
|
|
if (!hook) {
|
|
const err = `Environment driver '${driver.driverKey}' does not implement ${hookName}`;
|
|
record(type, params as Record<string, unknown>, undefined, err);
|
|
throw new Error(err);
|
|
}
|
|
try {
|
|
const result = await hook(params);
|
|
record(type, params as Record<string, unknown>, result);
|
|
return result;
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
record(type, params as Record<string, unknown>, undefined, msg);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
const envHarness: EnvironmentTestHarness = {
|
|
...base,
|
|
environmentEvents,
|
|
async validateConfig(params) {
|
|
return callHook("validateConfig", driver.onValidateConfig, params, "onValidateConfig");
|
|
},
|
|
async probe(params) {
|
|
return callHook("probe", driver.onProbe, params, "onProbe");
|
|
},
|
|
async acquireLease(params) {
|
|
return callHook("acquireLease", driver.onAcquireLease, params, "onAcquireLease");
|
|
},
|
|
async resumeLease(params) {
|
|
return callHook("resumeLease", driver.onResumeLease, params, "onResumeLease");
|
|
},
|
|
async releaseLease(params) {
|
|
return callHook("releaseLease", driver.onReleaseLease, params, "onReleaseLease");
|
|
},
|
|
async destroyLease(params) {
|
|
return callHook("destroyLease", driver.onDestroyLease, params, "onDestroyLease");
|
|
},
|
|
async realizeWorkspace(params) {
|
|
return callHook("realizeWorkspace", driver.onRealizeWorkspace, params, "onRealizeWorkspace");
|
|
},
|
|
async execute(params) {
|
|
return callHook("execute", driver.onExecute, params, "onExecute");
|
|
},
|
|
};
|
|
|
|
return envHarness;
|
|
}
|