mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
Merge upstream/master into add-gpt-5-4-xhigh-effort
This commit is contained in:
commit
432d7e72fa
227 changed files with 31564 additions and 2543 deletions
|
|
@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local";
|
||||
import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local";
|
||||
import { models as opencodeFallbackModels } from "@paperclipai/adapter-opencode-local";
|
||||
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
|
||||
import { listAdapterModels } from "../adapters/index.js";
|
||||
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
|
||||
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
|
||||
|
|
@ -9,9 +10,11 @@ import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from ".
|
|||
describe("adapter model listing", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.PAPERCLIP_OPENCODE_COMMAND;
|
||||
resetCodexModelsCacheForTests();
|
||||
resetCursorModelsCacheForTests();
|
||||
setCursorModelsRunnerForTests(null);
|
||||
resetOpenCodeModelsCacheForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
|
|
@ -61,6 +64,7 @@ describe("adapter model listing", () => {
|
|||
expect(models).toEqual(codexFallbackModels);
|
||||
});
|
||||
|
||||
|
||||
it("returns cursor fallback models when CLI discovery is unavailable", async () => {
|
||||
setCursorModelsRunnerForTests(() => ({
|
||||
status: null,
|
||||
|
|
@ -74,6 +78,8 @@ describe("adapter model listing", () => {
|
|||
});
|
||||
|
||||
it("returns opencode fallback models including gpt-5.4", async () => {
|
||||
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
|
||||
|
||||
const models = await listAdapterModels("opencode_local");
|
||||
|
||||
expect(models).toEqual(opencodeFallbackModels);
|
||||
|
|
@ -97,4 +103,5 @@ describe("adapter model listing", () => {
|
|||
expect(first.some((model) => model.id === "gpt-5.3-codex-high")).toBe(true);
|
||||
expect(first.some((model) => model.id === "composer-1")).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
69
server/src/__tests__/agent-shortname-collision.test.ts
Normal file
69
server/src/__tests__/agent-shortname-collision.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { hasAgentShortnameCollision, deduplicateAgentName } from "../services/agents.ts";
|
||||
|
||||
describe("hasAgentShortnameCollision", () => {
|
||||
it("detects collisions by normalized shortname", () => {
|
||||
const collision = hasAgentShortnameCollision("Codex Coder", [
|
||||
{ id: "a1", name: "codex-coder", status: "idle" },
|
||||
]);
|
||||
expect(collision).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores terminated agents", () => {
|
||||
const collision = hasAgentShortnameCollision("Codex Coder", [
|
||||
{ id: "a1", name: "codex-coder", status: "terminated" },
|
||||
]);
|
||||
expect(collision).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores the excluded agent id", () => {
|
||||
const collision = hasAgentShortnameCollision(
|
||||
"Codex Coder",
|
||||
[
|
||||
{ id: "a1", name: "codex-coder", status: "idle" },
|
||||
{ id: "a2", name: "other-agent", status: "idle" },
|
||||
],
|
||||
{ excludeAgentId: "a1" },
|
||||
);
|
||||
expect(collision).toBe(false);
|
||||
});
|
||||
|
||||
it("does not collide when candidate has no shortname", () => {
|
||||
const collision = hasAgentShortnameCollision("!!!", [
|
||||
{ id: "a1", name: "codex-coder", status: "idle" },
|
||||
]);
|
||||
expect(collision).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deduplicateAgentName", () => {
|
||||
it("returns original name when no collision", () => {
|
||||
const name = deduplicateAgentName("OpenClaw", [
|
||||
{ id: "a1", name: "other-agent", status: "idle" },
|
||||
]);
|
||||
expect(name).toBe("OpenClaw");
|
||||
});
|
||||
|
||||
it("appends suffix when name collides", () => {
|
||||
const name = deduplicateAgentName("OpenClaw", [
|
||||
{ id: "a1", name: "openclaw", status: "idle" },
|
||||
]);
|
||||
expect(name).toBe("OpenClaw 2");
|
||||
});
|
||||
|
||||
it("increments suffix until unique", () => {
|
||||
const name = deduplicateAgentName("OpenClaw", [
|
||||
{ id: "a1", name: "openclaw", status: "idle" },
|
||||
{ id: "a2", name: "openclaw-2", status: "idle" },
|
||||
{ id: "a3", name: "openclaw-3", status: "idle" },
|
||||
]);
|
||||
expect(name).toBe("OpenClaw 4");
|
||||
});
|
||||
|
||||
it("ignores terminated agents for collision", () => {
|
||||
const name = deduplicateAgentName("OpenClaw", [
|
||||
{ id: "a1", name: "openclaw", status: "terminated" },
|
||||
]);
|
||||
expect(name).toBe("OpenClaw");
|
||||
});
|
||||
});
|
||||
49
server/src/__tests__/companies-route-path-guard.test.ts
Normal file
49
server/src/__tests__/companies-route-path-guard.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { companyRoutes } from "../routes/companies.js";
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
list: vi.fn(),
|
||||
stats: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}),
|
||||
companyPortabilityService: () => ({
|
||||
exportBundle: vi.fn(),
|
||||
previewImport: vi.fn(),
|
||||
importBundle: vi.fn(),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
}),
|
||||
logActivity: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("company routes malformed issue path guard", () => {
|
||||
it("returns a clear error when companyId is missing for issues list path", async () => {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api/companies", companyRoutes({} as any));
|
||||
|
||||
const res = await request(app).get("/api/companies/issues");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({
|
||||
error: "Missing companyId in path. Use /api/companies/{companyId}/issues.",
|
||||
});
|
||||
});
|
||||
});
|
||||
53
server/src/__tests__/error-handler.test.ts
Normal file
53
server/src/__tests__/error-handler.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import type { NextFunction, Request, Response } from "express";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
|
||||
function makeReq(): Request {
|
||||
return {
|
||||
method: "GET",
|
||||
originalUrl: "/api/test",
|
||||
body: { a: 1 },
|
||||
params: { id: "123" },
|
||||
query: { q: "x" },
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
function makeRes(): Response {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
} as unknown as Response;
|
||||
(res.status as unknown as ReturnType<typeof vi.fn>).mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("errorHandler", () => {
|
||||
it("attaches the original Error to res.err for 500s", () => {
|
||||
const req = makeReq();
|
||||
const res = makeRes() as any;
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const err = new Error("boom");
|
||||
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" });
|
||||
expect(res.err).toBe(err);
|
||||
expect(res.__errorContext?.error?.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("attaches HttpError instances for 500 responses", () => {
|
||||
const req = makeReq();
|
||||
const res = makeRes() as any;
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const err = new HttpError(500, "db exploded");
|
||||
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "db exploded" });
|
||||
expect(res.err).toBe(err);
|
||||
expect(res.__errorContext?.error?.message).toBe("db exploded");
|
||||
});
|
||||
});
|
||||
180
server/src/__tests__/hire-hook.test.ts
Normal file
180
server/src/__tests__/hire-hook.test.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { notifyHireApproved } from "../services/hire-hook.js";
|
||||
|
||||
// Mock the registry so we control whether the adapter has onHireApproved and what it does.
|
||||
vi.mock("../adapters/registry.js", () => ({
|
||||
findServerAdapter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/activity-log.js", () => ({
|
||||
logActivity: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { findServerAdapter } = await import("../adapters/registry.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 {
|
||||
return {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: agent.id,
|
||||
companyId: agent.companyId,
|
||||
name: agent.name,
|
||||
adapterType: agent.adapterType,
|
||||
adapterConfig: agent.adapterConfig ?? {},
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("notifyHireApproved", () => {
|
||||
it("writes success activity when adapter hook returns ok", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({
|
||||
type: "openclaw_gateway",
|
||||
onHireApproved: vi.fn().mockResolvedValue({ ok: true }),
|
||||
} as any);
|
||||
|
||||
const db = mockDbWithAgent({
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
name: "OpenClaw Agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
});
|
||||
|
||||
await expect(
|
||||
notifyHireApproved(db, {
|
||||
companyId: "c1",
|
||||
agentId: "a1",
|
||||
source: "approval",
|
||||
sourceId: "ap1",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "hire_hook.succeeded",
|
||||
entityId: "a1",
|
||||
details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw_gateway" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing when agent is not found", async () => {
|
||||
const db = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => Promise.resolve([]),
|
||||
}),
|
||||
}),
|
||||
} as unknown as Db;
|
||||
|
||||
await expect(
|
||||
notifyHireApproved(db, {
|
||||
companyId: "c1",
|
||||
agentId: "a1",
|
||||
source: "join_request",
|
||||
sourceId: "jr1",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(findServerAdapter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when adapter has no onHireApproved", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({ type: "process" } as any);
|
||||
|
||||
const db = mockDbWithAgent({
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
name: "Agent",
|
||||
adapterType: "process",
|
||||
});
|
||||
|
||||
await expect(
|
||||
notifyHireApproved(db, {
|
||||
companyId: "c1",
|
||||
agentId: "a1",
|
||||
source: "approval",
|
||||
sourceId: "ap1",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(findServerAdapter).toHaveBeenCalledWith("process");
|
||||
expect(logActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs failed result when adapter onHireApproved returns ok=false", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({
|
||||
type: "openclaw_gateway",
|
||||
onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }),
|
||||
} as any);
|
||||
|
||||
const db = mockDbWithAgent({
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
name: "OpenClaw Agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
});
|
||||
|
||||
await expect(
|
||||
notifyHireApproved(db, {
|
||||
companyId: "c1",
|
||||
agentId: "a1",
|
||||
source: "join_request",
|
||||
sourceId: "jr1",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "hire_hook.failed",
|
||||
entityId: "a1",
|
||||
details: expect.objectContaining({ source: "join_request", sourceId: "jr1", error: "HTTP 500" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not throw when adapter onHireApproved throws (non-fatal)", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({
|
||||
type: "openclaw_gateway",
|
||||
onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")),
|
||||
} as any);
|
||||
|
||||
const db = mockDbWithAgent({
|
||||
id: "a1",
|
||||
companyId: "c1",
|
||||
name: "OpenClaw Agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
});
|
||||
|
||||
await expect(
|
||||
notifyHireApproved(db, {
|
||||
companyId: "c1",
|
||||
agentId: "a1",
|
||||
source: "join_request",
|
||||
sourceId: "jr1",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "hire_hook.error",
|
||||
entityId: "a1",
|
||||
details: expect.objectContaining({ source: "join_request", sourceId: "jr1", error: "Network error" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
119
server/src/__tests__/invite-accept-gateway-defaults.test.ts
Normal file
119
server/src/__tests__/invite-accept-gateway-defaults.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildJoinDefaultsPayloadForAccept,
|
||||
normalizeAgentDefaultsForJoin,
|
||||
} from "../routes/access.js";
|
||||
|
||||
describe("buildJoinDefaultsPayloadForAccept (openclaw_gateway)", () => {
|
||||
it("leaves non-gateway payloads unchanged", () => {
|
||||
const defaultsPayload = { command: "echo hello" };
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "process",
|
||||
defaultsPayload,
|
||||
inboundOpenClawAuthHeader: "ignored-token",
|
||||
});
|
||||
|
||||
expect(result).toEqual(defaultsPayload);
|
||||
});
|
||||
|
||||
it("normalizes wrapped x-openclaw-token header", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": {
|
||||
value: "gateway-token-1234567890",
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts inbound x-openclaw-token for gateway joins", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
},
|
||||
inboundOpenClawTokenHeader: "gateway-token-1234567890",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("derives x-openclaw-token from authorization header", () => {
|
||||
const result = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
authorization: "Bearer gateway-token-1234567890",
|
||||
},
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
headers: {
|
||||
authorization: "Bearer gateway-token-1234567890",
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAgentDefaultsForJoin (openclaw_gateway)", () => {
|
||||
it("generates persistent device key when device auth is enabled", () => {
|
||||
const normalized = normalizeAgentDefaultsForJoin({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
disableDeviceAuth: false,
|
||||
},
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(normalized.fatalErrors).toEqual([]);
|
||||
expect(normalized.normalized?.disableDeviceAuth).toBe(false);
|
||||
expect(typeof normalized.normalized?.devicePrivateKeyPem).toBe("string");
|
||||
expect((normalized.normalized?.devicePrivateKeyPem as string).length).toBeGreaterThan(64);
|
||||
});
|
||||
|
||||
it("does not generate device key when disableDeviceAuth=true", () => {
|
||||
const normalized = normalizeAgentDefaultsForJoin({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token-1234567890",
|
||||
},
|
||||
disableDeviceAuth: true,
|
||||
},
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(normalized.fatalErrors).toEqual([]);
|
||||
expect(normalized.normalized?.disableDeviceAuth).toBe(true);
|
||||
expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined();
|
||||
});
|
||||
});
|
||||
92
server/src/__tests__/invite-accept-replay.test.ts
Normal file
92
server/src/__tests__/invite-accept-replay.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildJoinDefaultsPayloadForAccept,
|
||||
canReplayOpenClawGatewayInviteAccept,
|
||||
mergeJoinDefaultsPayloadForReplay,
|
||||
} from "../routes/access.js";
|
||||
|
||||
describe("canReplayOpenClawGatewayInviteAccept", () => {
|
||||
it("allows replay only for openclaw_gateway agent joins in pending or approved state", () => {
|
||||
expect(
|
||||
canReplayOpenClawGatewayInviteAccept({
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
status: "pending_approval",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canReplayOpenClawGatewayInviteAccept({
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
status: "approved",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canReplayOpenClawGatewayInviteAccept({
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
status: "rejected",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
canReplayOpenClawGatewayInviteAccept({
|
||||
requestType: "human",
|
||||
adapterType: "openclaw_gateway",
|
||||
existingJoinRequest: {
|
||||
requestType: "agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
status: "pending_approval",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeJoinDefaultsPayloadForReplay", () => {
|
||||
it("merges replay payloads and allows gateway token override", () => {
|
||||
const merged = mergeJoinDefaultsPayloadForReplay(
|
||||
{
|
||||
url: "ws://old.example:18789",
|
||||
paperclipApiUrl: "http://host.docker.internal:3100",
|
||||
headers: {
|
||||
"x-openclaw-token": "old-token-1234567890",
|
||||
"x-custom": "keep-me",
|
||||
},
|
||||
},
|
||||
{
|
||||
paperclipApiUrl: "https://paperclip.example.com",
|
||||
headers: {
|
||||
"x-openclaw-token": "new-token-1234567890",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const normalized = buildJoinDefaultsPayloadForAccept({
|
||||
adapterType: "openclaw_gateway",
|
||||
defaultsPayload: merged,
|
||||
inboundOpenClawAuthHeader: null,
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(normalized.url).toBe("ws://old.example:18789");
|
||||
expect(normalized.paperclipApiUrl).toBe("https://paperclip.example.com");
|
||||
expect(normalized.headers).toMatchObject({
|
||||
"x-openclaw-token": "new-token-1234567890",
|
||||
"x-custom": "keep-me",
|
||||
});
|
||||
});
|
||||
});
|
||||
10
server/src/__tests__/invite-expiry.test.ts
Normal file
10
server/src/__tests__/invite-expiry.test.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { companyInviteExpiresAt } from "../routes/access.js";
|
||||
|
||||
describe("companyInviteExpiresAt", () => {
|
||||
it("sets invite expiration to 10 minutes after invite creation time", () => {
|
||||
const createdAtMs = Date.parse("2026-03-06T00:00:00.000Z");
|
||||
const expiresAt = companyInviteExpiresAt(createdAtMs);
|
||||
expect(expiresAt.toISOString()).toBe("2026-03-06T00:10:00.000Z");
|
||||
});
|
||||
});
|
||||
33
server/src/__tests__/invite-join-manager.test.ts
Normal file
33
server/src/__tests__/invite-join-manager.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveJoinRequestAgentManagerId } from "../routes/access.js";
|
||||
|
||||
describe("resolveJoinRequestAgentManagerId", () => {
|
||||
it("returns null when no CEO exists in the company agent list", () => {
|
||||
const managerId = resolveJoinRequestAgentManagerId([
|
||||
{ id: "a1", role: "cto", reportsTo: null },
|
||||
{ id: "a2", role: "engineer", reportsTo: "a1" },
|
||||
]);
|
||||
|
||||
expect(managerId).toBeNull();
|
||||
});
|
||||
|
||||
it("selects the root CEO when available", () => {
|
||||
const managerId = resolveJoinRequestAgentManagerId([
|
||||
{ id: "ceo-child", role: "ceo", reportsTo: "manager-1" },
|
||||
{ id: "manager-1", role: "cto", reportsTo: null },
|
||||
{ id: "ceo-root", role: "ceo", reportsTo: null },
|
||||
]);
|
||||
|
||||
expect(managerId).toBe("ceo-root");
|
||||
});
|
||||
|
||||
it("falls back to the first CEO when no root CEO is present", () => {
|
||||
const managerId = resolveJoinRequestAgentManagerId([
|
||||
{ id: "ceo-1", role: "ceo", reportsTo: "mgr" },
|
||||
{ id: "ceo-2", role: "ceo", reportsTo: "mgr" },
|
||||
{ id: "mgr", role: "cto", reportsTo: null },
|
||||
]);
|
||||
|
||||
expect(managerId).toBe("ceo-1");
|
||||
});
|
||||
});
|
||||
|
|
@ -37,10 +37,22 @@ describe("buildInviteOnboardingTextDocument", () => {
|
|||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(text).toContain("Paperclip OpenClaw Onboarding");
|
||||
expect(text).toContain("Paperclip OpenClaw Gateway Onboarding");
|
||||
expect(text).toContain("/api/invites/token-123/accept");
|
||||
expect(text).toContain("/api/join-requests/{requestId}/claim-api-key");
|
||||
expect(text).toContain("/api/invites/token-123/onboarding.txt");
|
||||
expect(text).toContain("Suggested Paperclip base URLs to try");
|
||||
expect(text).toContain("http://localhost:3100");
|
||||
expect(text).toContain("host.docker.internal");
|
||||
expect(text).toContain("paperclipApiUrl");
|
||||
expect(text).toContain("adapterType \"openclaw_gateway\"");
|
||||
expect(text).toContain("headers.x-openclaw-token");
|
||||
expect(text).toContain("Do NOT use /v1/responses or /hooks/*");
|
||||
expect(text).toContain("set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl");
|
||||
expect(text).toContain("~/.openclaw/workspace/paperclip-claimed-api-key.json");
|
||||
expect(text).toContain("PAPERCLIP_API_KEY");
|
||||
expect(text).toContain("saved token field");
|
||||
expect(text).toContain("Gateway token unexpectedly short");
|
||||
});
|
||||
|
||||
it("includes loopback diagnostics for authenticated/private onboarding", () => {
|
||||
|
|
@ -69,5 +81,36 @@ describe("buildInviteOnboardingTextDocument", () => {
|
|||
|
||||
expect(text).toContain("Connectivity diagnostics");
|
||||
expect(text).toContain("loopback hostname");
|
||||
expect(text).toContain("If none are reachable");
|
||||
});
|
||||
|
||||
it("includes inviter message in the onboarding text when provided", () => {
|
||||
const req = buildReq("localhost:3100");
|
||||
const invite = {
|
||||
id: "invite-3",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "agent",
|
||||
tokenHash: "hash",
|
||||
defaultsPayload: {
|
||||
agentMessage: "Please join as our QA lead and prioritize flaky test triage first.",
|
||||
},
|
||||
expiresAt: new Date("2026-03-05T00:00:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-04T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-04T00:00:00.000Z"),
|
||||
} as const;
|
||||
|
||||
const text = buildInviteOnboardingTextDocument(req, "token-789", invite as any, {
|
||||
deploymentMode: "local_trusted",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
});
|
||||
|
||||
expect(text).toContain("Message from inviter");
|
||||
expect(text).toContain("prioritize flaky test triage first");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
48
server/src/__tests__/issues-checkout-wakeup.test.ts
Normal file
48
server/src/__tests__/issues-checkout-wakeup.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { shouldWakeAssigneeOnCheckout } from "../routes/issues-checkout-wakeup.js";
|
||||
|
||||
describe("shouldWakeAssigneeOnCheckout", () => {
|
||||
it("keeps wakeup behavior for board actors", () => {
|
||||
expect(
|
||||
shouldWakeAssigneeOnCheckout({
|
||||
actorType: "board",
|
||||
actorAgentId: null,
|
||||
checkoutAgentId: "agent-1",
|
||||
checkoutRunId: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("skips wakeup for agent self-checkout in an active run", () => {
|
||||
expect(
|
||||
shouldWakeAssigneeOnCheckout({
|
||||
actorType: "agent",
|
||||
actorAgentId: "agent-1",
|
||||
checkoutAgentId: "agent-1",
|
||||
checkoutRunId: "run-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("still wakes when checkout run id is missing", () => {
|
||||
expect(
|
||||
shouldWakeAssigneeOnCheckout({
|
||||
actorType: "agent",
|
||||
actorAgentId: "agent-1",
|
||||
checkoutAgentId: "agent-1",
|
||||
checkoutRunId: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("still wakes when agent checks out on behalf of another agent id", () => {
|
||||
expect(
|
||||
shouldWakeAssigneeOnCheckout({
|
||||
actorType: "agent",
|
||||
actorAgentId: "agent-1",
|
||||
checkoutAgentId: "agent-2",
|
||||
checkoutRunId: "run-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
113
server/src/__tests__/issues-user-context.test.ts
Normal file
113
server/src/__tests__/issues-user-context.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { deriveIssueUserContext } from "../services/issues.ts";
|
||||
|
||||
function makeIssue(overrides?: Partial<{
|
||||
createdByUserId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>) {
|
||||
return {
|
||||
createdByUserId: null,
|
||||
assigneeUserId: null,
|
||||
createdAt: new Date("2026-03-06T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-06T11:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveIssueUserContext", () => {
|
||||
it("marks issue unread when external comments are newer than my latest comment", () => {
|
||||
const context = deriveIssueUserContext(
|
||||
makeIssue({ createdByUserId: "user-1" }),
|
||||
"user-1",
|
||||
{
|
||||
myLastCommentAt: new Date("2026-03-06T12:00:00.000Z"),
|
||||
myLastReadAt: null,
|
||||
lastExternalCommentAt: new Date("2026-03-06T13:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T12:00:00.000Z");
|
||||
expect(context.lastExternalCommentAt?.toISOString()).toBe("2026-03-06T13:00:00.000Z");
|
||||
expect(context.isUnreadForMe).toBe(true);
|
||||
});
|
||||
|
||||
it("marks issue read when my latest comment is newest", () => {
|
||||
const context = deriveIssueUserContext(
|
||||
makeIssue({ createdByUserId: "user-1" }),
|
||||
"user-1",
|
||||
{
|
||||
myLastCommentAt: new Date("2026-03-06T14:00:00.000Z"),
|
||||
myLastReadAt: null,
|
||||
lastExternalCommentAt: new Date("2026-03-06T13:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(context.isUnreadForMe).toBe(false);
|
||||
});
|
||||
|
||||
it("uses issue creation time as fallback touch point for creator", () => {
|
||||
const context = deriveIssueUserContext(
|
||||
makeIssue({ createdByUserId: "user-1", createdAt: new Date("2026-03-06T09:00:00.000Z") }),
|
||||
"user-1",
|
||||
{
|
||||
myLastCommentAt: null,
|
||||
myLastReadAt: null,
|
||||
lastExternalCommentAt: new Date("2026-03-06T10:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T09:00:00.000Z");
|
||||
expect(context.isUnreadForMe).toBe(true);
|
||||
});
|
||||
|
||||
it("uses issue updated time as fallback touch point for assignee", () => {
|
||||
const context = deriveIssueUserContext(
|
||||
makeIssue({ assigneeUserId: "user-1", updatedAt: new Date("2026-03-06T15:00:00.000Z") }),
|
||||
"user-1",
|
||||
{
|
||||
myLastCommentAt: null,
|
||||
myLastReadAt: null,
|
||||
lastExternalCommentAt: new Date("2026-03-06T14:59:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T15:00:00.000Z");
|
||||
expect(context.isUnreadForMe).toBe(false);
|
||||
});
|
||||
|
||||
it("uses latest read timestamp to clear unread without requiring a comment", () => {
|
||||
const context = deriveIssueUserContext(
|
||||
makeIssue({ createdByUserId: "user-1", createdAt: new Date("2026-03-06T09:00:00.000Z") }),
|
||||
"user-1",
|
||||
{
|
||||
myLastCommentAt: null,
|
||||
myLastReadAt: new Date("2026-03-06T11:30:00.000Z"),
|
||||
lastExternalCommentAt: new Date("2026-03-06T11:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T11:30:00.000Z");
|
||||
expect(context.isUnreadForMe).toBe(false);
|
||||
});
|
||||
|
||||
it("handles SQL timestamp strings without throwing", () => {
|
||||
const context = deriveIssueUserContext(
|
||||
makeIssue({
|
||||
createdByUserId: "user-1",
|
||||
createdAt: new Date("2026-03-06T09:00:00.000Z"),
|
||||
}),
|
||||
"user-1",
|
||||
{
|
||||
myLastCommentAt: "2026-03-06T10:00:00.000Z",
|
||||
myLastReadAt: null,
|
||||
lastExternalCommentAt: "2026-03-06T11:00:00.000Z",
|
||||
},
|
||||
);
|
||||
|
||||
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T10:00:00.000Z");
|
||||
expect(context.lastExternalCommentAt?.toISOString()).toBe("2026-03-06T11:00:00.000Z");
|
||||
expect(context.isUnreadForMe).toBe(true);
|
||||
});
|
||||
});
|
||||
493
server/src/__tests__/openclaw-gateway-adapter.test.ts
Normal file
493
server/src/__tests__/openclaw-gateway-adapter.test.ts
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createServer } from "node:http";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server";
|
||||
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
|
||||
function buildContext(
|
||||
config: Record<string, unknown>,
|
||||
overrides?: Partial<AdapterExecutionContext>,
|
||||
): AdapterExecutionContext {
|
||||
return {
|
||||
runId: "run-123",
|
||||
agent: {
|
||||
id: "agent-123",
|
||||
companyId: "company-123",
|
||||
name: "OpenClaw Gateway Agent",
|
||||
adapterType: "openclaw_gateway",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config,
|
||||
context: {
|
||||
taskId: "task-123",
|
||||
issueId: "issue-123",
|
||||
wakeReason: "issue_assigned",
|
||||
issueIds: ["issue-123"],
|
||||
},
|
||||
onLog: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function createMockGatewayServer() {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
let agentPayload: Record<string, unknown> | null = null;
|
||||
|
||||
wss.on("connection", (socket) => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "connect.challenge",
|
||||
payload: { nonce: "nonce-123" },
|
||||
}),
|
||||
);
|
||||
|
||||
socket.on("message", (raw) => {
|
||||
const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
|
||||
const frame = JSON.parse(text) as {
|
||||
type: string;
|
||||
id: string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (frame.type !== "req") return;
|
||||
|
||||
if (frame.method === "connect") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
type: "hello-ok",
|
||||
protocol: 3,
|
||||
server: { version: "test", connId: "conn-1" },
|
||||
features: { methods: ["connect", "agent", "agent.wait"], events: ["agent"] },
|
||||
snapshot: { version: 1, ts: Date.now() },
|
||||
policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "agent") {
|
||||
agentPayload = frame.params ?? null;
|
||||
const runId =
|
||||
typeof frame.params?.idempotencyKey === "string"
|
||||
? frame.params.idempotencyKey
|
||||
: "run-123";
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
runId,
|
||||
status: "accepted",
|
||||
acceptedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId,
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { delta: "cha" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId,
|
||||
seq: 2,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { delta: "chacha" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "agent.wait") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
runId: frame.params?.runId,
|
||||
status: "ok",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Failed to resolve test server address");
|
||||
}
|
||||
|
||||
return {
|
||||
url: `ws://127.0.0.1:${address.port}`,
|
||||
getAgentPayload: () => agentPayload,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createMockGatewayServerWithPairing() {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
let agentPayload: Record<string, unknown> | null = null;
|
||||
let approved = false;
|
||||
let pendingRequestId = "req-1";
|
||||
let lastSeenDeviceId: string | null = null;
|
||||
|
||||
wss.on("connection", (socket) => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "connect.challenge",
|
||||
payload: { nonce: "nonce-123" },
|
||||
}),
|
||||
);
|
||||
|
||||
socket.on("message", (raw) => {
|
||||
const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
|
||||
const frame = JSON.parse(text) as {
|
||||
type: string;
|
||||
id: string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (frame.type !== "req") return;
|
||||
|
||||
if (frame.method === "connect") {
|
||||
const device = frame.params?.device as Record<string, unknown> | undefined;
|
||||
const deviceId = typeof device?.id === "string" ? device.id : null;
|
||||
if (deviceId) {
|
||||
lastSeenDeviceId = deviceId;
|
||||
}
|
||||
|
||||
if (deviceId && !approved) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "NOT_PAIRED",
|
||||
message: "pairing required",
|
||||
details: {
|
||||
code: "PAIRING_REQUIRED",
|
||||
requestId: pendingRequestId,
|
||||
reason: "not-paired",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
socket.close(1008, "pairing required");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
type: "hello-ok",
|
||||
protocol: 3,
|
||||
server: { version: "test", connId: "conn-1" },
|
||||
features: {
|
||||
methods: ["connect", "agent", "agent.wait", "device.pair.list", "device.pair.approve"],
|
||||
events: ["agent"],
|
||||
},
|
||||
snapshot: { version: 1, ts: Date.now() },
|
||||
policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "device.pair.list") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
pending: approved
|
||||
? []
|
||||
: [
|
||||
{
|
||||
requestId: pendingRequestId,
|
||||
deviceId: lastSeenDeviceId ?? "device-unknown",
|
||||
},
|
||||
],
|
||||
paired: approved && lastSeenDeviceId ? [{ deviceId: lastSeenDeviceId }] : [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "device.pair.approve") {
|
||||
const requestId = frame.params?.requestId;
|
||||
if (requestId !== pendingRequestId) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: { code: "INVALID_REQUEST", message: "unknown requestId" },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
approved = true;
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
requestId: pendingRequestId,
|
||||
device: {
|
||||
deviceId: lastSeenDeviceId ?? "device-unknown",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "agent") {
|
||||
agentPayload = frame.params ?? null;
|
||||
const runId =
|
||||
typeof frame.params?.idempotencyKey === "string"
|
||||
? frame.params.idempotencyKey
|
||||
: "run-123";
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
runId,
|
||||
status: "accepted",
|
||||
acceptedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId,
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { delta: "ok" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "agent.wait") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
runId: frame.params?.runId,
|
||||
status: "ok",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Failed to resolve test server address");
|
||||
}
|
||||
|
||||
return {
|
||||
url: `ws://127.0.0.1:${address.port}`,
|
||||
getAgentPayload: () => agentPayload,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// no global mocks
|
||||
});
|
||||
|
||||
describe("openclaw gateway ui stdout parser", () => {
|
||||
it("parses assistant deltas from gateway event lines", () => {
|
||||
const ts = "2026-03-06T15:00:00.000Z";
|
||||
const line =
|
||||
'[openclaw-gateway:event] run=run-1 stream=assistant data={"delta":"hello"}';
|
||||
|
||||
expect(parseOpenClawGatewayStdoutLine(line, ts)).toEqual([
|
||||
{
|
||||
kind: "assistant",
|
||||
ts,
|
||||
text: "hello",
|
||||
delta: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openclaw gateway adapter execute", () => {
|
||||
it("runs connect -> agent -> agent.wait and forwards wake payload", async () => {
|
||||
const gateway = await createMockGatewayServer();
|
||||
const logs: string[] = [];
|
||||
|
||||
try {
|
||||
const result = await execute(
|
||||
buildContext(
|
||||
{
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
payloadTemplate: {
|
||||
message: "wake now",
|
||||
},
|
||||
waitTimeoutMs: 2000,
|
||||
},
|
||||
{
|
||||
onLog: async (_stream, chunk) => {
|
||||
logs.push(chunk);
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.summary).toContain("chachacha");
|
||||
expect(result.provider).toBe("openclaw");
|
||||
|
||||
const payload = gateway.getAgentPayload();
|
||||
expect(payload).toBeTruthy();
|
||||
expect(payload?.idempotencyKey).toBe("run-123");
|
||||
expect(payload?.sessionKey).toBe("paperclip:issue:issue-123");
|
||||
expect(String(payload?.message ?? "")).toContain("wake now");
|
||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
|
||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
|
||||
|
||||
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
|
||||
} finally {
|
||||
await gateway.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails fast when url is missing", async () => {
|
||||
const result = await execute(buildContext({}));
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
|
||||
});
|
||||
|
||||
it("auto-approves pairing once and retries the run", async () => {
|
||||
const gateway = await createMockGatewayServerWithPairing();
|
||||
const logs: string[] = [];
|
||||
|
||||
try {
|
||||
const result = await execute(
|
||||
buildContext(
|
||||
{
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
payloadTemplate: {
|
||||
message: "wake now",
|
||||
},
|
||||
waitTimeoutMs: 2000,
|
||||
},
|
||||
{
|
||||
onLog: async (_stream, chunk) => {
|
||||
logs.push(chunk);
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.summary).toContain("ok");
|
||||
expect(logs.some((entry) => entry.includes("pairing required; attempting automatic pairing approval"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(logs.some((entry) => entry.includes("auto-approved pairing request"))).toBe(true);
|
||||
expect(gateway.getAgentPayload()).toBeTruthy();
|
||||
} finally {
|
||||
await gateway.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("openclaw gateway testEnvironment", () => {
|
||||
it("reports missing url as failure", async () => {
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-123",
|
||||
adapterType: "openclaw_gateway",
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.checks.some((check) => check.code === "openclaw_gateway_url_missing")).toBe(true);
|
||||
});
|
||||
});
|
||||
181
server/src/__tests__/openclaw-invite-prompt-route.test.ts
Normal file
181
server/src/__tests__/openclaw-invite-prompt-route.test.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
hasPermission: vi.fn(),
|
||||
canUser: vi.fn(),
|
||||
isInstanceAdmin: vi.fn(),
|
||||
getMembership: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
listMembers: vi.fn(),
|
||||
setMemberPermissions: vi.fn(),
|
||||
promoteInstanceAdmin: vi.fn(),
|
||||
demoteInstanceAdmin: vi.fn(),
|
||||
listUserCompanyAccess: vi.fn(),
|
||||
setUserCompanyAccess: vi.fn(),
|
||||
setPrincipalGrants: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
deduplicateAgentName: vi.fn(),
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
}));
|
||||
|
||||
function createDbStub() {
|
||||
const createdInvite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "agent",
|
||||
defaultsPayload: null,
|
||||
expiresAt: new Date("2026-03-07T00:10:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
tokenHash: "hash",
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
};
|
||||
const returning = vi.fn().mockResolvedValue([createdInvite]);
|
||||
const values = vi.fn().mockReturnValue({ returning });
|
||||
const insert = vi.fn().mockReturnValue({ values });
|
||||
return {
|
||||
insert,
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
"/api",
|
||||
accessRoutes(db as any, {
|
||||
deploymentMode: "local_trusted",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
beforeEach(() => {
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAgentService.getById.mockReset();
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("rejects non-CEO agent callers", async () => {
|
||||
const db = createDbStub();
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
role: "engineer",
|
||||
});
|
||||
const app = createApp(
|
||||
{
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/openclaw/invite-prompt")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("Only CEO agents");
|
||||
});
|
||||
|
||||
it("allows CEO agent callers and creates an agent-only invite", async () => {
|
||||
const db = createDbStub();
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
role: "ceo",
|
||||
});
|
||||
const app = createApp(
|
||||
{
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/openclaw/invite-prompt")
|
||||
.send({ agentMessage: "Join and configure OpenClaw gateway." });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.allowedJoinTypes).toBe("agent");
|
||||
expect(typeof res.body.token).toBe("string");
|
||||
expect(res.body.onboardingTextPath).toContain("/api/invites/");
|
||||
});
|
||||
|
||||
it("allows board callers with invite permission", async () => {
|
||||
const db = createDbStub();
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/openclaw/invite-prompt")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.allowedJoinTypes).toBe("agent");
|
||||
});
|
||||
|
||||
it("rejects board callers without invite permission", async () => {
|
||||
const db = createDbStub();
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/openclaw/invite-prompt")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe("Permission denied");
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,7 @@ import path from "node:path";
|
|||
import { testEnvironment } from "@paperclipai/adapter-opencode-local/server";
|
||||
|
||||
describe("opencode_local environment diagnostics", () => {
|
||||
it("creates a missing working directory when cwd is absolute", async () => {
|
||||
it("reports a missing working directory as an error when cwd is absolute", async () => {
|
||||
const cwd = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-opencode-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
|
|
@ -23,11 +23,9 @@ describe("opencode_local environment diagnostics", () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "opencode_cwd_valid")).toBe(true);
|
||||
expect(result.checks.some((check) => check.level === "error")).toBe(false);
|
||||
const stats = await fs.stat(cwd);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||
expect(result.checks.some((check) => check.code === "opencode_cwd_invalid")).toBe(true);
|
||||
expect(result.checks.some((check) => check.level === "error")).toBe(true);
|
||||
expect(result.status).toBe("fail");
|
||||
});
|
||||
|
||||
it("treats an empty OPENAI_API_KEY override as missing", async () => {
|
||||
|
|
|
|||
45
server/src/__tests__/project-shortname-resolution.test.ts
Normal file
45
server/src/__tests__/project-shortname-resolution.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveProjectNameForUniqueShortname } from "../services/projects.ts";
|
||||
|
||||
describe("resolveProjectNameForUniqueShortname", () => {
|
||||
it("keeps name when shortname is not used", () => {
|
||||
const resolved = resolveProjectNameForUniqueShortname("Platform", [
|
||||
{ id: "p1", name: "Growth" },
|
||||
]);
|
||||
expect(resolved).toBe("Platform");
|
||||
});
|
||||
|
||||
it("appends numeric suffix when shortname collides", () => {
|
||||
const resolved = resolveProjectNameForUniqueShortname("Growth Team", [
|
||||
{ id: "p1", name: "growth-team" },
|
||||
]);
|
||||
expect(resolved).toBe("Growth Team 2");
|
||||
});
|
||||
|
||||
it("increments suffix until unique", () => {
|
||||
const resolved = resolveProjectNameForUniqueShortname("Growth Team", [
|
||||
{ id: "p1", name: "growth-team" },
|
||||
{ id: "p2", name: "growth-team-2" },
|
||||
]);
|
||||
expect(resolved).toBe("Growth Team 3");
|
||||
});
|
||||
|
||||
it("ignores excluded project id", () => {
|
||||
const resolved = resolveProjectNameForUniqueShortname(
|
||||
"Growth Team",
|
||||
[
|
||||
{ id: "p1", name: "growth-team" },
|
||||
{ id: "p2", name: "platform" },
|
||||
],
|
||||
{ excludeProjectId: "p1" },
|
||||
);
|
||||
expect(resolved).toBe("Growth Team");
|
||||
});
|
||||
|
||||
it("keeps non-normalizable names unchanged", () => {
|
||||
const resolved = resolveProjectNameForUniqueShortname("!!!", [
|
||||
{ id: "p1", name: "growth" },
|
||||
]);
|
||||
expect(resolved).toBe("!!!");
|
||||
});
|
||||
});
|
||||
|
|
@ -18,21 +18,34 @@ import {
|
|||
} from "@paperclipai/adapter-cursor-local/server";
|
||||
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local";
|
||||
import {
|
||||
execute as opencodeExecute,
|
||||
testEnvironment as opencodeTestEnvironment,
|
||||
sessionCodec as opencodeSessionCodec,
|
||||
execute as openCodeExecute,
|
||||
testEnvironment as openCodeTestEnvironment,
|
||||
sessionCodec as openCodeSessionCodec,
|
||||
listOpenCodeModels,
|
||||
} from "@paperclipai/adapter-opencode-local/server";
|
||||
import { agentConfigurationDoc as opencodeAgentConfigurationDoc, models as opencodeModels } from "@paperclipai/adapter-opencode-local";
|
||||
import {
|
||||
execute as openclawExecute,
|
||||
testEnvironment as openclawTestEnvironment,
|
||||
} from "@paperclipai/adapter-openclaw/server";
|
||||
agentConfigurationDoc as openCodeAgentConfigurationDoc,
|
||||
models as openCodeModels,
|
||||
} from "@paperclipai/adapter-opencode-local";
|
||||
import {
|
||||
agentConfigurationDoc as openclawAgentConfigurationDoc,
|
||||
models as openclawModels,
|
||||
} from "@paperclipai/adapter-openclaw";
|
||||
execute as openclawGatewayExecute,
|
||||
testEnvironment as openclawGatewayTestEnvironment,
|
||||
} from "@paperclipai/adapter-openclaw-gateway/server";
|
||||
import {
|
||||
agentConfigurationDoc as openclawGatewayAgentConfigurationDoc,
|
||||
models as openclawGatewayModels,
|
||||
} from "@paperclipai/adapter-openclaw-gateway";
|
||||
import { listCodexModels } from "./codex-models.js";
|
||||
import { listCursorModels } from "./cursor-models.js";
|
||||
import {
|
||||
execute as piExecute,
|
||||
testEnvironment as piTestEnvironment,
|
||||
sessionCodec as piSessionCodec,
|
||||
listPiModels,
|
||||
} from "@paperclipai/adapter-pi-local/server";
|
||||
import {
|
||||
agentConfigurationDoc as piAgentConfigurationDoc,
|
||||
} from "@paperclipai/adapter-pi-local";
|
||||
import { processAdapter } from "./process/index.js";
|
||||
import { httpAdapter } from "./http/index.js";
|
||||
|
||||
|
|
@ -57,16 +70,6 @@ const codexLocalAdapter: ServerAdapterModule = {
|
|||
agentConfigurationDoc: codexAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const opencodeLocalAdapter: ServerAdapterModule = {
|
||||
type: "opencode_local",
|
||||
execute: opencodeExecute,
|
||||
testEnvironment: opencodeTestEnvironment,
|
||||
sessionCodec: opencodeSessionCodec,
|
||||
models: opencodeModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: opencodeAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const cursorLocalAdapter: ServerAdapterModule = {
|
||||
type: "cursor",
|
||||
execute: cursorExecute,
|
||||
|
|
@ -78,17 +81,48 @@ const cursorLocalAdapter: ServerAdapterModule = {
|
|||
agentConfigurationDoc: cursorAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const openclawAdapter: ServerAdapterModule = {
|
||||
type: "openclaw",
|
||||
execute: openclawExecute,
|
||||
testEnvironment: openclawTestEnvironment,
|
||||
models: openclawModels,
|
||||
const openclawGatewayAdapter: ServerAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
execute: openclawGatewayExecute,
|
||||
testEnvironment: openclawGatewayTestEnvironment,
|
||||
models: openclawGatewayModels,
|
||||
supportsLocalAgentJwt: false,
|
||||
agentConfigurationDoc: openclawAgentConfigurationDoc,
|
||||
agentConfigurationDoc: openclawGatewayAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const openCodeLocalAdapter: ServerAdapterModule = {
|
||||
type: "opencode_local",
|
||||
execute: openCodeExecute,
|
||||
testEnvironment: openCodeTestEnvironment,
|
||||
sessionCodec: openCodeSessionCodec,
|
||||
models: openCodeModels,
|
||||
listModels: listOpenCodeModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: openCodeAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const piLocalAdapter: ServerAdapterModule = {
|
||||
type: "pi_local",
|
||||
execute: piExecute,
|
||||
testEnvironment: piTestEnvironment,
|
||||
sessionCodec: piSessionCodec,
|
||||
models: [],
|
||||
listModels: listPiModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: piAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
[claudeLocalAdapter, codexLocalAdapter, opencodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
|
||||
[
|
||||
claudeLocalAdapter,
|
||||
codexLocalAdapter,
|
||||
openCodeLocalAdapter,
|
||||
piLocalAdapter,
|
||||
cursorLocalAdapter,
|
||||
openclawGatewayAdapter,
|
||||
processAdapter,
|
||||
httpAdapter,
|
||||
].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getServerAdapter(type: string): ServerAdapterModule {
|
||||
|
|
|
|||
|
|
@ -121,6 +121,9 @@ export async function createApp(
|
|||
}),
|
||||
);
|
||||
app.use("/api", api);
|
||||
app.use("/api", (_req, res) => {
|
||||
res.status(404).json({ error: "API route not found" });
|
||||
});
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
if (opts.uiMode === "static") {
|
||||
|
|
@ -131,9 +134,10 @@ export async function createApp(
|
|||
];
|
||||
const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html")));
|
||||
if (uiDist) {
|
||||
const indexHtml = fs.readFileSync(path.join(uiDist, "index.html"), "utf-8");
|
||||
app.use(express.static(uiDist));
|
||||
app.get(/.*/, (_req, res) => {
|
||||
res.sendFile(path.join(uiDist, "index.html"));
|
||||
res.status(200).set("Content-Type", "text/html").end(indexHtml);
|
||||
});
|
||||
} else {
|
||||
console.warn("[paperclip] UI dist not found; running in API-only mode");
|
||||
|
|
|
|||
|
|
@ -42,13 +42,38 @@ function headersFromExpressRequest(req: Request): Headers {
|
|||
return headersFromNodeHeaders(req.headers);
|
||||
}
|
||||
|
||||
export function createBetterAuthInstance(db: Db, config: Config): BetterAuthInstance {
|
||||
export function deriveAuthTrustedOrigins(config: Config): string[] {
|
||||
const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined;
|
||||
const trustedOrigins = new Set<string>();
|
||||
|
||||
if (baseUrl) {
|
||||
try {
|
||||
trustedOrigins.add(new URL(baseUrl).origin);
|
||||
} catch {
|
||||
// Better Auth will surface invalid base URL separately.
|
||||
}
|
||||
}
|
||||
if (config.deploymentMode === "authenticated") {
|
||||
for (const hostname of config.allowedHostnames) {
|
||||
const trimmed = hostname.trim().toLowerCase();
|
||||
if (!trimmed) continue;
|
||||
trustedOrigins.add(`https://${trimmed}`);
|
||||
trustedOrigins.add(`http://${trimmed}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(trustedOrigins);
|
||||
}
|
||||
|
||||
export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins?: string[]): BetterAuthInstance {
|
||||
const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined;
|
||||
const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET ?? "paperclip-dev-secret";
|
||||
const effectiveTrustedOrigins = trustedOrigins ?? deriveAuthTrustedOrigins(config);
|
||||
|
||||
const authConfig = {
|
||||
baseURL: baseUrl,
|
||||
secret,
|
||||
trustedOrigins: effectiveTrustedOrigins,
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: {
|
||||
|
|
@ -61,6 +86,7 @@ export function createBetterAuthInstance(db: Db, config: Config): BetterAuthInst
|
|||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false,
|
||||
disableSignUp: config.authDisableSignUp,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export interface Config {
|
|||
allowedHostnames: string[];
|
||||
authBaseUrlMode: AuthBaseUrlMode;
|
||||
authPublicBaseUrl: string | undefined;
|
||||
authDisableSignUp: boolean;
|
||||
databaseMode: DatabaseMode;
|
||||
databaseUrl: string | undefined;
|
||||
embeddedPostgresDataDir: string;
|
||||
|
|
@ -130,15 +131,23 @@ export function loadConfig(): Config {
|
|||
AUTH_BASE_URL_MODES.includes(authBaseUrlModeFromEnvRaw as AuthBaseUrlMode)
|
||||
? (authBaseUrlModeFromEnvRaw as AuthBaseUrlMode)
|
||||
: null;
|
||||
const publicUrlFromEnv = process.env.PAPERCLIP_PUBLIC_URL;
|
||||
const authPublicBaseUrlRaw =
|
||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
|
||||
process.env.BETTER_AUTH_URL ??
|
||||
process.env.BETTER_AUTH_BASE_URL ??
|
||||
publicUrlFromEnv ??
|
||||
fileConfig?.auth?.publicBaseUrl;
|
||||
const authPublicBaseUrl = authPublicBaseUrlRaw?.trim() || undefined;
|
||||
const authBaseUrlMode: AuthBaseUrlMode =
|
||||
authBaseUrlModeFromEnv ??
|
||||
fileConfig?.auth?.baseUrlMode ??
|
||||
(authPublicBaseUrl ? "explicit" : "auto");
|
||||
const disableSignUpFromEnv = process.env.PAPERCLIP_AUTH_DISABLE_SIGN_UP;
|
||||
const authDisableSignUp: boolean =
|
||||
disableSignUpFromEnv !== undefined
|
||||
? disableSignUpFromEnv === "true"
|
||||
: (fileConfig?.auth?.disableSignUp ?? false);
|
||||
const allowedHostnamesFromEnvRaw = process.env.PAPERCLIP_ALLOWED_HOSTNAMES;
|
||||
const allowedHostnamesFromEnv = allowedHostnamesFromEnvRaw
|
||||
? allowedHostnamesFromEnvRaw
|
||||
|
|
@ -146,8 +155,24 @@ export function loadConfig(): Config {
|
|||
.map((value) => value.trim().toLowerCase())
|
||||
.filter((value) => value.length > 0)
|
||||
: null;
|
||||
const publicUrlHostname = authPublicBaseUrl
|
||||
? (() => {
|
||||
try {
|
||||
return new URL(authPublicBaseUrl).hostname.trim().toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
const allowedHostnames = Array.from(
|
||||
new Set((allowedHostnamesFromEnv ?? fileConfig?.server.allowedHostnames ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean)),
|
||||
new Set(
|
||||
[
|
||||
...(allowedHostnamesFromEnv ?? fileConfig?.server.allowedHostnames ?? []),
|
||||
...(publicUrlHostname ? [publicUrlHostname] : []),
|
||||
]
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
const companyDeletionEnvRaw = process.env.PAPERCLIP_ENABLE_COMPANY_DELETION;
|
||||
const companyDeletionEnabled =
|
||||
|
|
@ -184,6 +209,7 @@ export function loadConfig(): Config {
|
|||
allowedHostnames,
|
||||
authBaseUrlMode,
|
||||
authPublicBaseUrl,
|
||||
authDisableSignUp,
|
||||
databaseMode: fileDatabaseMode,
|
||||
databaseUrl: process.env.DATABASE_URL ?? fileDbUrl,
|
||||
embeddedPostgresDataDir: resolveHomeAwarePath(
|
||||
|
|
|
|||
|
|
@ -412,6 +412,7 @@ if (config.deploymentMode === "authenticated") {
|
|||
const {
|
||||
createBetterAuthHandler,
|
||||
createBetterAuthInstance,
|
||||
deriveAuthTrustedOrigins,
|
||||
resolveBetterAuthSession,
|
||||
resolveBetterAuthSessionFromHeaders,
|
||||
} = await import("./auth/better-auth.js");
|
||||
|
|
@ -422,7 +423,25 @@ if (config.deploymentMode === "authenticated") {
|
|||
"authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET) to be set",
|
||||
);
|
||||
}
|
||||
const auth = createBetterAuthInstance(db as any, config);
|
||||
const derivedTrustedOrigins = deriveAuthTrustedOrigins(config);
|
||||
const envTrustedOrigins = (process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
const effectiveTrustedOrigins = Array.from(new Set([...derivedTrustedOrigins, ...envTrustedOrigins]));
|
||||
logger.info(
|
||||
{
|
||||
authBaseUrlMode: config.authBaseUrlMode,
|
||||
authPublicBaseUrl: config.authPublicBaseUrl ?? null,
|
||||
trustedOrigins: effectiveTrustedOrigins,
|
||||
trustedOriginsSource: {
|
||||
derived: derivedTrustedOrigins.length,
|
||||
env: envTrustedOrigins.length,
|
||||
},
|
||||
},
|
||||
"Authenticated mode auth origin configuration",
|
||||
);
|
||||
const auth = createBetterAuthInstance(db as any, config, effectiveTrustedOrigins);
|
||||
betterAuthHandler = createBetterAuthHandler(auth);
|
||||
resolveSession = (req) => resolveBetterAuthSession(auth, req);
|
||||
resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers);
|
||||
|
|
@ -444,7 +463,7 @@ const app = await createApp(db as any, {
|
|||
betterAuthHandler,
|
||||
resolveSession,
|
||||
});
|
||||
const server = createServer(app);
|
||||
const server = createServer(app as unknown as Parameters<typeof createServer>[0]);
|
||||
const listenPort = await detectPort(config.port);
|
||||
|
||||
if (listenPort !== config.port) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,35 @@
|
|||
import type { Request, Response, NextFunction } from "express";
|
||||
import { ZodError } from "zod";
|
||||
import { logger } from "./logger.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
|
||||
export interface ErrorContext {
|
||||
error: { message: string; stack?: string; name?: string; details?: unknown; raw?: unknown };
|
||||
method: string;
|
||||
url: string;
|
||||
reqBody?: unknown;
|
||||
reqParams?: unknown;
|
||||
reqQuery?: unknown;
|
||||
}
|
||||
|
||||
function attachErrorContext(
|
||||
req: Request,
|
||||
res: Response,
|
||||
payload: ErrorContext["error"],
|
||||
rawError?: Error,
|
||||
) {
|
||||
(res as any).__errorContext = {
|
||||
error: payload,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
reqBody: req.body,
|
||||
reqParams: req.params,
|
||||
reqQuery: req.query,
|
||||
} satisfies ErrorContext;
|
||||
if (rawError) {
|
||||
(res as any).err = rawError;
|
||||
}
|
||||
}
|
||||
|
||||
export function errorHandler(
|
||||
err: unknown,
|
||||
req: Request,
|
||||
|
|
@ -10,6 +37,14 @@ export function errorHandler(
|
|||
_next: NextFunction,
|
||||
) {
|
||||
if (err instanceof HttpError) {
|
||||
if (err.status >= 500) {
|
||||
attachErrorContext(
|
||||
req,
|
||||
res,
|
||||
{ message: err.message, stack: err.stack, name: err.name, details: err.details },
|
||||
err,
|
||||
);
|
||||
}
|
||||
res.status(err.status).json({
|
||||
error: err.message,
|
||||
...(err.details ? { details: err.details } : {}),
|
||||
|
|
@ -22,19 +57,15 @@ export function errorHandler(
|
|||
return;
|
||||
}
|
||||
|
||||
const errObj = err instanceof Error
|
||||
? { message: err.message, stack: err.stack, name: err.name }
|
||||
: { raw: err };
|
||||
|
||||
// Attach the real error so pino-http can include it in its response log
|
||||
res.locals.serverError = errObj;
|
||||
|
||||
logger.error(
|
||||
{ err: errObj, method: req.method, url: req.originalUrl },
|
||||
"Unhandled error: %s %s — %s",
|
||||
req.method,
|
||||
req.originalUrl,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
const rootError = err instanceof Error ? err : new Error(String(err));
|
||||
attachErrorContext(
|
||||
req,
|
||||
res,
|
||||
err instanceof Error
|
||||
? { message: err.message, stack: err.stack, name: err.name }
|
||||
: { message: String(err), raw: err, stack: rootError.stack, name: rootError.name },
|
||||
rootError,
|
||||
);
|
||||
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,13 +52,37 @@ export const httpLogger = pinoHttp({
|
|||
customSuccessMessage(req, res) {
|
||||
return `${req.method} ${req.url} ${res.statusCode}`;
|
||||
},
|
||||
customErrorMessage(req, res) {
|
||||
return `${req.method} ${req.url} ${res.statusCode}`;
|
||||
customErrorMessage(req, res, err) {
|
||||
const ctx = (res as any).__errorContext;
|
||||
const errMsg = ctx?.error?.message || err?.message || (res as any).err?.message || "unknown error";
|
||||
return `${req.method} ${req.url} ${res.statusCode} — ${errMsg}`;
|
||||
},
|
||||
customProps(_req, res) {
|
||||
const serverError = (res as any).locals?.serverError;
|
||||
if (serverError) {
|
||||
return { serverError };
|
||||
customProps(req, res) {
|
||||
if (res.statusCode >= 400) {
|
||||
const ctx = (res as any).__errorContext;
|
||||
if (ctx) {
|
||||
return {
|
||||
errorContext: ctx.error,
|
||||
reqBody: ctx.reqBody,
|
||||
reqParams: ctx.reqParams,
|
||||
reqQuery: ctx.reqQuery,
|
||||
};
|
||||
}
|
||||
const props: Record<string, unknown> = {};
|
||||
const { body, params, query } = req as any;
|
||||
if (body && typeof body === "object" && Object.keys(body).length > 0) {
|
||||
props.reqBody = body;
|
||||
}
|
||||
if (params && typeof params === "object" && Object.keys(params).length > 0) {
|
||||
props.reqParams = params;
|
||||
}
|
||||
if (query && typeof query === "object" && Object.keys(query).length > 0) {
|
||||
props.reqQuery = query;
|
||||
}
|
||||
if ((req as any).route?.path) {
|
||||
props.routePath = (req as any).route.path;
|
||||
}
|
||||
return props;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,15 +1,45 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import type { IncomingMessage, Server as HttpServer } from "node:http";
|
||||
import { createRequire } from "node:module";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentApiKeys, companyMemberships, instanceUserRoles } from "@paperclipai/db";
|
||||
import type { DeploymentMode } from "@paperclipai/shared";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
|
||||
|
||||
interface WsSocket {
|
||||
readyState: number;
|
||||
ping(): void;
|
||||
send(data: string): void;
|
||||
terminate(): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
on(event: "pong", listener: () => void): void;
|
||||
on(event: "close", listener: () => void): void;
|
||||
on(event: "error", listener: (err: Error) => void): void;
|
||||
}
|
||||
|
||||
interface WsServer {
|
||||
clients: Set<WsSocket>;
|
||||
on(event: "connection", listener: (socket: WsSocket, req: IncomingMessage) => void): void;
|
||||
on(event: "close", listener: () => void): void;
|
||||
handleUpgrade(
|
||||
req: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
callback: (ws: WsSocket) => void,
|
||||
): void;
|
||||
emit(event: "connection", ws: WsSocket, req: IncomingMessage): boolean;
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { WebSocket, WebSocketServer } = require("ws") as {
|
||||
WebSocket: { OPEN: number };
|
||||
WebSocketServer: new (opts: { noServer: boolean }) => WsServer;
|
||||
};
|
||||
|
||||
interface UpgradeContext {
|
||||
companyId: string;
|
||||
actorType: "board" | "agent";
|
||||
|
|
@ -154,8 +184,8 @@ export function setupLiveEventsWebSocketServer(
|
|||
},
|
||||
) {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const cleanupByClient = new Map<WebSocket, () => void>();
|
||||
const aliveByClient = new Map<WebSocket, boolean>();
|
||||
const cleanupByClient = new Map<WsSocket, () => void>();
|
||||
const aliveByClient = new Map<WsSocket, boolean>();
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
for (const socket of wss.clients) {
|
||||
|
|
@ -168,7 +198,7 @@ export function setupLiveEventsWebSocketServer(
|
|||
}
|
||||
}, 30000);
|
||||
|
||||
wss.on("connection", (socket, req) => {
|
||||
wss.on("connection", (socket: WsSocket, req: IncomingMessage) => {
|
||||
const context = (req as IncomingMessageWithContext).paperclipUpgradeContext;
|
||||
if (!context) {
|
||||
socket.close(1008, "missing context");
|
||||
|
|
@ -194,7 +224,7 @@ export function setupLiveEventsWebSocketServer(
|
|||
aliveByClient.delete(socket);
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
socket.on("error", (err: Error) => {
|
||||
logger.warn({ err, companyId: context.companyId }, "live websocket client error");
|
||||
});
|
||||
});
|
||||
|
|
@ -229,7 +259,7 @@ export function setupLiveEventsWebSocketServer(
|
|||
const reqWithContext = req as IncomingMessageWithContext;
|
||||
reqWithContext.paperclipUpgradeContext = context;
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.handleUpgrade(req, socket, head, (ws: WsSocket) => {
|
||||
wss.emit("connection", ws, reqWithContext);
|
||||
});
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
|||
import { Router, type Request } from "express";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db";
|
||||
|
|
@ -27,7 +27,7 @@ import {
|
|||
logActivity,
|
||||
secretService,
|
||||
} from "../services/index.js";
|
||||
import { conflict, forbidden, unprocessable } from "../errors.js";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
|
||||
import { redactEventPayload } from "../redaction.js";
|
||||
|
|
@ -37,7 +37,7 @@ import {
|
|||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
|
||||
|
||||
export function agentRoutes(db: Db) {
|
||||
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
|
||||
|
|
@ -152,7 +152,10 @@ export function agentRoutes(db: Db) {
|
|||
if (resolved.ambiguous) {
|
||||
throw conflict("Agent shortname is ambiguous in this company. Use the agent ID.");
|
||||
}
|
||||
return resolved.agent?.id ?? raw;
|
||||
if (!resolved.agent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
return resolved.agent.id;
|
||||
}
|
||||
|
||||
function parseSourceIssueIds(input: {
|
||||
|
|
@ -178,6 +181,40 @@ export function agentRoutes(db: Db) {
|
|||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseBooleanLike(value: unknown): boolean | null {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") {
|
||||
if (value === 1) return true;
|
||||
if (value === 0) return false;
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function generateEd25519PrivateKeyPem(): string {
|
||||
const { privateKey } = generateKeyPairSync("ed25519");
|
||||
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
||||
}
|
||||
|
||||
function ensureGatewayDeviceKey(
|
||||
adapterType: string | null | undefined,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (adapterType !== "openclaw_gateway") return adapterConfig;
|
||||
const disableDeviceAuth = parseBooleanLike(adapterConfig.disableDeviceAuth) === true;
|
||||
if (disableDeviceAuth) return adapterConfig;
|
||||
if (asNonEmptyString(adapterConfig.devicePrivateKeyPem)) return adapterConfig;
|
||||
return { ...adapterConfig, devicePrivateKeyPem: generateEd25519PrivateKeyPem() };
|
||||
}
|
||||
|
||||
function applyCreateDefaultsByAdapterType(
|
||||
adapterType: string | null | undefined,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
|
|
@ -193,15 +230,34 @@ export function agentRoutes(db: Db) {
|
|||
if (!hasBypassFlag) {
|
||||
next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_OPENCODE_LOCAL_MODEL;
|
||||
return ensureGatewayDeviceKey(adapterType, next);
|
||||
}
|
||||
// OpenCode requires explicit model selection — no default
|
||||
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
}
|
||||
return next;
|
||||
return ensureGatewayDeviceKey(adapterType, next);
|
||||
}
|
||||
|
||||
async function assertAdapterConfigConstraints(
|
||||
companyId: string,
|
||||
adapterType: string | null | undefined,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
) {
|
||||
if (adapterType !== "opencode_local") return;
|
||||
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig);
|
||||
const runtimeEnv = asRecord(runtimeConfig.env) ?? {};
|
||||
try {
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model: runtimeConfig.model,
|
||||
command: runtimeConfig.command,
|
||||
cwd: runtimeConfig.cwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveInstructionsFilePath(candidatePath: string, adapterConfig: Record<string, unknown>) {
|
||||
|
|
@ -335,7 +391,9 @@ export function agentRoutes(db: Db) {
|
|||
}
|
||||
});
|
||||
|
||||
router.get("/adapters/:type/models", async (req, res) => {
|
||||
router.get("/companies/:companyId/adapters/:type/models", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const type = req.params.type as string;
|
||||
const models = await listAdapterModels(type);
|
||||
res.json(models);
|
||||
|
|
@ -362,7 +420,7 @@ export function agentRoutes(db: Db) {
|
|||
inputAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
const runtimeAdapterConfig = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
const { config: runtimeAdapterConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
companyId,
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
|
|
@ -589,6 +647,11 @@ export function agentRoutes(db: Db) {
|
|||
requestedAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
companyId,
|
||||
hireInput.adapterType,
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
const normalizedHireInput = {
|
||||
...hireInput,
|
||||
adapterConfig: normalizedAdapterConfig,
|
||||
|
|
@ -724,6 +787,11 @@ export function agentRoutes(db: Db) {
|
|||
requestedAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
companyId,
|
||||
req.body.adapterType,
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
|
||||
const agent = await svc.create(companyId, {
|
||||
...req.body,
|
||||
|
|
@ -896,11 +964,36 @@ export function agentRoutes(db: Db) {
|
|||
if (changingInstructionsPath) {
|
||||
await assertCanManageInstructionsPath(req, existing);
|
||||
}
|
||||
patchData.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
patchData.adapterConfig = adapterConfig;
|
||||
}
|
||||
|
||||
const requestedAdapterType =
|
||||
typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType;
|
||||
const touchesAdapterConfiguration =
|
||||
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
|
||||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
|
||||
if (touchesAdapterConfiguration) {
|
||||
const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
|
||||
? (asRecord(patchData.adapterConfig) ?? {})
|
||||
: (asRecord(existing.adapterConfig) ?? {});
|
||||
const effectiveAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
requestedAdapterType,
|
||||
rawEffectiveAdapterConfig,
|
||||
);
|
||||
const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
adapterConfig,
|
||||
effectiveAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
patchData.adapterConfig = normalizedEffectiveAdapterConfig;
|
||||
}
|
||||
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
|
||||
const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {};
|
||||
await assertAdapterConfigConstraints(
|
||||
existing.companyId,
|
||||
requestedAdapterType,
|
||||
effectiveAdapterConfig,
|
||||
);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
|
|
@ -1171,7 +1264,7 @@ export function agentRoutes(db: Db) {
|
|||
}
|
||||
|
||||
const config = asRecord(agent.adapterConfig) ?? {};
|
||||
const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config);
|
||||
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config);
|
||||
const result = await runClaudeLogin({
|
||||
runId: `claude-login-${randomUUID()}`,
|
||||
agent: {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,13 @@ export function companyRoutes(db: Db) {
|
|||
res.json(filtered);
|
||||
});
|
||||
|
||||
// Common malformed path when companyId is empty in "/api/companies/{companyId}/issues".
|
||||
router.get("/issues", (_req, res) => {
|
||||
res.status(400).json({
|
||||
error: "Missing companyId in path. Use /api/companies/{companyId}/issues.",
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/:companyId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
|
|
|
|||
14
server/src/routes/issues-checkout-wakeup.ts
Normal file
14
server/src/routes/issues-checkout-wakeup.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
type CheckoutWakeInput = {
|
||||
actorType: "board" | "agent" | "none";
|
||||
actorAgentId: string | null;
|
||||
checkoutAgentId: string;
|
||||
checkoutRunId: string | null;
|
||||
};
|
||||
|
||||
export function shouldWakeAssigneeOnCheckout(input: CheckoutWakeInput): boolean {
|
||||
if (input.actorType !== "agent") return true;
|
||||
if (!input.actorAgentId) return true;
|
||||
if (input.actorAgentId !== input.checkoutAgentId) return true;
|
||||
if (!input.checkoutRunId) return true;
|
||||
return false;
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import {
|
|||
import { logger } from "../middleware/logger.js";
|
||||
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
||||
const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([
|
||||
|
|
@ -183,24 +184,51 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
}
|
||||
});
|
||||
|
||||
// Common malformed path when companyId is empty in "/api/companies/{companyId}/issues".
|
||||
router.get("/issues", (_req, res) => {
|
||||
res.status(400).json({
|
||||
error: "Missing companyId in path. Use /api/companies/{companyId}/issues.",
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/issues", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const assigneeUserFilterRaw = req.query.assigneeUserId as string | undefined;
|
||||
const touchedByUserFilterRaw = req.query.touchedByUserId as string | undefined;
|
||||
const unreadForUserFilterRaw = req.query.unreadForUserId as string | undefined;
|
||||
const assigneeUserId =
|
||||
assigneeUserFilterRaw === "me" && req.actor.type === "board"
|
||||
? req.actor.userId
|
||||
: assigneeUserFilterRaw;
|
||||
const touchedByUserId =
|
||||
touchedByUserFilterRaw === "me" && req.actor.type === "board"
|
||||
? req.actor.userId
|
||||
: touchedByUserFilterRaw;
|
||||
const unreadForUserId =
|
||||
unreadForUserFilterRaw === "me" && req.actor.type === "board"
|
||||
? req.actor.userId
|
||||
: unreadForUserFilterRaw;
|
||||
|
||||
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
||||
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
|
||||
return;
|
||||
}
|
||||
if (touchedByUserFilterRaw === "me" && (!touchedByUserId || req.actor.type !== "board")) {
|
||||
res.status(403).json({ error: "touchedByUserId=me requires board authentication" });
|
||||
return;
|
||||
}
|
||||
if (unreadForUserFilterRaw === "me" && (!unreadForUserId || req.actor.type !== "board")) {
|
||||
res.status(403).json({ error: "unreadForUserId=me requires board authentication" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await svc.list(companyId, {
|
||||
status: req.query.status as string | undefined,
|
||||
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
|
||||
assigneeUserId,
|
||||
touchedByUserId,
|
||||
unreadForUserId,
|
||||
projectId: req.query.projectId as string | undefined,
|
||||
labelId: req.query.labelId as string | undefined,
|
||||
q: req.query.q as string | undefined,
|
||||
|
|
@ -282,6 +310,38 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null, mentionedProjects });
|
||||
});
|
||||
|
||||
router.post("/issues/:id/read", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Board authentication required" });
|
||||
return;
|
||||
}
|
||||
if (!req.actor.userId) {
|
||||
res.status(403).json({ error: "Board user context required" });
|
||||
return;
|
||||
}
|
||||
const readState = await svc.markRead(issue.companyId, issue.id, req.actor.userId, new Date());
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.read_marked",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: { userId: req.actor.userId, lastReadAt: readState.lastReadAt },
|
||||
});
|
||||
res.json(readState);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/approvals", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
|
|
@ -379,7 +439,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
details: { title: issue.title, identifier: issue.identifier },
|
||||
});
|
||||
|
||||
if (issue.assigneeAgentId) {
|
||||
if (issue.assigneeAgentId && issue.status !== "backlog") {
|
||||
void heartbeat
|
||||
.wakeup(issue.assigneeAgentId, {
|
||||
source: "assignment",
|
||||
|
|
@ -469,6 +529,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const hasFieldChanges = Object.keys(previous).length > 0;
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
|
|
@ -478,7 +539,12 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: { ...updateFields, identifier: issue.identifier, _previous: Object.keys(previous).length > 0 ? previous : undefined },
|
||||
details: {
|
||||
...updateFields,
|
||||
identifier: issue.identifier,
|
||||
...(commentBody ? { source: "comment" } : {}),
|
||||
_previous: hasFieldChanges ? previous : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
let comment = null;
|
||||
|
|
@ -502,18 +568,23 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
bodySnippet: comment.body.slice(0, 120),
|
||||
identifier: issue.identifier,
|
||||
issueTitle: issue.title,
|
||||
...(hasFieldChanges ? { updated: true } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
const assigneeChanged = assigneeWillChange;
|
||||
const statusChangedFromBacklog =
|
||||
existing.status === "backlog" &&
|
||||
issue.status !== "backlog" &&
|
||||
req.body.status !== undefined;
|
||||
|
||||
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
|
||||
void (async () => {
|
||||
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
|
||||
|
||||
if (assigneeChanged && issue.assigneeAgentId) {
|
||||
if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
|
||||
wakeups.set(issue.assigneeAgentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
|
|
@ -525,6 +596,18 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
});
|
||||
}
|
||||
|
||||
if (!assigneeChanged && statusChangedFromBacklog && issue.assigneeAgentId) {
|
||||
wakeups.set(issue.assigneeAgentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_status_changed",
|
||||
payload: { issueId: issue.id, mutation: "update" },
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
contextSnapshot: { issueId: issue.id, source: "issue.status_change" },
|
||||
});
|
||||
}
|
||||
|
||||
if (commentBody && comment) {
|
||||
let mentionedIds: string[] = [];
|
||||
try {
|
||||
|
|
@ -535,6 +618,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
|
||||
for (const mentionedId of mentionedIds) {
|
||||
if (wakeups.has(mentionedId)) continue;
|
||||
if (actor.actorType === "agent" && actor.actorId === mentionedId) continue;
|
||||
wakeups.set(mentionedId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
|
|
@ -634,17 +718,26 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
details: { agentId: req.body.agentId },
|
||||
});
|
||||
|
||||
void heartbeat
|
||||
.wakeup(req.body.agentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_checked_out",
|
||||
payload: { issueId: issue.id, mutation: "checkout" },
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
contextSnapshot: { issueId: issue.id, source: "issue.checkout" },
|
||||
if (
|
||||
shouldWakeAssigneeOnCheckout({
|
||||
actorType: req.actor.type,
|
||||
actorAgentId: req.actor.type === "agent" ? req.actor.agentId ?? null : null,
|
||||
checkoutAgentId: req.body.agentId,
|
||||
checkoutRunId,
|
||||
})
|
||||
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue checkout"));
|
||||
) {
|
||||
void heartbeat
|
||||
.wakeup(req.body.agentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_checked_out",
|
||||
payload: { issueId: issue.id, mutation: "checkout" },
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
contextSnapshot: { issueId: issue.id, source: "issue.checkout" },
|
||||
})
|
||||
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue checkout"));
|
||||
}
|
||||
|
||||
res.json(updated);
|
||||
});
|
||||
|
|
@ -837,7 +930,10 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
void (async () => {
|
||||
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
|
||||
const assigneeId = currentIssue.assigneeAgentId;
|
||||
if (assigneeId) {
|
||||
const actorIsAgent = actor.actorType === "agent";
|
||||
const selfComment = actorIsAgent && actor.actorId === assigneeId;
|
||||
const skipWake = selfComment || isClosed;
|
||||
if (assigneeId && (reopened || !skipWake)) {
|
||||
if (reopened) {
|
||||
wakeups.set(assigneeId, {
|
||||
source: "automation",
|
||||
|
|
@ -896,6 +992,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
|
||||
for (const mentionedId of mentionedIds) {
|
||||
if (wakeups.has(mentionedId)) continue;
|
||||
if (actorIsAgent && actor.actorId === mentionedId) continue;
|
||||
wakeups.set(mentionedId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { issues, joinRequests } from "@paperclipai/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { joinRequests } from "@paperclipai/db";
|
||||
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { dashboardService } from "../services/dashboard.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
|
||||
const INBOX_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"] as const;
|
||||
|
||||
export function sidebarBadgeRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = sidebarBadgeService(db);
|
||||
const issueSvc = issueService(db);
|
||||
const access = accessService(db);
|
||||
const dashboard = dashboardService(db);
|
||||
|
||||
router.get("/companies/:companyId/sidebar-badges", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
|
|
@ -34,26 +36,17 @@ export function sidebarBadgeRoutes(db: Db) {
|
|||
.then((rows) => Number(rows[0]?.count ?? 0))
|
||||
: 0;
|
||||
|
||||
const assignedIssueCount =
|
||||
req.actor.type === "board" && req.actor.userId
|
||||
? await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.assigneeUserId, req.actor.userId),
|
||||
inArray(issues.status, [...INBOX_ISSUE_STATUSES]),
|
||||
isNull(issues.hiddenAt),
|
||||
),
|
||||
)
|
||||
.then((rows) => Number(rows[0]?.count ?? 0))
|
||||
: 0;
|
||||
|
||||
const badges = await svc.get(companyId, {
|
||||
joinRequests: joinRequestCount,
|
||||
assignedIssues: assignedIssueCount,
|
||||
});
|
||||
const summary = await dashboard.summary(companyId);
|
||||
const staleIssueCount = await issueSvc.staleCount(companyId, 24 * 60);
|
||||
const hasFailedRuns = badges.failedRuns > 0;
|
||||
const alertsCount =
|
||||
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
|
||||
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
||||
badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount + badges.approvals;
|
||||
|
||||
res.json(badges);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,16 @@ interface UpdateAgentOptions {
|
|||
recordRevision?: RevisionMetadata;
|
||||
}
|
||||
|
||||
interface AgentShortnameRow {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface AgentShortnameCollisionOptions {
|
||||
excludeAgentId?: string | null;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -140,6 +150,37 @@ function configPatchFromSnapshot(snapshot: unknown): Partial<typeof agents.$infe
|
|||
};
|
||||
}
|
||||
|
||||
export function hasAgentShortnameCollision(
|
||||
candidateName: string,
|
||||
existingAgents: AgentShortnameRow[],
|
||||
options?: AgentShortnameCollisionOptions,
|
||||
): boolean {
|
||||
const candidateShortname = normalizeAgentUrlKey(candidateName);
|
||||
if (!candidateShortname) return false;
|
||||
|
||||
return existingAgents.some((agent) => {
|
||||
if (agent.status === "terminated") return false;
|
||||
if (options?.excludeAgentId && agent.id === options.excludeAgentId) return false;
|
||||
return normalizeAgentUrlKey(agent.name) === candidateShortname;
|
||||
});
|
||||
}
|
||||
|
||||
export function deduplicateAgentName(
|
||||
candidateName: string,
|
||||
existingAgents: AgentShortnameRow[],
|
||||
): string {
|
||||
if (!hasAgentShortnameCollision(candidateName, existingAgents)) {
|
||||
return candidateName;
|
||||
}
|
||||
for (let i = 2; i <= 100; i++) {
|
||||
const suffixed = `${candidateName} ${i}`;
|
||||
if (!hasAgentShortnameCollision(suffixed, existingAgents)) {
|
||||
return suffixed;
|
||||
}
|
||||
}
|
||||
return `${candidateName} ${Date.now()}`;
|
||||
}
|
||||
|
||||
export function agentService(db: Db) {
|
||||
function withUrlKey<T extends { id: string; name: string }>(row: T) {
|
||||
return {
|
||||
|
|
@ -185,6 +226,31 @@ export function agentService(db: Db) {
|
|||
}
|
||||
}
|
||||
|
||||
async function assertCompanyShortnameAvailable(
|
||||
companyId: string,
|
||||
candidateName: string,
|
||||
options?: AgentShortnameCollisionOptions,
|
||||
) {
|
||||
const candidateShortname = normalizeAgentUrlKey(candidateName);
|
||||
if (!candidateShortname) return;
|
||||
|
||||
const existingAgents = await db
|
||||
.select({
|
||||
id: agents.id,
|
||||
name: agents.name,
|
||||
status: agents.status,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId));
|
||||
|
||||
const hasCollision = hasAgentShortnameCollision(candidateName, existingAgents, options);
|
||||
if (hasCollision) {
|
||||
throw conflict(
|
||||
`Agent shortname '${candidateShortname}' is already in use in this company`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAgent(
|
||||
id: string,
|
||||
data: Partial<typeof agents.$inferInsert>,
|
||||
|
|
@ -212,6 +278,14 @@ export function agentService(db: Db) {
|
|||
await assertNoCycle(id, data.reportsTo);
|
||||
}
|
||||
|
||||
if (data.name !== undefined) {
|
||||
const previousShortname = normalizeAgentUrlKey(existing.name);
|
||||
const nextShortname = normalizeAgentUrlKey(data.name);
|
||||
if (previousShortname !== nextShortname) {
|
||||
await assertCompanyShortnameAvailable(existing.companyId, data.name, { excludeAgentId: id });
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPatch = { ...data } as Partial<typeof agents.$inferInsert>;
|
||||
if (data.permissions !== undefined) {
|
||||
const role = (data.role ?? existing.role) as string;
|
||||
|
|
@ -267,11 +341,17 @@ export function agentService(db: Db) {
|
|||
await ensureManager(companyId, data.reportsTo);
|
||||
}
|
||||
|
||||
const existingAgents = await db
|
||||
.select({ id: agents.id, name: agents.name, status: agents.status })
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId));
|
||||
const uniqueName = deduplicateAgentName(data.name, existingAgents);
|
||||
|
||||
const role = data.role ?? "general";
|
||||
const normalizedPermissions = normalizeAgentPermissions(data.permissions, role);
|
||||
const created = await db
|
||||
.insert(agents)
|
||||
.values({ ...data, companyId, role, permissions: normalizedPermissions })
|
||||
.values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions })
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db";
|
|||
import { approvalComments, approvals } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { notifyHireApproved } from "./hire-hook.js";
|
||||
|
||||
export function approvalService(db: Db) {
|
||||
const agentsSvc = agentService(db);
|
||||
|
|
@ -59,13 +60,15 @@ export function approvalService(db: Db) {
|
|||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
let hireApprovedAgentId: string | null = null;
|
||||
if (updated.type === "hire_agent") {
|
||||
const payload = updated.payload as Record<string, unknown>;
|
||||
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||
if (payloadAgentId) {
|
||||
await agentsSvc.activatePendingApproval(payloadAgentId);
|
||||
hireApprovedAgentId = payloadAgentId;
|
||||
} else {
|
||||
await agentsSvc.create(updated.companyId, {
|
||||
const created = await agentsSvc.create(updated.companyId, {
|
||||
name: String(payload.name ?? "New Agent"),
|
||||
role: String(payload.role ?? "general"),
|
||||
title: typeof payload.title === "string" ? payload.title : null,
|
||||
|
|
@ -87,6 +90,16 @@ export function approvalService(db: Db) {
|
|||
permissions: undefined,
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
hireApprovedAgentId = created?.id ?? null;
|
||||
}
|
||||
if (hireApprovedAgentId) {
|
||||
void notifyHireApproved(db, {
|
||||
companyId: updated.companyId,
|
||||
agentId: hireApprovedAgentId,
|
||||
source: "approval",
|
||||
sourceId: id,
|
||||
approvedAt: now,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -83,9 +83,13 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
|
|||
{ path: ["graceSec"], value: 15 },
|
||||
{ path: ["maxTurnsPerRun"], value: 80 },
|
||||
],
|
||||
openclaw: [
|
||||
{ path: ["method"], value: "POST" },
|
||||
{ path: ["timeoutSec"], value: 30 },
|
||||
openclaw_gateway: [
|
||||
{ path: ["timeoutSec"], value: 120 },
|
||||
{ path: ["waitTimeoutMs"], value: 120000 },
|
||||
{ path: ["sessionKeyStrategy"], value: "fixed" },
|
||||
{ path: ["sessionKey"], value: "paperclip" },
|
||||
{ path: ["role"], value: "operator" },
|
||||
{ path: ["scopes"], value: ["operator.admin"] },
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1240,11 +1240,16 @@ export function heartbeatService(db: Db) {
|
|||
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
||||
? { ...config, ...issueAssigneeOverrides.adapterConfig }
|
||||
: config;
|
||||
const resolvedConfig = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
agent.companyId,
|
||||
mergedConfig,
|
||||
);
|
||||
const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
|
||||
if (meta.env && secretKeys.size > 0) {
|
||||
for (const key of secretKeys) {
|
||||
if (key in meta.env) meta.env[key] = "***REDACTED***";
|
||||
}
|
||||
}
|
||||
await appendRunEvent(currentRun, seq++, {
|
||||
eventType: "adapter.invoke",
|
||||
stream: "system",
|
||||
|
|
|
|||
113
server/src/services/hire-hook.ts
Normal file
113
server/src/services/hire-hook.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents } from "@paperclipai/db";
|
||||
import type { HireApprovedPayload } from "@paperclipai/adapter-utils";
|
||||
import { findServerAdapter } from "../adapters/registry.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
|
||||
const HIRE_APPROVED_MESSAGE =
|
||||
"Tell your user that your hire was approved, now they should assign you a task in Paperclip or ask you to create issues.";
|
||||
|
||||
export interface NotifyHireApprovedInput {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
source: "join_request" | "approval";
|
||||
sourceId: string;
|
||||
approvedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the adapter's onHireApproved hook when an agent is approved (join-request or hire_agent approval).
|
||||
* Failures are non-fatal: we log and write to activity, never throw.
|
||||
*/
|
||||
export async function notifyHireApproved(
|
||||
db: Db,
|
||||
input: NotifyHireApprovedInput,
|
||||
): Promise<void> {
|
||||
const { companyId, agentId, source, sourceId } = input;
|
||||
const approvedAt = input.approvedAt ?? new Date();
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(and(eq(agents.id, agentId), eq(agents.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!row) {
|
||||
logger.warn({ companyId, agentId, source, sourceId }, "hire hook: agent not found in company, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const adapterType = row.adapterType ?? "process";
|
||||
const adapter = findServerAdapter(adapterType);
|
||||
const onHireApproved = adapter?.onHireApproved;
|
||||
if (!onHireApproved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: HireApprovedPayload = {
|
||||
companyId,
|
||||
agentId,
|
||||
agentName: row.name,
|
||||
adapterType,
|
||||
source,
|
||||
sourceId,
|
||||
approvedAt: approvedAt.toISOString(),
|
||||
message: HIRE_APPROVED_MESSAGE,
|
||||
};
|
||||
|
||||
const adapterConfig =
|
||||
typeof row.adapterConfig === "object" && row.adapterConfig !== null && !Array.isArray(row.adapterConfig)
|
||||
? (row.adapterConfig as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
try {
|
||||
const result = await onHireApproved(payload, adapterConfig);
|
||||
if (result.ok) {
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "system",
|
||||
actorId: "hire_hook",
|
||||
action: "hire_hook.succeeded",
|
||||
entityType: "agent",
|
||||
entityId: agentId,
|
||||
details: { source, sourceId, adapterType },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
{ companyId, agentId, adapterType, source, sourceId, error: result.error, detail: result.detail },
|
||||
"hire hook: adapter returned failure",
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "system",
|
||||
actorId: "hire_hook",
|
||||
action: "hire_hook.failed",
|
||||
entityType: "agent",
|
||||
entityId: agentId,
|
||||
details: { source, sourceId, adapterType, error: result.error, detail: result.detail },
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, companyId, agentId, adapterType, source, sourceId },
|
||||
"hire hook: adapter threw",
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "system",
|
||||
actorId: "hire_hook",
|
||||
action: "hire_hook.error",
|
||||
entityType: "agent",
|
||||
entityId: agentId,
|
||||
details: {
|
||||
source,
|
||||
sourceId,
|
||||
adapterType,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
export { companyService } from "./companies.js";
|
||||
export { agentService } from "./agents.js";
|
||||
export { agentService, deduplicateAgentName } from "./agents.js";
|
||||
export { assetService } from "./assets.js";
|
||||
export { projectService } from "./projects.js";
|
||||
export { issueService, type IssueFilters } from "./issues.js";
|
||||
|
|
@ -15,5 +15,6 @@ export { sidebarBadgeService } from "./sidebar-badges.js";
|
|||
export { accessService } from "./access.js";
|
||||
export { companyPortabilityService } from "./company-portability.js";
|
||||
export { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
|
||||
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
|
||||
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
issueAttachments,
|
||||
issueLabels,
|
||||
issueComments,
|
||||
issueReadStates,
|
||||
issues,
|
||||
labels,
|
||||
projectWorkspaces,
|
||||
|
|
@ -49,6 +50,8 @@ export interface IssueFilters {
|
|||
status?: string;
|
||||
assigneeAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
touchedByUserId?: string;
|
||||
unreadForUserId?: string;
|
||||
projectId?: string;
|
||||
labelId?: string;
|
||||
q?: string;
|
||||
|
|
@ -68,6 +71,17 @@ type IssueActiveRunRow = {
|
|||
};
|
||||
type IssueWithLabels = IssueRow & { labels: IssueLabelRow[]; labelIds: string[] };
|
||||
type IssueWithLabelsAndRun = IssueWithLabels & { activeRun: IssueActiveRunRow | null };
|
||||
type IssueUserCommentStats = {
|
||||
issueId: string;
|
||||
myLastCommentAt: Date | null;
|
||||
lastExternalCommentAt: Date | null;
|
||||
};
|
||||
type IssueUserContextInput = {
|
||||
createdByUserId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
};
|
||||
|
||||
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
|
||||
if (actorRunId) return checkoutRunId === actorRunId;
|
||||
|
|
@ -80,6 +94,127 @@ function escapeLikePattern(value: string): string {
|
|||
return value.replace(/[\\%_]/g, "\\$&");
|
||||
}
|
||||
|
||||
function touchedByUserCondition(companyId: string, userId: string) {
|
||||
return sql<boolean>`
|
||||
(
|
||||
${issues.createdByUserId} = ${userId}
|
||||
OR ${issues.assigneeUserId} = ${userId}
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM ${issueReadStates}
|
||||
WHERE ${issueReadStates.issueId} = ${issues.id}
|
||||
AND ${issueReadStates.companyId} = ${companyId}
|
||||
AND ${issueReadStates.userId} = ${userId}
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM ${issueComments}
|
||||
WHERE ${issueComments.issueId} = ${issues.id}
|
||||
AND ${issueComments.companyId} = ${companyId}
|
||||
AND ${issueComments.authorUserId} = ${userId}
|
||||
)
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function myLastCommentAtExpr(companyId: string, userId: string) {
|
||||
return sql<Date | null>`
|
||||
(
|
||||
SELECT MAX(${issueComments.createdAt})
|
||||
FROM ${issueComments}
|
||||
WHERE ${issueComments.issueId} = ${issues.id}
|
||||
AND ${issueComments.companyId} = ${companyId}
|
||||
AND ${issueComments.authorUserId} = ${userId}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function myLastReadAtExpr(companyId: string, userId: string) {
|
||||
return sql<Date | null>`
|
||||
(
|
||||
SELECT MAX(${issueReadStates.lastReadAt})
|
||||
FROM ${issueReadStates}
|
||||
WHERE ${issueReadStates.issueId} = ${issues.id}
|
||||
AND ${issueReadStates.companyId} = ${companyId}
|
||||
AND ${issueReadStates.userId} = ${userId}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function myLastTouchAtExpr(companyId: string, userId: string) {
|
||||
const myLastCommentAt = myLastCommentAtExpr(companyId, userId);
|
||||
const myLastReadAt = myLastReadAtExpr(companyId, userId);
|
||||
return sql<Date | null>`
|
||||
GREATEST(
|
||||
COALESCE(${myLastCommentAt}, to_timestamp(0)),
|
||||
COALESCE(${myLastReadAt}, to_timestamp(0)),
|
||||
COALESCE(CASE WHEN ${issues.createdByUserId} = ${userId} THEN ${issues.createdAt} ELSE NULL END, to_timestamp(0)),
|
||||
COALESCE(CASE WHEN ${issues.assigneeUserId} = ${userId} THEN ${issues.updatedAt} ELSE NULL END, to_timestamp(0))
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function unreadForUserCondition(companyId: string, userId: string) {
|
||||
const touchedCondition = touchedByUserCondition(companyId, userId);
|
||||
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
|
||||
return sql<boolean>`
|
||||
(
|
||||
${touchedCondition}
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM ${issueComments}
|
||||
WHERE ${issueComments.issueId} = ${issues.id}
|
||||
AND ${issueComments.companyId} = ${companyId}
|
||||
AND (
|
||||
${issueComments.authorUserId} IS NULL
|
||||
OR ${issueComments.authorUserId} <> ${userId}
|
||||
)
|
||||
AND ${issueComments.createdAt} > ${myLastTouchAt}
|
||||
)
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
export function deriveIssueUserContext(
|
||||
issue: IssueUserContextInput,
|
||||
userId: string,
|
||||
stats:
|
||||
| {
|
||||
myLastCommentAt: Date | string | null;
|
||||
myLastReadAt: Date | string | null;
|
||||
lastExternalCommentAt: Date | string | null;
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
const normalizeDate = (value: Date | string | null | undefined) => {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
const myLastCommentAt = normalizeDate(stats?.myLastCommentAt);
|
||||
const myLastReadAt = normalizeDate(stats?.myLastReadAt);
|
||||
const createdTouchAt = issue.createdByUserId === userId ? normalizeDate(issue.createdAt) : null;
|
||||
const assignedTouchAt = issue.assigneeUserId === userId ? normalizeDate(issue.updatedAt) : null;
|
||||
const myLastTouchAt = [myLastCommentAt, myLastReadAt, createdTouchAt, assignedTouchAt]
|
||||
.filter((value): value is Date => value instanceof Date)
|
||||
.sort((a, b) => b.getTime() - a.getTime())[0] ?? null;
|
||||
const lastExternalCommentAt = normalizeDate(stats?.lastExternalCommentAt);
|
||||
const isUnreadForMe = Boolean(
|
||||
myLastTouchAt &&
|
||||
lastExternalCommentAt &&
|
||||
lastExternalCommentAt.getTime() > myLastTouchAt.getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
myLastTouchAt,
|
||||
lastExternalCommentAt,
|
||||
isUnreadForMe,
|
||||
};
|
||||
}
|
||||
|
||||
async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise<Map<string, IssueLabelRow[]>> {
|
||||
const map = new Map<string, IssueLabelRow[]>();
|
||||
if (issueIds.length === 0) return map;
|
||||
|
|
@ -284,6 +419,9 @@ export function issueService(db: Db) {
|
|||
return {
|
||||
list: async (companyId: string, filters?: IssueFilters) => {
|
||||
const conditions = [eq(issues.companyId, companyId)];
|
||||
const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
|
||||
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
|
||||
const contextUserId = unreadForUserId ?? touchedByUserId;
|
||||
const rawSearch = filters?.q?.trim() ?? "";
|
||||
const hasSearch = rawSearch.length > 0;
|
||||
const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : "";
|
||||
|
|
@ -313,6 +451,12 @@ export function issueService(db: Db) {
|
|||
if (filters?.assigneeUserId) {
|
||||
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
|
||||
}
|
||||
if (touchedByUserId) {
|
||||
conditions.push(touchedByUserCondition(companyId, touchedByUserId));
|
||||
}
|
||||
if (unreadForUserId) {
|
||||
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
|
||||
}
|
||||
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
|
||||
if (filters?.labelId) {
|
||||
const labeledIssueIds = await db
|
||||
|
|
@ -353,7 +497,102 @@ export function issueService(db: Db) {
|
|||
.orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(issues.updatedAt));
|
||||
const withLabels = await withIssueLabels(db, rows);
|
||||
const runMap = await activeRunMapForIssues(db, withLabels);
|
||||
return withActiveRuns(withLabels, runMap);
|
||||
const withRuns = withActiveRuns(withLabels, runMap);
|
||||
if (!contextUserId || withRuns.length === 0) {
|
||||
return withRuns;
|
||||
}
|
||||
|
||||
const issueIds = withRuns.map((row) => row.id);
|
||||
const statsRows = await db
|
||||
.select({
|
||||
issueId: issueComments.issueId,
|
||||
myLastCommentAt: sql<Date | null>`
|
||||
MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END)
|
||||
`,
|
||||
lastExternalCommentAt: sql<Date | null>`
|
||||
MAX(
|
||||
CASE
|
||||
WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${contextUserId}
|
||||
THEN ${issueComments.createdAt}
|
||||
END
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, companyId),
|
||||
inArray(issueComments.issueId, issueIds),
|
||||
),
|
||||
)
|
||||
.groupBy(issueComments.issueId);
|
||||
const readRows = await db
|
||||
.select({
|
||||
issueId: issueReadStates.issueId,
|
||||
myLastReadAt: issueReadStates.lastReadAt,
|
||||
})
|
||||
.from(issueReadStates)
|
||||
.where(
|
||||
and(
|
||||
eq(issueReadStates.companyId, companyId),
|
||||
eq(issueReadStates.userId, contextUserId),
|
||||
inArray(issueReadStates.issueId, issueIds),
|
||||
),
|
||||
);
|
||||
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
|
||||
const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt]));
|
||||
|
||||
return withRuns.map((row) => ({
|
||||
...row,
|
||||
...deriveIssueUserContext(row, contextUserId, {
|
||||
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
|
||||
myLastReadAt: readByIssueId.get(row.id) ?? null,
|
||||
lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
|
||||
countUnreadTouchedByUser: async (companyId: string, userId: string, status?: string) => {
|
||||
const conditions = [
|
||||
eq(issues.companyId, companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
unreadForUserCondition(companyId, userId),
|
||||
];
|
||||
if (status) {
|
||||
const statuses = status.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (statuses.length === 1) {
|
||||
conditions.push(eq(issues.status, statuses[0]));
|
||||
} else if (statuses.length > 1) {
|
||||
conditions.push(inArray(issues.status, statuses));
|
||||
}
|
||||
}
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(issues)
|
||||
.where(and(...conditions));
|
||||
return Number(row?.count ?? 0);
|
||||
},
|
||||
|
||||
markRead: async (companyId: string, issueId: string, userId: string, readAt: Date = new Date()) => {
|
||||
const now = new Date();
|
||||
const [row] = await db
|
||||
.insert(issueReadStates)
|
||||
.values({
|
||||
companyId,
|
||||
issueId,
|
||||
userId,
|
||||
lastReadAt: readAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [issueReadStates.companyId, issueReadStates.issueId, issueReadStates.userId],
|
||||
set: {
|
||||
lastReadAt: readAt,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
return row;
|
||||
},
|
||||
|
||||
getById: async (id: string) => {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,15 @@ interface ProjectWithGoals extends ProjectRow {
|
|||
primaryWorkspace: ProjectWorkspace | null;
|
||||
}
|
||||
|
||||
interface ProjectShortnameRow {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ResolveProjectNameOptions {
|
||||
excludeProjectId?: string | null;
|
||||
}
|
||||
|
||||
/** Batch-load goal refs for a set of projects. */
|
||||
async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals[]> {
|
||||
if (rows.length === 0) return [];
|
||||
|
|
@ -192,6 +201,34 @@ function deriveWorkspaceName(input: {
|
|||
return "Workspace";
|
||||
}
|
||||
|
||||
export function resolveProjectNameForUniqueShortname(
|
||||
requestedName: string,
|
||||
existingProjects: ProjectShortnameRow[],
|
||||
options?: ResolveProjectNameOptions,
|
||||
): string {
|
||||
const requestedShortname = normalizeProjectUrlKey(requestedName);
|
||||
if (!requestedShortname) return requestedName;
|
||||
|
||||
const usedShortnames = new Set(
|
||||
existingProjects
|
||||
.filter((project) => !(options?.excludeProjectId && project.id === options.excludeProjectId))
|
||||
.map((project) => normalizeProjectUrlKey(project.name))
|
||||
.filter((value): value is string => value !== null),
|
||||
);
|
||||
if (!usedShortnames.has(requestedShortname)) return requestedName;
|
||||
|
||||
for (let suffix = 2; suffix < 10_000; suffix += 1) {
|
||||
const candidateName = `${requestedName} ${suffix}`;
|
||||
const candidateShortname = normalizeProjectUrlKey(candidateName);
|
||||
if (candidateShortname && !usedShortnames.has(candidateShortname)) {
|
||||
return candidateName;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback guard for pathological naming collisions.
|
||||
return `${requestedName} ${Date.now()}`;
|
||||
}
|
||||
|
||||
async function ensureSinglePrimaryWorkspace(
|
||||
dbOrTx: any,
|
||||
input: {
|
||||
|
|
@ -271,6 +308,12 @@ export function projectService(db: Db) {
|
|||
projectData.color = nextColor;
|
||||
}
|
||||
|
||||
const existingProjects = await db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.companyId, companyId));
|
||||
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects);
|
||||
|
||||
// Also write goalId to the legacy column (first goal or null)
|
||||
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
|
||||
|
||||
|
|
@ -295,6 +338,26 @@ export function projectService(db: Db) {
|
|||
): Promise<ProjectWithGoals | null> => {
|
||||
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
||||
const existingProject = await db
|
||||
.select({ id: projects.id, companyId: projects.companyId, name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existingProject) return null;
|
||||
|
||||
if (projectData.name !== undefined) {
|
||||
const existingShortname = normalizeProjectUrlKey(existingProject.name);
|
||||
const nextShortname = normalizeProjectUrlKey(projectData.name);
|
||||
if (existingShortname !== nextShortname) {
|
||||
const existingProjects = await db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.companyId, existingProject.companyId));
|
||||
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects, {
|
||||
excludeProjectId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Keep legacy goalId column in sync
|
||||
const updates: Partial<typeof projects.$inferInsert> = {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { createReadStream, createWriteStream, promises as fs } from "node:fs";
|
||||
import { createReadStream, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { notFound } from "../errors.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
|
||||
export type RunLogStoreType = "local_file";
|
||||
|
||||
|
|
@ -113,11 +114,7 @@ function createLocalFileRunLogStore(basePath: string): RunLogStore {
|
|||
stream: event.stream,
|
||||
chunk: event.chunk,
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stream = createWriteStream(absPath, { flags: "a", encoding: "utf8" });
|
||||
stream.on("error", reject);
|
||||
stream.end(`${line}\n`, () => resolve());
|
||||
});
|
||||
await fs.appendFile(absPath, `${line}\n`, "utf8");
|
||||
},
|
||||
|
||||
async finalize(handle) {
|
||||
|
|
@ -152,7 +149,7 @@ let cachedStore: RunLogStore | null = null;
|
|||
|
||||
export function getRunLogStore() {
|
||||
if (cachedStore) return cachedStore;
|
||||
const basePath = process.env.RUN_LOG_BASE_PATH ?? path.resolve(process.cwd(), "data/run-logs");
|
||||
const basePath = process.env.RUN_LOG_BASE_PATH ?? path.resolve(resolvePaperclipInstanceRoot(), "data", "run-logs");
|
||||
cachedStore = createLocalFileRunLogStore(basePath);
|
||||
return cachedStore;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -308,10 +308,11 @@ export function secretService(db: Db) {
|
|||
return normalized;
|
||||
},
|
||||
|
||||
resolveEnvBindings: async (companyId: string, envValue: unknown) => {
|
||||
resolveEnvBindings: async (companyId: string, envValue: unknown): Promise<{ env: Record<string, string>; secretKeys: Set<string> }> => {
|
||||
const record = asRecord(envValue);
|
||||
if (!record) return {} as Record<string, string>;
|
||||
if (!record) return { env: {} as Record<string, string>, secretKeys: new Set<string>() };
|
||||
const resolved: Record<string, string> = {};
|
||||
const secretKeys = new Set<string>();
|
||||
|
||||
for (const [key, rawBinding] of Object.entries(record)) {
|
||||
if (!ENV_KEY_RE.test(key)) {
|
||||
|
|
@ -326,20 +327,22 @@ export function secretService(db: Db) {
|
|||
resolved[key] = binding.value;
|
||||
} else {
|
||||
resolved[key] = await resolveSecretValue(companyId, binding.secretId, binding.version);
|
||||
secretKeys.add(key);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
return { env: resolved, secretKeys };
|
||||
},
|
||||
|
||||
resolveAdapterConfigForRuntime: async (companyId: string, adapterConfig: Record<string, unknown>) => {
|
||||
resolveAdapterConfigForRuntime: async (companyId: string, adapterConfig: Record<string, unknown>): Promise<{ config: Record<string, unknown>; secretKeys: Set<string> }> => {
|
||||
const resolved = { ...adapterConfig };
|
||||
const secretKeys = new Set<string>();
|
||||
if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
|
||||
return resolved;
|
||||
return { config: resolved, secretKeys };
|
||||
}
|
||||
const record = asRecord(adapterConfig.env);
|
||||
if (!record) {
|
||||
resolved.env = {};
|
||||
return resolved;
|
||||
return { config: resolved, secretKeys };
|
||||
}
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, rawBinding] of Object.entries(record)) {
|
||||
|
|
@ -355,10 +358,11 @@ export function secretService(db: Db) {
|
|||
env[key] = binding.value;
|
||||
} else {
|
||||
env[key] = await resolveSecretValue(companyId, binding.secretId, binding.version);
|
||||
secretKeys.add(key);
|
||||
}
|
||||
}
|
||||
resolved.env = env;
|
||||
return resolved;
|
||||
return { config: resolved, secretKeys };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export function sidebarBadgeService(db: Db) {
|
|||
return {
|
||||
get: async (
|
||||
companyId: string,
|
||||
extra?: { joinRequests?: number; assignedIssues?: number },
|
||||
extra?: { joinRequests?: number; unreadTouchedIssues?: number },
|
||||
): Promise<SidebarBadges> => {
|
||||
const actionableApprovals = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
|
|
@ -43,9 +43,9 @@ export function sidebarBadgeService(db: Db) {
|
|||
).length;
|
||||
|
||||
const joinRequests = extra?.joinRequests ?? 0;
|
||||
const assignedIssues = extra?.assignedIssues ?? 0;
|
||||
const unreadTouchedIssues = extra?.unreadTouchedIssues ?? 0;
|
||||
return {
|
||||
inbox: actionableApprovals + failedRuns + joinRequests + assignedIssues,
|
||||
inbox: actionableApprovals + failedRuns + joinRequests + unreadTouchedIssues,
|
||||
approvals: actionableApprovals,
|
||||
failedRuns,
|
||||
joinRequests,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue