mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
fix(adapters): honor paused overrides and isolate UI parser state
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
c6d2dc8b56
commit
d9476abecb
17 changed files with 297 additions and 53 deletions
|
|
@ -1,12 +1,15 @@
|
||||||
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||||
import {
|
import {
|
||||||
|
detectAdapterModel,
|
||||||
|
findActiveServerAdapter,
|
||||||
findServerAdapter,
|
findServerAdapter,
|
||||||
listAdapterModels,
|
listAdapterModels,
|
||||||
registerServerAdapter,
|
registerServerAdapter,
|
||||||
requireServerAdapter,
|
requireServerAdapter,
|
||||||
unregisterServerAdapter,
|
unregisterServerAdapter,
|
||||||
} from "../adapters/index.js";
|
} from "../adapters/index.js";
|
||||||
|
import { setOverridePaused } from "../adapters/registry.js";
|
||||||
|
|
||||||
const externalAdapter: ServerAdapterModule = {
|
const externalAdapter: ServerAdapterModule = {
|
||||||
type: "external_test",
|
type: "external_test",
|
||||||
|
|
@ -28,10 +31,14 @@ const externalAdapter: ServerAdapterModule = {
|
||||||
describe("server adapter registry", () => {
|
describe("server adapter registry", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
unregisterServerAdapter("external_test");
|
unregisterServerAdapter("external_test");
|
||||||
|
unregisterServerAdapter("claude_local");
|
||||||
|
setOverridePaused("claude_local", false);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
unregisterServerAdapter("external_test");
|
unregisterServerAdapter("external_test");
|
||||||
|
unregisterServerAdapter("claude_local");
|
||||||
|
setOverridePaused("claude_local", false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers external adapters and exposes them through lookup helpers", async () => {
|
it("registers external adapters and exposes them through lookup helpers", async () => {
|
||||||
|
|
@ -87,4 +94,50 @@ describe("server adapter registry", () => {
|
||||||
{ id: "plugin-model", label: "Plugin Override" },
|
{ id: "plugin-model", label: "Plugin Override" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("switches active adapter behavior back to the builtin when an override is paused", async () => {
|
||||||
|
const builtIn = findServerAdapter("claude_local");
|
||||||
|
expect(builtIn).not.toBeNull();
|
||||||
|
|
||||||
|
const detectModel = vi.fn(async () => ({
|
||||||
|
model: "plugin-model",
|
||||||
|
provider: "plugin-provider",
|
||||||
|
source: "plugin-source",
|
||||||
|
}));
|
||||||
|
const plugin: ServerAdapterModule = {
|
||||||
|
type: "claude_local",
|
||||||
|
execute: async () => ({
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
timedOut: false,
|
||||||
|
}),
|
||||||
|
testEnvironment: async () => ({
|
||||||
|
adapterType: "claude_local",
|
||||||
|
status: "pass",
|
||||||
|
checks: [],
|
||||||
|
testedAt: new Date(0).toISOString(),
|
||||||
|
}),
|
||||||
|
models: [{ id: "plugin-model", label: "Plugin Override" }],
|
||||||
|
detectModel,
|
||||||
|
supportsLocalAgentJwt: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
registerServerAdapter(plugin);
|
||||||
|
|
||||||
|
expect(findActiveServerAdapter("claude_local")).toBe(plugin);
|
||||||
|
expect(await listAdapterModels("claude_local")).toEqual([
|
||||||
|
{ id: "plugin-model", label: "Plugin Override" },
|
||||||
|
]);
|
||||||
|
expect(await detectAdapterModel("claude_local")).toMatchObject({
|
||||||
|
model: "plugin-model",
|
||||||
|
provider: "plugin-provider",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setOverridePaused("claude_local", true)).toBe(true);
|
||||||
|
|
||||||
|
expect(findActiveServerAdapter("claude_local")).not.toBe(plugin);
|
||||||
|
expect(await listAdapterModels("claude_local")).toEqual(builtIn?.models ?? []);
|
||||||
|
expect(await detectAdapterModel("claude_local")).toBeNull();
|
||||||
|
expect(detectModel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
78
server/src/__tests__/adapter-routes.test.ts
Normal file
78
server/src/__tests__/adapter-routes.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||||
|
import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
|
||||||
|
import { setOverridePaused } from "../adapters/registry.js";
|
||||||
|
import { adapterRoutes } from "../routes/adapters.js";
|
||||||
|
import { errorHandler } from "../middleware/index.js";
|
||||||
|
|
||||||
|
const overridingConfigSchemaAdapter: ServerAdapterModule = {
|
||||||
|
type: "claude_local",
|
||||||
|
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
||||||
|
testEnvironment: async () => ({
|
||||||
|
adapterType: "claude_local",
|
||||||
|
status: "pass",
|
||||||
|
checks: [],
|
||||||
|
testedAt: new Date(0).toISOString(),
|
||||||
|
}),
|
||||||
|
getConfigSchema: async () => ({
|
||||||
|
version: 1,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "mode",
|
||||||
|
type: "text",
|
||||||
|
label: "Mode",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
function createApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as any).actor = {
|
||||||
|
type: "board",
|
||||||
|
userId: "local-board",
|
||||||
|
companyIds: [],
|
||||||
|
source: "local_implicit",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use("/api", adapterRoutes());
|
||||||
|
app.use(errorHandler);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("adapter routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setOverridePaused("claude_local", false);
|
||||||
|
registerServerAdapter(overridingConfigSchemaAdapter);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setOverridePaused("claude_local", false);
|
||||||
|
unregisterServerAdapter("claude_local");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the active adapter when resolving config schema for a paused builtin override", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
const active = await request(app).get("/api/adapters/claude_local/config-schema");
|
||||||
|
expect(active.status, JSON.stringify(active.body)).toBe(200);
|
||||||
|
expect(active.body).toMatchObject({
|
||||||
|
fields: [{ key: "mode" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const paused = await request(app)
|
||||||
|
.patch("/api/adapters/claude_local/override")
|
||||||
|
.send({ paused: true });
|
||||||
|
expect(paused.status, JSON.stringify(paused.body)).toBe(200);
|
||||||
|
|
||||||
|
const builtin = await request(app).get("/api/adapters/claude_local/config-schema");
|
||||||
|
expect(builtin.status, JSON.stringify(builtin.body)).toBe(404);
|
||||||
|
expect(String(builtin.body.error ?? "")).toContain("does not provide a config schema");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -61,6 +61,7 @@ const mockAdapter = vi.hoisted(() => ({
|
||||||
|
|
||||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||||
trackAgentCreated: mockTrackAgentCreated,
|
trackAgentCreated: mockTrackAgentCreated,
|
||||||
|
trackErrorHandlerCrash: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../telemetry.js", () => ({
|
vi.mock("../telemetry.js", () => ({
|
||||||
|
|
@ -85,6 +86,7 @@ vi.mock("../services/index.js", () => ({
|
||||||
|
|
||||||
vi.mock("../adapters/index.js", () => ({
|
vi.mock("../adapters/index.js", () => ({
|
||||||
findServerAdapter: vi.fn(() => mockAdapter),
|
findServerAdapter: vi.fn(() => mockAdapter),
|
||||||
|
findActiveServerAdapter: vi.fn(() => mockAdapter),
|
||||||
listAdapterModels: vi.fn(),
|
listAdapterModels: vi.fn(),
|
||||||
detectAdapterModel: vi.fn(),
|
detectAdapterModel: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@ import { notifyHireApproved } from "../services/hire-hook.js";
|
||||||
|
|
||||||
// Mock the registry so we control whether the adapter has onHireApproved and what it does.
|
// Mock the registry so we control whether the adapter has onHireApproved and what it does.
|
||||||
vi.mock("../adapters/registry.js", () => ({
|
vi.mock("../adapters/registry.js", () => ({
|
||||||
findServerAdapter: vi.fn(),
|
findActiveServerAdapter: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../services/activity-log.js", () => ({
|
vi.mock("../services/activity-log.js", () => ({
|
||||||
logActivity: vi.fn().mockResolvedValue(undefined),
|
logActivity: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { findServerAdapter } = await import("../adapters/registry.js");
|
const { findActiveServerAdapter } = await import("../adapters/registry.js");
|
||||||
const { logActivity } = await import("../services/activity-log.js");
|
const { logActivity } = await import("../services/activity-log.js");
|
||||||
|
|
||||||
function mockDbWithAgent(agent: { id: string; companyId: string; name: string; adapterType: string; adapterConfig?: Record<string, unknown> }): Db {
|
function mockDbWithAgent(agent: { id: string; companyId: string; name: string; adapterType: string; adapterConfig?: Record<string, unknown> }): Db {
|
||||||
|
|
@ -39,7 +39,7 @@ afterEach(() => {
|
||||||
|
|
||||||
describe("notifyHireApproved", () => {
|
describe("notifyHireApproved", () => {
|
||||||
it("writes success activity when adapter hook returns ok", async () => {
|
it("writes success activity when adapter hook returns ok", async () => {
|
||||||
vi.mocked(findServerAdapter).mockReturnValue({
|
vi.mocked(findActiveServerAdapter).mockReturnValue({
|
||||||
type: "openclaw_gateway",
|
type: "openclaw_gateway",
|
||||||
onHireApproved: vi.fn().mockResolvedValue({ ok: true }),
|
onHireApproved: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
@ -88,11 +88,11 @@ describe("notifyHireApproved", () => {
|
||||||
}),
|
}),
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
expect(findServerAdapter).not.toHaveBeenCalled();
|
expect(findActiveServerAdapter).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does nothing when adapter has no onHireApproved", async () => {
|
it("does nothing when adapter has no onHireApproved", async () => {
|
||||||
vi.mocked(findServerAdapter).mockReturnValue({ type: "process" } as any);
|
vi.mocked(findActiveServerAdapter).mockReturnValue({ type: "process" } as any);
|
||||||
|
|
||||||
const db = mockDbWithAgent({
|
const db = mockDbWithAgent({
|
||||||
id: "a1",
|
id: "a1",
|
||||||
|
|
@ -110,12 +110,12 @@ describe("notifyHireApproved", () => {
|
||||||
}),
|
}),
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
expect(findServerAdapter).toHaveBeenCalledWith("process");
|
expect(findActiveServerAdapter).toHaveBeenCalledWith("process");
|
||||||
expect(logActivity).not.toHaveBeenCalled();
|
expect(logActivity).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs failed result when adapter onHireApproved returns ok=false", async () => {
|
it("logs failed result when adapter onHireApproved returns ok=false", async () => {
|
||||||
vi.mocked(findServerAdapter).mockReturnValue({
|
vi.mocked(findActiveServerAdapter).mockReturnValue({
|
||||||
type: "openclaw_gateway",
|
type: "openclaw_gateway",
|
||||||
onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }),
|
onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
@ -147,7 +147,7 @@ describe("notifyHireApproved", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not throw when adapter onHireApproved throws (non-fatal)", async () => {
|
it("does not throw when adapter onHireApproved throws (non-fatal)", async () => {
|
||||||
vi.mocked(findServerAdapter).mockReturnValue({
|
vi.mocked(findActiveServerAdapter).mockReturnValue({
|
||||||
type: "openclaw_gateway",
|
type: "openclaw_gateway",
|
||||||
onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")),
|
onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export {
|
||||||
listAdapterModels,
|
listAdapterModels,
|
||||||
listServerAdapters,
|
listServerAdapters,
|
||||||
findServerAdapter,
|
findServerAdapter,
|
||||||
|
findActiveServerAdapter,
|
||||||
detectAdapterModel,
|
detectAdapterModel,
|
||||||
registerServerAdapter,
|
registerServerAdapter,
|
||||||
unregisterServerAdapter,
|
unregisterServerAdapter,
|
||||||
|
|
|
||||||
|
|
@ -278,16 +278,33 @@ export function waitForExternalAdapters(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerServerAdapter(adapter: ServerAdapterModule): void {
|
export function registerServerAdapter(adapter: ServerAdapterModule): void {
|
||||||
|
if (BUILTIN_ADAPTER_TYPES.has(adapter.type) && !builtinFallbacks.has(adapter.type)) {
|
||||||
|
const existing = adaptersByType.get(adapter.type);
|
||||||
|
if (existing) {
|
||||||
|
builtinFallbacks.set(adapter.type, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
adaptersByType.set(adapter.type, adapter);
|
adaptersByType.set(adapter.type, adapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unregisterServerAdapter(type: string): void {
|
export function unregisterServerAdapter(type: string): void {
|
||||||
if (type === processAdapter.type || type === httpAdapter.type) return;
|
if (type === processAdapter.type || type === httpAdapter.type) return;
|
||||||
|
if (builtinFallbacks.has(type)) {
|
||||||
|
pausedOverrides.delete(type);
|
||||||
|
const fallback = builtinFallbacks.get(type);
|
||||||
|
if (fallback) {
|
||||||
|
adaptersByType.set(type, fallback);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (BUILTIN_ADAPTER_TYPES.has(type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
adaptersByType.delete(type);
|
adaptersByType.delete(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requireServerAdapter(type: string): ServerAdapterModule {
|
export function requireServerAdapter(type: string): ServerAdapterModule {
|
||||||
const adapter = adaptersByType.get(type);
|
const adapter = findActiveServerAdapter(type);
|
||||||
if (!adapter) {
|
if (!adapter) {
|
||||||
throw new Error(`Unknown adapter type: ${type}`);
|
throw new Error(`Unknown adapter type: ${type}`);
|
||||||
}
|
}
|
||||||
|
|
@ -295,15 +312,11 @@ export function requireServerAdapter(type: string): ServerAdapterModule {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getServerAdapter(type: string): ServerAdapterModule {
|
export function getServerAdapter(type: string): ServerAdapterModule {
|
||||||
if (pausedOverrides.has(type)) {
|
return findActiveServerAdapter(type) ?? processAdapter;
|
||||||
const fallback = builtinFallbacks.get(type);
|
|
||||||
if (fallback) return fallback;
|
|
||||||
}
|
|
||||||
return adaptersByType.get(type) ?? processAdapter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAdapterModels(type: string): Promise<{ id: string; label: string }[]> {
|
export async function listAdapterModels(type: string): Promise<{ id: string; label: string }[]> {
|
||||||
const adapter = adaptersByType.get(type);
|
const adapter = findActiveServerAdapter(type);
|
||||||
if (!adapter) return [];
|
if (!adapter) return [];
|
||||||
if (adapter.listModels) {
|
if (adapter.listModels) {
|
||||||
const discovered = await adapter.listModels();
|
const discovered = await adapter.listModels();
|
||||||
|
|
@ -332,7 +345,7 @@ export function listEnabledServerAdapters(): ServerAdapterModule[] {
|
||||||
export async function detectAdapterModel(
|
export async function detectAdapterModel(
|
||||||
type: string,
|
type: string,
|
||||||
): Promise<{ model: string; provider: string; source: string; candidates?: string[] } | null> {
|
): Promise<{ model: string; provider: string; source: string; candidates?: string[] } | null> {
|
||||||
const adapter = adaptersByType.get(type);
|
const adapter = findActiveServerAdapter(type);
|
||||||
if (!adapter?.detectModel) return null;
|
if (!adapter?.detectModel) return null;
|
||||||
const detected = await adapter.detectModel();
|
const detected = await adapter.detectModel();
|
||||||
if (!detected) return null;
|
if (!detected) return null;
|
||||||
|
|
@ -390,3 +403,11 @@ export function getPausedOverrides(): Set<string> {
|
||||||
export function findServerAdapter(type: string): ServerAdapterModule | null {
|
export function findServerAdapter(type: string): ServerAdapterModule | null {
|
||||||
return adaptersByType.get(type) ?? null;
|
return adaptersByType.get(type) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findActiveServerAdapter(type: string): ServerAdapterModule | null {
|
||||||
|
if (pausedOverrides.has(type)) {
|
||||||
|
const fallback = builtinFallbacks.get(type);
|
||||||
|
if (fallback) return fallback;
|
||||||
|
}
|
||||||
|
return adaptersByType.get(type) ?? null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { Router } from "express";
|
||||||
import {
|
import {
|
||||||
listServerAdapters,
|
listServerAdapters,
|
||||||
findServerAdapter,
|
findServerAdapter,
|
||||||
|
findActiveServerAdapter,
|
||||||
listEnabledServerAdapters,
|
listEnabledServerAdapters,
|
||||||
registerServerAdapter,
|
registerServerAdapter,
|
||||||
unregisterServerAdapter,
|
unregisterServerAdapter,
|
||||||
|
|
@ -593,7 +594,7 @@ export function adapterRoutes() {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const { type } = req.params;
|
const { type } = req.params;
|
||||||
|
|
||||||
const adapter = findServerAdapter(type);
|
const adapter = findActiveServerAdapter(type);
|
||||||
if (!adapter) {
|
if (!adapter) {
|
||||||
res.status(404).json({ error: `Adapter "${type}" is not registered.` });
|
res.status(404).json({ error: `Adapter "${type}" is not registered.` });
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
||||||
import {
|
import {
|
||||||
detectAdapterModel,
|
detectAdapterModel,
|
||||||
|
findActiveServerAdapter,
|
||||||
findServerAdapter,
|
findServerAdapter,
|
||||||
listAdapterModels,
|
listAdapterModels,
|
||||||
requireServerAdapter,
|
requireServerAdapter,
|
||||||
|
|
@ -820,7 +821,7 @@ export function agentRoutes(db: Db) {
|
||||||
}
|
}
|
||||||
await assertCanReadConfigurations(req, agent.companyId);
|
await assertCanReadConfigurations(req, agent.companyId);
|
||||||
|
|
||||||
const adapter = findServerAdapter(agent.adapterType);
|
const adapter = findActiveServerAdapter(agent.adapterType);
|
||||||
if (!adapter?.listSkills) {
|
if (!adapter?.listSkills) {
|
||||||
const preference = readPaperclipSkillSyncPreference(
|
const preference = readPaperclipSkillSyncPreference(
|
||||||
agent.adapterConfig as Record<string, unknown>,
|
agent.adapterConfig as Record<string, unknown>,
|
||||||
|
|
@ -898,7 +899,7 @@ export function agentRoutes(db: Db) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adapter = findServerAdapter(updated.adapterType);
|
const adapter = findActiveServerAdapter(updated.adapterType);
|
||||||
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
|
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||||
updated.companyId,
|
updated.companyId,
|
||||||
updated.adapterConfig,
|
updated.adapterConfig,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import type {
|
||||||
CompanySkillUsageAgent,
|
CompanySkillUsageAgent,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||||
import { findServerAdapter } from "../adapters/index.js";
|
import { findActiveServerAdapter } from "../adapters/index.js";
|
||||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||||
import { notFound, unprocessable } from "../errors.js";
|
import { notFound, unprocessable } from "../errors.js";
|
||||||
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
|
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
|
||||||
|
|
@ -1575,7 +1575,7 @@ export function companySkillService(db: Db) {
|
||||||
|
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
desiredAgents.map(async (agent) => {
|
desiredAgents.map(async (agent) => {
|
||||||
const adapter = findServerAdapter(agent.adapterType);
|
const adapter = findActiveServerAdapter(agent.adapterType);
|
||||||
let actualState: string | null = null;
|
let actualState: string | null = null;
|
||||||
|
|
||||||
if (!adapter?.listSkills) {
|
if (!adapter?.listSkills) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { and, eq } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { agents } from "@paperclipai/db";
|
import { agents } from "@paperclipai/db";
|
||||||
import type { HireApprovedPayload } from "@paperclipai/adapter-utils";
|
import type { HireApprovedPayload } from "@paperclipai/adapter-utils";
|
||||||
import { findServerAdapter } from "../adapters/registry.js";
|
import { findActiveServerAdapter } from "../adapters/registry.js";
|
||||||
import { logger } from "../middleware/logger.js";
|
import { logger } from "../middleware/logger.js";
|
||||||
import { logActivity } from "./activity-log.js";
|
import { logActivity } from "./activity-log.js";
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ export async function notifyHireApproved(
|
||||||
}
|
}
|
||||||
|
|
||||||
const adapterType = row.adapterType ?? "process";
|
const adapterType = row.adapterType ?? "process";
|
||||||
const adapter = findServerAdapter(adapterType);
|
const adapter = findActiveServerAdapter(adapterType);
|
||||||
const onHireApproved = adapter?.onHireApproved;
|
const onHireApproved = adapter?.onHireApproved;
|
||||||
if (!onHireApproved) {
|
if (!onHireApproved) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,16 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||||
import type { StdoutLineParser } from "./types";
|
import type { StatefulStdoutParser, StdoutLineParser, StdoutParserFactory } from "./types";
|
||||||
|
|
||||||
|
interface DynamicParserModule {
|
||||||
|
parseStdoutLine: StdoutLineParser;
|
||||||
|
createStdoutParser?: StdoutParserFactory;
|
||||||
|
}
|
||||||
|
|
||||||
// Cache of dynamically loaded parsers by adapter type.
|
// Cache of dynamically loaded parsers by adapter type.
|
||||||
// Once loaded, the parser is reused for all runs of that adapter type.
|
// Once loaded, the parser is reused for all runs of that adapter type.
|
||||||
const dynamicParserCache = new Map<string, StdoutLineParser>();
|
const dynamicParserCache = new Map<string, DynamicParserModule>();
|
||||||
|
|
||||||
// Track which types we've already attempted to load (to avoid repeat 404s).
|
// Track which types we've already attempted to load (to avoid repeat 404s).
|
||||||
const failedLoads = new Set<string>();
|
const failedLoads = new Set<string>();
|
||||||
|
|
@ -33,7 +38,7 @@ const failedLoads = new Set<string>();
|
||||||
*
|
*
|
||||||
* @returns A StdoutLineParser function, or null if unavailable.
|
* @returns A StdoutLineParser function, or null if unavailable.
|
||||||
*/
|
*/
|
||||||
export async function loadDynamicParser(adapterType: string): Promise<StdoutLineParser | null> {
|
export async function loadDynamicParser(adapterType: string): Promise<DynamicParserModule | null> {
|
||||||
// Return cached parser if already loaded
|
// Return cached parser if already loaded
|
||||||
const cached = dynamicParserCache.get(adapterType);
|
const cached = dynamicParserCache.get(adapterType);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
@ -56,7 +61,7 @@ export async function loadDynamicParser(adapterType: string): Promise<StdoutLine
|
||||||
const blob = new Blob([source], { type: "application/javascript" });
|
const blob = new Blob([source], { type: "application/javascript" });
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
let parseFn: StdoutLineParser;
|
let parserModule: DynamicParserModule;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mod = await import(/* @vite-ignore */ blobUrl);
|
const mod = await import(/* @vite-ignore */ blobUrl);
|
||||||
|
|
@ -64,13 +69,24 @@ export async function loadDynamicParser(adapterType: string): Promise<StdoutLine
|
||||||
// Prefer the factory function (stateful parser) if available,
|
// Prefer the factory function (stateful parser) if available,
|
||||||
// fall back to the static parseStdoutLine function.
|
// fall back to the static parseStdoutLine function.
|
||||||
if (typeof mod.createStdoutParser === "function") {
|
if (typeof mod.createStdoutParser === "function") {
|
||||||
// Stateful parser — create one instance for the UI session.
|
const createStdoutParser = mod.createStdoutParser as StdoutParserFactory;
|
||||||
// Each run creates its own transcript builder, so a single
|
parserModule = {
|
||||||
// parser instance is sufficient per adapter type.
|
createStdoutParser,
|
||||||
const parser = (mod.createStdoutParser as () => { parseLine: StdoutLineParser; reset: () => void })();
|
// Fallback for callers that only know about parseStdoutLine.
|
||||||
parseFn = parser.parseLine.bind(parser);
|
parseStdoutLine:
|
||||||
|
typeof mod.parseStdoutLine === "function"
|
||||||
|
? (mod.parseStdoutLine as StdoutLineParser)
|
||||||
|
: ((line: string, ts: string) => {
|
||||||
|
const parser = createStdoutParser() as StatefulStdoutParser;
|
||||||
|
const entries = parser.parseLine(line, ts);
|
||||||
|
parser.reset();
|
||||||
|
return entries;
|
||||||
|
}),
|
||||||
|
};
|
||||||
} else if (typeof mod.parseStdoutLine === "function") {
|
} else if (typeof mod.parseStdoutLine === "function") {
|
||||||
parseFn = mod.parseStdoutLine as StdoutLineParser;
|
parserModule = {
|
||||||
|
parseStdoutLine: mod.parseStdoutLine as StdoutLineParser,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[adapter-ui-loader] Module for "${adapterType}" exports neither parseStdoutLine nor createStdoutParser`);
|
console.warn(`[adapter-ui-loader] Module for "${adapterType}" exports neither parseStdoutLine nor createStdoutParser`);
|
||||||
failedLoads.add(adapterType);
|
failedLoads.add(adapterType);
|
||||||
|
|
@ -81,9 +97,9 @@ export async function loadDynamicParser(adapterType: string): Promise<StdoutLine
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache for reuse
|
// Cache for reuse
|
||||||
dynamicParserCache.set(adapterType, parseFn);
|
dynamicParserCache.set(adapterType, parserModule);
|
||||||
console.info(`[adapter-ui-loader] Loaded dynamic UI parser for "${adapterType}"`);
|
console.info(`[adapter-ui-loader] Loaded dynamic UI parser for "${adapterType}"`);
|
||||||
return parseFn;
|
return parserModule;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[adapter-ui-loader] Failed to load UI parser for "${adapterType}":`, err);
|
console.warn(`[adapter-ui-loader] Failed to load UI parser for "${adapterType}":`, err);
|
||||||
failedLoads.add(adapterType);
|
failedLoads.add(adapterType);
|
||||||
|
|
|
||||||
|
|
@ -101,12 +101,13 @@ export function getUIAdapter(type: string): UIAdapterModule {
|
||||||
parseStdoutLine: (line: string, ts: string) => {
|
parseStdoutLine: (line: string, ts: string) => {
|
||||||
if (!loadStarted) {
|
if (!loadStarted) {
|
||||||
loadStarted = true;
|
loadStarted = true;
|
||||||
loadDynamicParser(type).then((parser) => {
|
loadDynamicParser(type).then((parserModule) => {
|
||||||
if (parser) {
|
if (parserModule) {
|
||||||
registerUIAdapter({
|
registerUIAdapter({
|
||||||
type,
|
type,
|
||||||
label: type,
|
label: type,
|
||||||
parseStdoutLine: parser,
|
parseStdoutLine: parserModule.parseStdoutLine,
|
||||||
|
createStdoutParser: parserModule.createStdoutParser,
|
||||||
ConfigFields: SchemaConfigFields,
|
ConfigFields: SchemaConfigFields,
|
||||||
buildAdapterConfig: buildSchemaAdapterConfig,
|
buildAdapterConfig: buildSchemaAdapterConfig,
|
||||||
});
|
});
|
||||||
|
|
@ -182,13 +183,14 @@ export function syncExternalAdapters(
|
||||||
parseStdoutLine: (line: string, ts: string) => {
|
parseStdoutLine: (line: string, ts: string) => {
|
||||||
if (!loadStarted) {
|
if (!loadStarted) {
|
||||||
loadStarted = true;
|
loadStarted = true;
|
||||||
loadDynamicParser(builtinType).then((parser) => {
|
loadDynamicParser(builtinType).then((parserModule) => {
|
||||||
// Discard if the override was torn down while the load was in-flight.
|
// Discard if the override was torn down while the load was in-flight.
|
||||||
if (parser && overrideGeneration.get(builtinType) === gen) {
|
if (parserModule && overrideGeneration.get(builtinType) === gen) {
|
||||||
registerUIAdapter({
|
registerUIAdapter({
|
||||||
type: builtinType,
|
type: builtinType,
|
||||||
label,
|
label,
|
||||||
parseStdoutLine: parser,
|
parseStdoutLine: parserModule.parseStdoutLine,
|
||||||
|
createStdoutParser: parserModule.createStdoutParser,
|
||||||
ConfigFields: originalBuiltin.ConfigFields,
|
ConfigFields: originalBuiltin.ConfigFields,
|
||||||
buildAdapterConfig: originalBuiltin.buildAdapterConfig,
|
buildAdapterConfig: originalBuiltin.buildAdapterConfig,
|
||||||
});
|
});
|
||||||
|
|
@ -232,12 +234,13 @@ export function syncExternalAdapters(
|
||||||
parseStdoutLine: (line: string, ts: string) => {
|
parseStdoutLine: (line: string, ts: string) => {
|
||||||
if (!loadStarted) {
|
if (!loadStarted) {
|
||||||
loadStarted = true;
|
loadStarted = true;
|
||||||
loadDynamicParser(type).then((parser) => {
|
loadDynamicParser(type).then((parserModule) => {
|
||||||
if (parser) {
|
if (parserModule) {
|
||||||
registerUIAdapter({
|
registerUIAdapter({
|
||||||
type,
|
type,
|
||||||
label,
|
label,
|
||||||
parseStdoutLine: parser,
|
parseStdoutLine: parserModule.parseStdoutLine,
|
||||||
|
createStdoutParser: parserModule.createStdoutParser,
|
||||||
ConfigFields: existing?.ConfigFields ?? SchemaConfigFields,
|
ConfigFields: existing?.ConfigFields ?? SchemaConfigFields,
|
||||||
buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig,
|
buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildTranscript, type RunLogChunk } from "./transcript";
|
import { buildTranscript, type RunLogChunk } from "./transcript";
|
||||||
|
import type { UIAdapterModule } from "./types";
|
||||||
|
|
||||||
describe("buildTranscript", () => {
|
describe("buildTranscript", () => {
|
||||||
const ts = "2026-03-20T13:00:00.000Z";
|
const ts = "2026-03-20T13:00:00.000Z";
|
||||||
|
|
@ -27,4 +28,46 @@ describe("buildTranscript", () => {
|
||||||
{ kind: "stderr", ts, text: "stderr /Users/d****/project" },
|
{ kind: "stderr", ts, text: "stderr /Users/d****/project" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates a fresh stateful parser for each transcript build", () => {
|
||||||
|
const statefulAdapter: UIAdapterModule = {
|
||||||
|
type: "stateful_test",
|
||||||
|
label: "Stateful Test",
|
||||||
|
parseStdoutLine: (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }],
|
||||||
|
createStdoutParser: () => {
|
||||||
|
let pending: string | null = null;
|
||||||
|
return {
|
||||||
|
parseLine: (line, entryTs) => {
|
||||||
|
if (line.startsWith("begin:")) {
|
||||||
|
pending = line.slice("begin:".length);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (line === "finish" && pending) {
|
||||||
|
const text = `completed:${pending}`;
|
||||||
|
pending = null;
|
||||||
|
return [{ kind: "stdout", ts: entryTs, text }];
|
||||||
|
}
|
||||||
|
return [{ kind: "stdout", ts: entryTs, text: `literal:${line}` }];
|
||||||
|
},
|
||||||
|
reset: () => {
|
||||||
|
pending = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
ConfigFields: () => null,
|
||||||
|
buildAdapterConfig: () => ({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = buildTranscript(
|
||||||
|
[{ ts, stream: "stdout", chunk: "begin:task-a\n" }],
|
||||||
|
statefulAdapter,
|
||||||
|
);
|
||||||
|
const second = buildTranscript(
|
||||||
|
[{ ts, stream: "stdout", chunk: "finish\n" }],
|
||||||
|
statefulAdapter,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(first).toEqual([]);
|
||||||
|
expect(second).toEqual([{ kind: "stdout", ts, text: "literal:finish" }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,20 @@
|
||||||
import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils";
|
import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils";
|
||||||
import type { TranscriptEntry, StdoutLineParser } from "./types";
|
import type { TranscriptEntry, StdoutLineParser, TranscriptParserSource } from "./types";
|
||||||
|
|
||||||
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
|
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
|
||||||
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean };
|
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean };
|
||||||
|
|
||||||
|
function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource) {
|
||||||
|
if (typeof source === "function") {
|
||||||
|
return { parseLine: source, reset: null as (() => void) | null };
|
||||||
|
}
|
||||||
|
if (source.createStdoutParser) {
|
||||||
|
const parser = source.createStdoutParser();
|
||||||
|
return { parseLine: parser.parseLine, reset: parser.reset };
|
||||||
|
}
|
||||||
|
return { parseLine: source.parseStdoutLine, reset: null as (() => void) | null };
|
||||||
|
}
|
||||||
|
|
||||||
export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
|
export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
|
||||||
if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) {
|
if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) {
|
||||||
const last = entries[entries.length - 1];
|
const last = entries[entries.length - 1];
|
||||||
|
|
@ -24,12 +35,13 @@ export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: Tr
|
||||||
|
|
||||||
export function buildTranscript(
|
export function buildTranscript(
|
||||||
chunks: RunLogChunk[],
|
chunks: RunLogChunk[],
|
||||||
parser: StdoutLineParser,
|
parserSource: StdoutLineParser | TranscriptParserSource,
|
||||||
opts?: TranscriptBuildOptions,
|
opts?: TranscriptBuildOptions,
|
||||||
): TranscriptEntry[] {
|
): TranscriptEntry[] {
|
||||||
const entries: TranscriptEntry[] = [];
|
const entries: TranscriptEntry[] = [];
|
||||||
let stdoutBuffer = "";
|
let stdoutBuffer = "";
|
||||||
const redactionOptions = { enabled: opts?.censorUsernameInLogs ?? false };
|
const redactionOptions = { enabled: opts?.censorUsernameInLogs ?? false };
|
||||||
|
const { parseLine, reset } = resolveStdoutParser(parserSource);
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
if (chunk.stream === "stderr") {
|
if (chunk.stream === "stderr") {
|
||||||
|
|
@ -47,15 +59,17 @@ export function buildTranscript(
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
|
appendTranscriptEntries(entries, parseLine(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const trailing = stdoutBuffer.trim();
|
const trailing = stdoutBuffer.trim();
|
||||||
if (trailing) {
|
if (trailing) {
|
||||||
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
|
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
|
||||||
appendTranscriptEntries(entries, parser(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
|
appendTranscriptEntries(entries, parseLine(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reset?.();
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,18 @@ import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||||
// Re-export shared types so local consumers don't need to change imports
|
// Re-export shared types so local consumers don't need to change imports
|
||||||
export type { TranscriptEntry, StdoutLineParser, CreateConfigValues } from "@paperclipai/adapter-utils";
|
export type { TranscriptEntry, StdoutLineParser, CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||||
|
|
||||||
|
export interface StatefulStdoutParser {
|
||||||
|
parseLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[];
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StdoutParserFactory = () => StatefulStdoutParser;
|
||||||
|
|
||||||
|
export interface TranscriptParserSource {
|
||||||
|
parseStdoutLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[];
|
||||||
|
createStdoutParser?: StdoutParserFactory;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdapterConfigFieldsProps {
|
export interface AdapterConfigFieldsProps {
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
isCreate: boolean;
|
isCreate: boolean;
|
||||||
|
|
@ -24,10 +36,9 @@ export interface AdapterConfigFieldsProps {
|
||||||
hideInstructionsFile?: boolean;
|
hideInstructionsFile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UIAdapterModule {
|
export interface UIAdapterModule extends TranscriptParserSource {
|
||||||
type: string;
|
type: string;
|
||||||
label: string;
|
label: string;
|
||||||
parseStdoutLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[];
|
|
||||||
ConfigFields: ComponentType<AdapterConfigFieldsProps>;
|
ConfigFields: ComponentType<AdapterConfigFieldsProps>;
|
||||||
buildAdapterConfig: (values: CreateConfigValues) => Record<string, unknown>;
|
buildAdapterConfig: (values: CreateConfigValues) => Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -284,7 +284,7 @@ export function useLiveRunTranscripts({
|
||||||
const adapter = getUIAdapter(run.adapterType);
|
const adapter = getUIAdapter(run.adapterType);
|
||||||
next.set(
|
next.set(
|
||||||
run.id,
|
run.id,
|
||||||
buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine, {
|
buildTranscript(chunksByRun.get(run.id) ?? [], adapter, {
|
||||||
censorUsernameInLogs,
|
censorUsernameInLogs,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3802,7 +3802,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const transcript = useMemo(
|
const transcript = useMemo(
|
||||||
() => buildTranscript(logLines, adapter.parseStdoutLine, { censorUsernameInLogs }),
|
() => buildTranscript(logLines, adapter, { censorUsernameInLogs }),
|
||||||
[adapter, censorUsernameInLogs, logLines, parserTick],
|
[adapter, censorUsernameInLogs, logLines, parserTick],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue