mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Merge master into stale queued run branch
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
64bd735cca
31 files changed
+912
-212
No files matched your search
@@ -190,8 +190,9 @@ describe("worktree helpers", () => {
|
||||
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]);
|
||||
});
|
||||
|
||||
it("rewrites loopback auth URLs to the new port only", () => {
|
||||
it("rewrites auth URLs only when they already include a port", () => {
|
||||
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
|
||||
expect(rewriteLocalUrlPort("http://my-host.ts.net:3100", 3110)).toBe("http://my-host.ts.net:3110/");
|
||||
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
|
||||
});
|
||||
|
||||
|
||||
@@ -75,11 +75,6 @@ function nonEmpty(value: string | null | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
const value = hostname.trim().toLowerCase();
|
||||
return value === "127.0.0.1" || value === "localhost" || value === "::1";
|
||||
}
|
||||
|
||||
export function sanitizeWorktreeInstanceId(rawValue: string): string {
|
||||
const trimmed = rawValue.trim().toLowerCase();
|
||||
const normalized = trimmed
|
||||
@@ -168,7 +163,8 @@ export function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): s
|
||||
if (!rawUrl) return undefined;
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
|
||||
// The URL API normalizes default ports like :80/:443 to "", so treat them as stable URLs.
|
||||
if (!parsed.port) return rawUrl;
|
||||
parsed.port = String(port);
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyPaperclipWorkspaceEnv,
|
||||
appendWithByteCap,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
renderPaperclipWakePrompt,
|
||||
@@ -425,6 +426,50 @@ describe("renderPaperclipWakePrompt", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPaperclipWorkspaceEnv", () => {
|
||||
it("adds shared workspace env vars including AGENT_HOME", () => {
|
||||
const env = applyPaperclipWorkspaceEnv(
|
||||
{},
|
||||
{
|
||||
workspaceCwd: "/tmp/workspace",
|
||||
workspaceSource: "project_primary",
|
||||
workspaceStrategy: "git_worktree",
|
||||
workspaceId: "workspace-1",
|
||||
workspaceRepoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
workspaceRepoRef: "main",
|
||||
workspaceBranch: "feature/test",
|
||||
workspaceWorktreePath: "/tmp/worktree",
|
||||
agentHome: "/tmp/agent-home",
|
||||
},
|
||||
);
|
||||
|
||||
expect(env).toEqual({
|
||||
PAPERCLIP_WORKSPACE_CWD: "/tmp/workspace",
|
||||
PAPERCLIP_WORKSPACE_SOURCE: "project_primary",
|
||||
PAPERCLIP_WORKSPACE_STRATEGY: "git_worktree",
|
||||
PAPERCLIP_WORKSPACE_ID: "workspace-1",
|
||||
PAPERCLIP_WORKSPACE_REPO_URL: "https://github.com/paperclipai/paperclip.git",
|
||||
PAPERCLIP_WORKSPACE_REPO_REF: "main",
|
||||
PAPERCLIP_WORKSPACE_BRANCH: "feature/test",
|
||||
PAPERCLIP_WORKSPACE_WORKTREE_PATH: "/tmp/worktree",
|
||||
AGENT_HOME: "/tmp/agent-home",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips empty workspace env values", () => {
|
||||
const env = applyPaperclipWorkspaceEnv(
|
||||
{},
|
||||
{
|
||||
workspaceCwd: "",
|
||||
workspaceSource: null,
|
||||
agentHome: "",
|
||||
},
|
||||
);
|
||||
|
||||
expect(env).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendWithByteCap", () => {
|
||||
it("keeps valid UTF-8 when trimming through multibyte text", () => {
|
||||
const output = appendWithByteCap("prefix ", "hello — world", 7);
|
||||
|
||||
@@ -835,6 +835,41 @@ export function buildPaperclipEnv(agent: { id: string; companyId: string }): Rec
|
||||
return vars;
|
||||
}
|
||||
|
||||
export function applyPaperclipWorkspaceEnv(
|
||||
env: Record<string, string>,
|
||||
input: {
|
||||
workspaceCwd?: string | null;
|
||||
workspaceSource?: string | null;
|
||||
workspaceStrategy?: string | null;
|
||||
workspaceId?: string | null;
|
||||
workspaceRepoUrl?: string | null;
|
||||
workspaceRepoRef?: string | null;
|
||||
workspaceBranch?: string | null;
|
||||
workspaceWorktreePath?: string | null;
|
||||
agentHome?: string | null;
|
||||
},
|
||||
): Record<string, string> {
|
||||
const mappings = [
|
||||
["PAPERCLIP_WORKSPACE_CWD", input.workspaceCwd],
|
||||
["PAPERCLIP_WORKSPACE_SOURCE", input.workspaceSource],
|
||||
["PAPERCLIP_WORKSPACE_STRATEGY", input.workspaceStrategy],
|
||||
["PAPERCLIP_WORKSPACE_ID", input.workspaceId],
|
||||
["PAPERCLIP_WORKSPACE_REPO_URL", input.workspaceRepoUrl],
|
||||
["PAPERCLIP_WORKSPACE_REPO_REF", input.workspaceRepoRef],
|
||||
["PAPERCLIP_WORKSPACE_BRANCH", input.workspaceBranch],
|
||||
["PAPERCLIP_WORKSPACE_WORKTREE_PATH", input.workspaceWorktreePath],
|
||||
["AGENT_HOME", input.agentHome],
|
||||
] as const;
|
||||
|
||||
for (const [key, value] of mappings) {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
export function sanitizeInheritedPaperclipEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...baseEnv };
|
||||
for (const key of Object.keys(env)) {
|
||||
@@ -1037,6 +1072,20 @@ export async function resolvePaperclipSkillsDir(
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readSkillRequired(skillDir: string): Promise<boolean> {
|
||||
try {
|
||||
const content = await fs.readFile(path.join(skillDir, "SKILL.md"), "utf8");
|
||||
const normalized = content.replace(/\r\n/g, "\n");
|
||||
if (!normalized.startsWith("---\n")) return true;
|
||||
const closing = normalized.indexOf("\n---\n", 4);
|
||||
if (closing < 0) return true;
|
||||
const frontmatter = normalized.slice(4, closing);
|
||||
return !/^\s*required\s*:\s*false\s*$/m.test(frontmatter);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPaperclipSkillEntries(
|
||||
moduleDir: string,
|
||||
additionalCandidates: string[] = [],
|
||||
@@ -1046,15 +1095,20 @@ export async function listPaperclipSkillEntries(
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => ({
|
||||
const dirs = entries.filter((entry) => entry.isDirectory());
|
||||
return Promise.all(dirs.map(async (entry) => {
|
||||
const skillDir = path.join(root, entry.name);
|
||||
const required = await readSkillRequired(skillDir);
|
||||
return {
|
||||
key: `paperclipai/paperclip/${entry.name}`,
|
||||
runtimeName: entry.name,
|
||||
source: path.join(root, entry.name),
|
||||
required: true,
|
||||
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
|
||||
}));
|
||||
source: skillDir,
|
||||
required,
|
||||
requiredReason: required
|
||||
? "Bundled Paperclip skills are always available for local adapters."
|
||||
: null,
|
||||
};
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
asStringArray,
|
||||
parseObject,
|
||||
parseJson,
|
||||
applyPaperclipWorkspaceEnv,
|
||||
buildPaperclipEnv,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
joinPromptSections,
|
||||
@@ -193,33 +194,17 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (wakePayloadJson) {
|
||||
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
if (workspaceRepoUrl) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
}
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceBranch) {
|
||||
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
|
||||
}
|
||||
if (workspaceWorktreePath) {
|
||||
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (agentHome) {
|
||||
env.AGENT_HOME = agentHome;
|
||||
}
|
||||
applyPaperclipWorkspaceEnv(env, {
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceStrategy,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
workspaceBranch,
|
||||
workspaceWorktreePath,
|
||||
agentHome,
|
||||
});
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
asString,
|
||||
asNumber,
|
||||
parseObject,
|
||||
applyPaperclipWorkspaceEnv,
|
||||
buildPaperclipEnv,
|
||||
buildInvocationEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
@@ -421,33 +422,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (wakePayloadJson) {
|
||||
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
if (workspaceRepoUrl) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
}
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceBranch) {
|
||||
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
|
||||
}
|
||||
if (workspaceWorktreePath) {
|
||||
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (agentHome) {
|
||||
env.AGENT_HOME = agentHome;
|
||||
}
|
||||
applyPaperclipWorkspaceEnv(env, {
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceStrategy,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
workspaceBranch,
|
||||
workspaceWorktreePath,
|
||||
agentHome,
|
||||
});
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
asNumber,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
applyPaperclipWorkspaceEnv,
|
||||
buildPaperclipEnv,
|
||||
buildInvocationEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
@@ -277,24 +278,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (wakePayloadJson) {
|
||||
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
if (workspaceRepoUrl) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
}
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (agentHome) {
|
||||
env.AGENT_HOME = agentHome;
|
||||
}
|
||||
applyPaperclipWorkspaceEnv(env, {
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
agentHome,
|
||||
});
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
asNumber,
|
||||
asString,
|
||||
asStringArray,
|
||||
applyPaperclipWorkspaceEnv,
|
||||
buildPaperclipEnv,
|
||||
buildInvocationEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
@@ -240,12 +241,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
if (agentHome) env.AGENT_HOME = agentHome;
|
||||
applyPaperclipWorkspaceEnv(env, {
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
agentHome,
|
||||
});
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
|
||||
if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
asNumber,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
applyPaperclipWorkspaceEnv,
|
||||
buildPaperclipEnv,
|
||||
joinPromptSections,
|
||||
buildInvocationEnvForLogs,
|
||||
@@ -199,12 +200,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
if (agentHome) env.AGENT_HOME = agentHome;
|
||||
applyPaperclipWorkspaceEnv(env, {
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
agentHome,
|
||||
});
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
|
||||
if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
asNumber,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
applyPaperclipWorkspaceEnv,
|
||||
buildPaperclipEnv,
|
||||
joinPromptSections,
|
||||
buildInvocationEnvForLogs,
|
||||
@@ -228,12 +229,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
if (workspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd;
|
||||
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
if (agentHome) env.AGENT_HOME = agentHome;
|
||||
applyPaperclipWorkspaceEnv(env, {
|
||||
workspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
agentHome,
|
||||
});
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
|
||||
if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { acceptInviteSchema, createAgentSchema, updateAgentSchema } from "./index.js";
|
||||
import { AGENT_ROLE_LABELS, acceptInviteSchema, createAgentSchema, updateAgentSchema } from "./index.js";
|
||||
|
||||
describe("dynamic adapter type validation schemas", () => {
|
||||
it("accepts external adapter types in create/update agent schemas", () => {
|
||||
@@ -35,4 +35,16 @@ describe("dynamic adapter type validation schemas", () => {
|
||||
}).adapterType,
|
||||
).toBe("external_adapter");
|
||||
});
|
||||
|
||||
it("accepts the security agent role and exposes its UI label", () => {
|
||||
expect(
|
||||
createAgentSchema.parse({
|
||||
name: "Security Engineer",
|
||||
role: "security",
|
||||
adapterType: "codex_local",
|
||||
}).role,
|
||||
).toBe("security");
|
||||
|
||||
expect(AGENT_ROLE_LABELS.security).toBe("Security");
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ export const AGENT_ROLES = [
|
||||
"cto",
|
||||
"cmo",
|
||||
"cfo",
|
||||
"security",
|
||||
"engineer",
|
||||
"designer",
|
||||
"pm",
|
||||
@@ -57,6 +58,7 @@ export const AGENT_ROLE_LABELS: Record<AgentRole, string> = {
|
||||
cto: "CTO",
|
||||
cmo: "CMO",
|
||||
cfo: "CFO",
|
||||
security: "Security",
|
||||
engineer: "Engineer",
|
||||
designer: "Designer",
|
||||
pm: "PM",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getCookies } from "better-auth/cookies";
|
||||
import {
|
||||
buildBetterAuthAdvancedOptions,
|
||||
deriveAuthCookiePrefix,
|
||||
deriveAuthTrustedOrigins,
|
||||
} from "../auth/better-auth.js";
|
||||
|
||||
const ORIGINAL_INSTANCE_ID = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
@@ -40,4 +41,38 @@ describe("Better Auth cookie scoping", () => {
|
||||
useSecureCookies: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("adds hostname port variants for authenticated mode on non-default ports", () => {
|
||||
const trustedOrigins = deriveAuthTrustedOrigins({
|
||||
deploymentMode: "authenticated",
|
||||
authBaseUrlMode: "auto",
|
||||
authPublicBaseUrl: undefined,
|
||||
allowedHostnames: ["Board.Example.Test"],
|
||||
port: 3101,
|
||||
} as Parameters<typeof deriveAuthTrustedOrigins>[0]);
|
||||
|
||||
expect(trustedOrigins).toEqual(expect.arrayContaining([
|
||||
"https://board.example.test",
|
||||
"http://board.example.test",
|
||||
"https://board.example.test:3101",
|
||||
"http://board.example.test:3101",
|
||||
]));
|
||||
});
|
||||
|
||||
it("prefers an explicit resolved listen port over the configured port", () => {
|
||||
const trustedOrigins = deriveAuthTrustedOrigins({
|
||||
deploymentMode: "authenticated",
|
||||
authBaseUrlMode: "auto",
|
||||
authPublicBaseUrl: undefined,
|
||||
allowedHostnames: ["board.example.test"],
|
||||
port: 3100,
|
||||
} as Parameters<typeof deriveAuthTrustedOrigins>[0], { listenPort: 3101 });
|
||||
|
||||
expect(trustedOrigins).toEqual(expect.arrayContaining([
|
||||
"https://board.example.test:3101",
|
||||
"http://board.example.test:3101",
|
||||
]));
|
||||
expect(trustedOrigins).not.toContain("https://board.example.test:3100");
|
||||
expect(trustedOrigins).not.toContain("http://board.example.test:3100");
|
||||
});
|
||||
});
|
||||
@@ -346,7 +346,16 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
await db.delete(companies);
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await db.delete(companySkills);
|
||||
try {
|
||||
await db.delete(companies);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (attempt === 4) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -895,6 +895,64 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("paginates earlier comments in descending order from an anchor comment", async () => {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const firstCommentId = randomUUID();
|
||||
const anchorCommentId = randomUUID();
|
||||
const latestCommentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Paged comments issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
await db.insert(issueComments).values([
|
||||
{
|
||||
id: firstCommentId,
|
||||
companyId,
|
||||
issueId,
|
||||
body: "First comment",
|
||||
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: anchorCommentId,
|
||||
companyId,
|
||||
issueId,
|
||||
body: "Anchor comment",
|
||||
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: latestCommentId,
|
||||
companyId,
|
||||
issueId,
|
||||
body: "Latest comment",
|
||||
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-26T12:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const comments = await svc.listComments(issueId, {
|
||||
afterCommentId: anchorCommentId,
|
||||
order: "desc",
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
expect(comments.map((comment) => comment.id)).toEqual([firstCommentId]);
|
||||
});
|
||||
|
||||
it("includes blockedBy summaries on list rows in one batched pass", async () => {
|
||||
const companyId = randomUUID();
|
||||
const blockerId = randomUUID();
|
||||
|
||||
@@ -43,6 +43,34 @@ describe("paperclip skill utils", () => {
|
||||
expect(entries[1]?.source).toBe(path.join(root, "skills", "paperclip-create-agent"));
|
||||
});
|
||||
|
||||
it("marks skills with required: false in SKILL.md frontmatter as optional", async () => {
|
||||
const root = await makeTempDir("paperclip-skill-optional-");
|
||||
cleanupDirs.add(root);
|
||||
|
||||
const moduleDir = path.join(root, "a", "b", "c", "d", "e");
|
||||
await fs.mkdir(moduleDir, { recursive: true });
|
||||
|
||||
// Required skill (no frontmatter flag)
|
||||
const requiredDir = path.join(root, "skills", "paperclip");
|
||||
await fs.mkdir(requiredDir, { recursive: true });
|
||||
await fs.writeFile(path.join(requiredDir, "SKILL.md"), "---\nname: paperclip\n---\n\n# Paperclip\n");
|
||||
|
||||
// Optional skill (required: false)
|
||||
const optionalDir = path.join(root, "skills", "paperclip-dev");
|
||||
await fs.mkdir(optionalDir, { recursive: true });
|
||||
await fs.writeFile(path.join(optionalDir, "SKILL.md"), "---\nname: paperclip-dev\nrequired: false\n---\n\n# Dev\n");
|
||||
|
||||
const entries = await listPaperclipSkillEntries(moduleDir);
|
||||
entries.sort((a, b) => a.runtimeName.localeCompare(b.runtimeName));
|
||||
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]?.runtimeName).toBe("paperclip");
|
||||
expect(entries[0]?.required).toBe(true);
|
||||
expect(entries[1]?.runtimeName).toBe("paperclip-dev");
|
||||
expect(entries[1]?.required).toBe(false);
|
||||
expect(entries[1]?.requiredReason).toBeNull();
|
||||
});
|
||||
|
||||
it("removes stale maintainer-only symlinks from a shared skills home", async () => {
|
||||
const root = await makeTempDir("paperclip-skill-cleanup-");
|
||||
cleanupDirs.add(root);
|
||||
|
||||
@@ -2,15 +2,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
createAppMock,
|
||||
createBetterAuthInstanceMock,
|
||||
createDbMock,
|
||||
detectPortMock,
|
||||
deriveAuthTrustedOriginsMock,
|
||||
feedbackExportServiceMock,
|
||||
feedbackServiceFactoryMock,
|
||||
fakeServer,
|
||||
loadConfigMock,
|
||||
} = vi.hoisted(() => {
|
||||
const createAppMock = vi.fn(async () => ((_: unknown, __: unknown) => {}) as never);
|
||||
const createBetterAuthInstanceMock = vi.fn(() => ({}));
|
||||
const createDbMock = vi.fn(() => ({}) as never);
|
||||
const detectPortMock = vi.fn(async (port: number) => port);
|
||||
const deriveAuthTrustedOriginsMock = vi.fn(() => []);
|
||||
const feedbackExportServiceMock = {
|
||||
flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 0, sent: 0, failed: 0 })),
|
||||
};
|
||||
@@ -24,46 +29,23 @@ const {
|
||||
}),
|
||||
close: vi.fn(),
|
||||
};
|
||||
const loadConfigMock = vi.fn();
|
||||
|
||||
return {
|
||||
createAppMock,
|
||||
createBetterAuthInstanceMock,
|
||||
createDbMock,
|
||||
detectPortMock,
|
||||
deriveAuthTrustedOriginsMock,
|
||||
feedbackExportServiceMock,
|
||||
feedbackServiceFactoryMock,
|
||||
fakeServer,
|
||||
loadConfigMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:http", () => ({
|
||||
createServer: vi.fn(() => fakeServer),
|
||||
}));
|
||||
|
||||
vi.mock("detect-port", () => ({
|
||||
default: detectPortMock,
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/db", () => ({
|
||||
createDb: createDbMock,
|
||||
ensurePostgresDatabase: vi.fn(),
|
||||
getPostgresDataDirectory: vi.fn(),
|
||||
inspectMigrations: vi.fn(async () => ({ status: "upToDate" })),
|
||||
applyPendingMigrations: vi.fn(),
|
||||
reconcilePendingMigrationHistory: vi.fn(async () => ({ repairedMigrations: [] })),
|
||||
formatDatabaseBackupResult: vi.fn(() => "ok"),
|
||||
runDatabaseBackup: vi.fn(),
|
||||
authUsers: {},
|
||||
companies: {},
|
||||
companyMemberships: {},
|
||||
instanceUserRoles: {},
|
||||
}));
|
||||
|
||||
vi.mock("../app.js", () => ({
|
||||
createApp: createAppMock,
|
||||
}));
|
||||
|
||||
vi.mock("../config.js", () => ({
|
||||
loadConfig: vi.fn(() => ({
|
||||
function buildTestConfig(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bind: "loopback",
|
||||
@@ -99,7 +81,39 @@ vi.mock("../config.js", () => ({
|
||||
heartbeatSchedulerEnabled: false,
|
||||
heartbeatSchedulerIntervalMs: 30000,
|
||||
companyDeletionEnabled: false,
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("node:http", () => ({
|
||||
createServer: vi.fn(() => fakeServer),
|
||||
}));
|
||||
|
||||
vi.mock("detect-port", () => ({
|
||||
default: detectPortMock,
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/db", () => ({
|
||||
createDb: createDbMock,
|
||||
ensurePostgresDatabase: vi.fn(),
|
||||
getPostgresDataDirectory: vi.fn(),
|
||||
inspectMigrations: vi.fn(async () => ({ status: "upToDate" })),
|
||||
applyPendingMigrations: vi.fn(),
|
||||
reconcilePendingMigrationHistory: vi.fn(async () => ({ repairedMigrations: [] })),
|
||||
formatDatabaseBackupResult: vi.fn(() => "ok"),
|
||||
runDatabaseBackup: vi.fn(),
|
||||
authUsers: {},
|
||||
companies: {},
|
||||
companyMemberships: {},
|
||||
instanceUserRoles: {},
|
||||
}));
|
||||
|
||||
vi.mock("../app.js", () => ({
|
||||
createApp: createAppMock,
|
||||
}));
|
||||
|
||||
vi.mock("../config.js", () => ({
|
||||
loadConfig: loadConfigMock,
|
||||
}));
|
||||
|
||||
vi.mock("../middleware/logger.js", () => ({
|
||||
@@ -166,8 +180,8 @@ vi.mock("../board-claim.js", () => ({
|
||||
|
||||
vi.mock("../auth/better-auth.js", () => ({
|
||||
createBetterAuthHandler: vi.fn(() => undefined),
|
||||
createBetterAuthInstance: vi.fn(() => ({})),
|
||||
deriveAuthTrustedOrigins: vi.fn(() => []),
|
||||
createBetterAuthInstance: createBetterAuthInstanceMock,
|
||||
deriveAuthTrustedOrigins: deriveAuthTrustedOriginsMock,
|
||||
resolveBetterAuthSession: vi.fn(async () => null),
|
||||
resolveBetterAuthSessionFromHeaders: vi.fn(async () => null),
|
||||
}));
|
||||
@@ -177,6 +191,9 @@ import { startServer } from "../index.ts";
|
||||
describe("startServer feedback export wiring", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
loadConfigMock.mockReturnValue(buildTestConfig());
|
||||
createBetterAuthInstanceMock.mockReturnValue({});
|
||||
deriveAuthTrustedOriginsMock.mockReturnValue([]);
|
||||
process.env.BETTER_AUTH_SECRET = "test-secret";
|
||||
});
|
||||
|
||||
@@ -194,9 +211,56 @@ describe("startServer feedback export wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("startServer authenticated auth origin setup", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
loadConfigMock.mockReturnValue(buildTestConfig());
|
||||
createBetterAuthInstanceMock.mockReturnValue({});
|
||||
deriveAuthTrustedOriginsMock.mockReturnValue([]);
|
||||
process.env.BETTER_AUTH_SECRET = "test-secret";
|
||||
});
|
||||
|
||||
it("derives trusted origins from the detected listen port before auth initializes", async () => {
|
||||
loadConfigMock.mockReturnValue(buildTestConfig({
|
||||
port: 3210,
|
||||
allowedHostnames: ["board.example.test"],
|
||||
authBaseUrlMode: "explicit",
|
||||
authPublicBaseUrl: "http://127.0.0.1:3210",
|
||||
}));
|
||||
detectPortMock.mockResolvedValueOnce(3211);
|
||||
deriveAuthTrustedOriginsMock.mockImplementation(
|
||||
(_config: { port: number; authPublicBaseUrl?: string }, opts?: { listenPort?: number }) => [
|
||||
`http://board.example.test:${opts?.listenPort ?? 0}`,
|
||||
],
|
||||
);
|
||||
|
||||
await startServer();
|
||||
|
||||
expect(deriveAuthTrustedOriginsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 3210,
|
||||
authPublicBaseUrl: "http://127.0.0.1:3211/",
|
||||
}),
|
||||
{ listenPort: 3211 },
|
||||
);
|
||||
expect(createBetterAuthInstanceMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
port: 3210,
|
||||
authPublicBaseUrl: "http://127.0.0.1:3211/",
|
||||
}),
|
||||
["http://board.example.test:3211"],
|
||||
);
|
||||
expect(createAppMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
serverPort: 3211,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("startServer PAPERCLIP_API_URL handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
loadConfigMock.mockReturnValue(buildTestConfig());
|
||||
process.env.BETTER_AUTH_SECRET = "test-secret";
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
});
|
||||
@@ -216,4 +280,34 @@ describe("startServer PAPERCLIP_API_URL handling", () => {
|
||||
expect(started.apiUrl).toBe("http://127.0.0.1:3210");
|
||||
expect(process.env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:3210");
|
||||
});
|
||||
|
||||
it("rewrites explicit-port auth public URLs when detect-port selects a new port", async () => {
|
||||
loadConfigMock.mockReturnValueOnce(buildTestConfig({
|
||||
port: 3100,
|
||||
authBaseUrlMode: "explicit",
|
||||
authPublicBaseUrl: "http://my-host.ts.net:3100",
|
||||
}));
|
||||
detectPortMock.mockResolvedValueOnce(3110);
|
||||
|
||||
const started = await startServer();
|
||||
|
||||
expect(started.listenPort).toBe(3110);
|
||||
expect(started.apiUrl).toBe("http://my-host.ts.net:3110");
|
||||
expect(process.env.PAPERCLIP_RUNTIME_API_URL).toBe("http://my-host.ts.net:3110");
|
||||
});
|
||||
|
||||
it("keeps no-port auth public URLs stable when detect-port selects a new port", async () => {
|
||||
loadConfigMock.mockReturnValueOnce(buildTestConfig({
|
||||
port: 3100,
|
||||
authBaseUrlMode: "explicit",
|
||||
authPublicBaseUrl: "https://paperclip.example",
|
||||
}));
|
||||
detectPortMock.mockResolvedValueOnce(3110);
|
||||
|
||||
const started = await startServer();
|
||||
|
||||
expect(started.listenPort).toBe(3110);
|
||||
expect(started.apiUrl).toBe("https://paperclip.example");
|
||||
expect(process.env.PAPERCLIP_RUNTIME_API_URL).toBe("https://paperclip.example");
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function buildLegacyConfig(sharedRoot: string) {
|
||||
function buildLegacyConfig(sharedRoot: string, publicBaseUrl = "http://127.0.0.1:3100") {
|
||||
return {
|
||||
$meta: {
|
||||
version: 1,
|
||||
@@ -56,7 +56,7 @@ function buildLegacyConfig(sharedRoot: string) {
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "explicit" as const,
|
||||
publicBaseUrl: "http://127.0.0.1:3100",
|
||||
publicBaseUrl,
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
@@ -439,7 +439,7 @@ describe("worktree config repair", () => {
|
||||
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
|
||||
});
|
||||
|
||||
it("persists runtime-selected worktree ports back into config", async () => {
|
||||
it("persists runtime-selected worktree ports back into explicit-port auth URLs", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-ports-"));
|
||||
const worktreeRoot = path.join(tempRoot, "PAP-878-create-a-mine-tab-in-inbox");
|
||||
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
||||
@@ -452,7 +452,7 @@ describe("worktree config repair", () => {
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...buildLegacyConfig(instanceRoot),
|
||||
...buildLegacyConfig(instanceRoot, "http://my-host.ts.net:3100"),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
|
||||
@@ -518,20 +518,122 @@ describe("worktree config repair", () => {
|
||||
|
||||
expect(writtenConfig.server.port).toBe(3103);
|
||||
expect(writtenConfig.database.embeddedPostgresPort).toBe(54335);
|
||||
expect(writtenConfig.auth.publicBaseUrl).toBe("http://127.0.0.1:3103/");
|
||||
expect(writtenConfig.auth.publicBaseUrl).toBe("http://my-host.ts.net:3103/");
|
||||
});
|
||||
|
||||
it("can update the in-memory config without rewriting env-driven ports", () => {
|
||||
const { config, changed } = applyRuntimePortSelectionToConfig(buildLegacyConfig("/tmp/shared"), {
|
||||
serverPort: 3104,
|
||||
databasePort: 54340,
|
||||
allowServerPortWrite: false,
|
||||
allowDatabasePortWrite: true,
|
||||
it("does not rewrite no-port public auth URLs when persisting runtime-selected ports", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-public-ports-"));
|
||||
const worktreeRoot = path.join(tempRoot, "PAP-125-public-base-url");
|
||||
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
||||
const configPath = path.join(paperclipDir, "config.json");
|
||||
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const instanceRoot = path.join(isolatedHome, "instances", "pap-125-public-base-url");
|
||||
|
||||
await fs.mkdir(paperclipDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...buildLegacyConfig(instanceRoot, "https://paperclip.example"),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
|
||||
embeddedPostgresPort: 54331,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(instanceRoot, "data", "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(instanceRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3101,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(instanceRoot, "data", "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(instanceRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.chdir(worktreeRoot);
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-125-public-base-url";
|
||||
process.env.PAPERCLIP_HOME = isolatedHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "pap-125-public-base-url";
|
||||
process.env.PAPERCLIP_CONFIG = configPath;
|
||||
|
||||
maybePersistWorktreeRuntimePorts({
|
||||
serverPort: 3103,
|
||||
databasePort: 54335,
|
||||
});
|
||||
|
||||
const writtenConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
|
||||
expect(writtenConfig.server.port).toBe(3103);
|
||||
expect(writtenConfig.database.embeddedPostgresPort).toBe(54335);
|
||||
expect(writtenConfig.auth.publicBaseUrl).toBe("https://paperclip.example");
|
||||
});
|
||||
|
||||
it("can update the in-memory config when auth URL already includes a port", () => {
|
||||
const { config, changed } = applyRuntimePortSelectionToConfig(
|
||||
buildLegacyConfig("/tmp/shared", "http://my-host.ts.net:3100"),
|
||||
{
|
||||
serverPort: 3104,
|
||||
databasePort: 54340,
|
||||
allowServerPortWrite: false,
|
||||
allowDatabasePortWrite: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(config.server.port).toBe(3100);
|
||||
expect(config.database.embeddedPostgresPort).toBe(54340);
|
||||
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3104/");
|
||||
expect(config.auth.publicBaseUrl).toBe("http://my-host.ts.net:3104/");
|
||||
});
|
||||
|
||||
it("does not rewrite the in-memory config when auth URL has no explicit port", () => {
|
||||
const { config, changed } = applyRuntimePortSelectionToConfig(
|
||||
buildLegacyConfig("/tmp/shared", "https://paperclip.example"),
|
||||
{
|
||||
serverPort: 3104,
|
||||
databasePort: 54340,
|
||||
allowServerPortWrite: false,
|
||||
allowDatabasePortWrite: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(config.server.port).toBe(3100);
|
||||
expect(config.database.embeddedPostgresPort).toBe(54340);
|
||||
expect(config.auth.publicBaseUrl).toBe("https://paperclip.example");
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,7 @@ function headersFromExpressRequest(req: Request): Headers {
|
||||
return headersFromNodeHeaders(req.headers);
|
||||
}
|
||||
|
||||
export function deriveAuthTrustedOrigins(config: Config): string[] {
|
||||
export function deriveAuthTrustedOrigins(config: Config, opts?: { listenPort?: number }): string[] {
|
||||
const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined;
|
||||
const trustedOrigins = new Set<string>();
|
||||
|
||||
@@ -73,18 +73,24 @@ export function deriveAuthTrustedOrigins(config: Config): string[] {
|
||||
}
|
||||
}
|
||||
if (config.deploymentMode === "authenticated") {
|
||||
const port = opts?.listenPort ?? config.port;
|
||||
const needsPortVariants = port !== 80 && port !== 443;
|
||||
for (const hostname of config.allowedHostnames) {
|
||||
const trimmed = hostname.trim().toLowerCase();
|
||||
if (!trimmed) continue;
|
||||
trustedOrigins.add(`https://${trimmed}`);
|
||||
trustedOrigins.add(`http://${trimmed}`);
|
||||
if (needsPortVariants) {
|
||||
trustedOrigins.add(`https://${trimmed}:${port}`);
|
||||
trustedOrigins.add(`http://${trimmed}:${port}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(trustedOrigins);
|
||||
}
|
||||
|
||||
export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins?: string[]): BetterAuthInstance {
|
||||
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;
|
||||
if (!secret) {
|
||||
@@ -93,15 +99,13 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins?
|
||||
"For local development, set BETTER_AUTH_SECRET=paperclip-dev-secret in your .env file.",
|
||||
);
|
||||
}
|
||||
const effectiveTrustedOrigins = trustedOrigins ?? deriveAuthTrustedOrigins(config);
|
||||
|
||||
const publicUrl = process.env.PAPERCLIP_PUBLIC_URL ?? baseUrl;
|
||||
const isHttpOnly = publicUrl ? publicUrl.startsWith("http://") : false;
|
||||
|
||||
const authConfig = {
|
||||
baseURL: baseUrl,
|
||||
secret,
|
||||
trustedOrigins: effectiveTrustedOrigins,
|
||||
trustedOrigins,
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: {
|
||||
|
||||
+13
-13
@@ -191,7 +191,8 @@ export async function startServer(): Promise<StartedServer> {
|
||||
if (!rawUrl) return undefined;
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
|
||||
// The URL API normalizes default ports like :80/:443 to "", so treat them as stable URLs.
|
||||
if (!parsed.port) return rawUrl;
|
||||
parsed.port = String(port);
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
@@ -467,6 +468,12 @@ export async function startServer(): Promise<StartedServer> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requestedListenPort = config.port;
|
||||
const listenPort = await detectPort(requestedListenPort);
|
||||
if (config.authBaseUrlMode === "explicit" && config.authPublicBaseUrl) {
|
||||
config.authPublicBaseUrl = rewriteLocalUrlPort(config.authPublicBaseUrl, listenPort);
|
||||
}
|
||||
|
||||
let authReady = config.deploymentMode === "local_trusted";
|
||||
let betterAuthHandler: RequestHandler | undefined;
|
||||
@@ -487,7 +494,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||
resolveBetterAuthSession,
|
||||
resolveBetterAuthSessionFromHeaders,
|
||||
} = await import("./auth/better-auth.js");
|
||||
const derivedTrustedOrigins = deriveAuthTrustedOrigins(config);
|
||||
const derivedTrustedOrigins = deriveAuthTrustedOrigins(config, { listenPort });
|
||||
const envTrustedOrigins = (process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
@@ -512,17 +519,10 @@ export async function startServer(): Promise<StartedServer> {
|
||||
await initializeBoardClaimChallenge(db as any, { deploymentMode: config.deploymentMode });
|
||||
authReady = true;
|
||||
}
|
||||
|
||||
const listenPort = await detectPort(config.port);
|
||||
if (listenPort !== config.port) {
|
||||
config.port = listenPort;
|
||||
}
|
||||
|
||||
if (resolvedEmbeddedPostgresPort !== null && resolvedEmbeddedPostgresPort !== config.embeddedPostgresPort) {
|
||||
config.embeddedPostgresPort = resolvedEmbeddedPostgresPort;
|
||||
}
|
||||
if (config.authBaseUrlMode === "explicit" && config.authPublicBaseUrl) {
|
||||
config.authPublicBaseUrl = rewriteLocalUrlPort(config.authPublicBaseUrl, listenPort);
|
||||
}
|
||||
maybePersistWorktreeRuntimePorts({
|
||||
serverPort: listenPort,
|
||||
databasePort: resolvedEmbeddedPostgresPort,
|
||||
@@ -626,8 +626,8 @@ export async function startServer(): Promise<StartedServer> {
|
||||
server.keepAliveTimeout = 185000;
|
||||
server.headersTimeout = 186000;
|
||||
|
||||
if (listenPort !== config.port) {
|
||||
logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`);
|
||||
if (listenPort !== requestedListenPort) {
|
||||
logger.warn(`Requested port is busy; using next free port (requestedPort=${requestedListenPort}, selectedPort=${listenPort})`);
|
||||
}
|
||||
|
||||
const runtimeListenHost = config.host;
|
||||
@@ -820,7 +820,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||
deploymentMode: config.deploymentMode,
|
||||
deploymentExposure: config.deploymentExposure,
|
||||
authReady,
|
||||
requestedPort: config.port,
|
||||
requestedPort: requestedListenPort,
|
||||
listenPort,
|
||||
uiMode,
|
||||
db: startupDbInfo,
|
||||
|
||||
@@ -9,7 +9,7 @@ Run this checklist on every heartbeat. This covers both your local planning/memo
|
||||
|
||||
## 2. Local Planning Check
|
||||
|
||||
1. Read today's plan from `./memory/YYYY-MM-DD.md` under "## Today's Plan".
|
||||
1. Read today's plan from `$AGENT_HOME/memory/YYYY-MM-DD.md` under "## Today's Plan".
|
||||
2. Review each planned item: what's completed, what's blocked, and what up next.
|
||||
3. For any blockers, resolve them yourself or escalate to the board.
|
||||
4. If you're ahead, start on the next highest priority.
|
||||
@@ -57,8 +57,8 @@ Status quick guide:
|
||||
## 7. Fact Extraction
|
||||
|
||||
1. Check for new conversations since last extraction.
|
||||
2. Extract durable facts to the relevant entity in `./life/` (PARA).
|
||||
3. Update `./memory/YYYY-MM-DD.md` with timeline entries.
|
||||
2. Extract durable facts to the relevant entity in `$AGENT_HOME/life/` (PARA).
|
||||
3. Update `$AGENT_HOME/memory/YYYY-MM-DD.md` with timeline entries.
|
||||
4. Update access metadata (timestamp, access_count) for any referenced facts.
|
||||
|
||||
## 8. Exit
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { and, asc, desc, eq, inArray, isNull, ne, notInArray, or, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, gt, inArray, isNull, lt, ne, notInArray, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
@@ -3259,14 +3259,14 @@ export function issueService(db: Db) {
|
||||
if (!anchor) return [];
|
||||
conditions.push(
|
||||
order === "asc"
|
||||
? sql<boolean>`(
|
||||
${issueComments.createdAt} > ${anchor.createdAt}
|
||||
OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} > ${anchor.id})
|
||||
)`
|
||||
: sql<boolean>`(
|
||||
${issueComments.createdAt} < ${anchor.createdAt}
|
||||
OR (${issueComments.createdAt} = ${anchor.createdAt} AND ${issueComments.id} < ${anchor.id})
|
||||
)`,
|
||||
? or(
|
||||
gt(issueComments.createdAt, anchor.createdAt),
|
||||
and(eq(issueComments.createdAt, anchor.createdAt), gt(issueComments.id, anchor.id)),
|
||||
)!
|
||||
: or(
|
||||
lt(issueComments.createdAt, anchor.createdAt),
|
||||
and(eq(issueComments.createdAt, anchor.createdAt), lt(issueComments.id, anchor.id)),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,16 +27,12 @@ function sanitizeWorktreeInstanceId(rawValue: string): string {
|
||||
return normalized || "worktree";
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
const value = hostname.trim().toLowerCase();
|
||||
return value === "127.0.0.1" || value === "localhost" || value === "::1";
|
||||
}
|
||||
|
||||
function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined {
|
||||
if (!rawUrl) return undefined;
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
|
||||
// The URL API normalizes default ports like :80/:443 to "", so treat them as stable URLs.
|
||||
if (!parsed.port) return rawUrl;
|
||||
parsed.port = String(port);
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
---
|
||||
name: paperclip-dev
|
||||
required: false
|
||||
description: >
|
||||
Develop and operate a local Paperclip instance — start and stop servers,
|
||||
pull updates from master, run builds and tests, manage worktrees, back up
|
||||
databases, and diagnose problems. Use whenever you need to work on the
|
||||
Paperclip codebase itself or keep a running instance healthy.
|
||||
---
|
||||
|
||||
# Paperclip Dev
|
||||
|
||||
This skill covers the day-to-day workflows for developing and operating a local Paperclip instance. It assumes you are working inside the Paperclip repo checkout with `origin` pointing to `git@github.com:paperclipai/paperclip.git`.
|
||||
|
||||
> **OPEN SOURCE HYGIENE:** This repository is public-facing. Treat anything you push to `origin` as publishable. Never commit or push secrets, API keys, tokens, private logs, PII, customer data, or machine-local configuration that should stay private. Keep git history tidy as well: avoid pushing throwaway branches, noisy checkpoint commits, or speculative work that does not need to be shared upstream.
|
||||
|
||||
> **MANDATORY:** Before running any CLI command, building, testing, or managing worktrees, you MUST read `doc/DEVELOPING.md` in the Paperclip repo. It is the canonical reference for all `paperclipai` CLI commands, their options, build/test workflows, database operations, worktree management, and diagnostics. Do NOT guess at flags or options — read the doc first.
|
||||
|
||||
## Quick Command Reference
|
||||
|
||||
These are the most common commands. For full option tables and details, see `doc/DEVELOPING.md`.
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Start server (first time or normal) | `npx paperclipai run` |
|
||||
| Dev mode with hot reload | `pnpm dev` |
|
||||
| Stop dev server | `pnpm dev:stop` |
|
||||
| Build | `pnpm build` |
|
||||
| Type-check | `pnpm typecheck` |
|
||||
| Run tests | `pnpm test` |
|
||||
| Run migrations | `pnpm db:migrate` |
|
||||
| Regenerate Drizzle client | `pnpm db:generate` |
|
||||
| Back up database | `npx paperclipai db:backup` |
|
||||
| Health check | `npx paperclipai doctor --repair` |
|
||||
| Print env vars | `npx paperclipai env` |
|
||||
| Trigger agent heartbeat | `npx paperclipai heartbeat run --agent-id <id>` |
|
||||
| Install agent skills locally | `npx paperclipai agent local-cli <agent> --company-id <id>` |
|
||||
|
||||
## Pulling from Master
|
||||
|
||||
```bash
|
||||
git fetch origin && git pull origin master
|
||||
pnpm install && pnpm build
|
||||
```
|
||||
|
||||
If schema changes landed, also run `pnpm db:generate && pnpm db:migrate`.
|
||||
|
||||
## Worktrees
|
||||
|
||||
Paperclip worktrees combine git worktrees with isolated Paperclip instances — each gets its own database, server port, and environment seeded from the primary instance.
|
||||
|
||||
> **MANDATORY:** Before creating or managing worktrees, you MUST read the "Worktree-local Instances" and "Worktree CLI Reference" sections in `doc/DEVELOPING.md`. That is the canonical reference for all worktree commands, their options, seed modes, and environment variables.
|
||||
|
||||
### When to Use Worktrees
|
||||
|
||||
- Starting a feature branch that needs its own Paperclip environment
|
||||
- Running parallel agent work without cross-contaminating the primary instance
|
||||
- Testing Paperclip changes in isolation before merging
|
||||
|
||||
### Command Overview
|
||||
|
||||
The CLI has two tiers (see `doc/DEVELOPING.md` for full option tables):
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `worktree:make <name>` | Create worktree + isolated instance in one step |
|
||||
| `worktree:list` | List worktrees and their Paperclip status |
|
||||
| `worktree:merge-history` | Preview/import issue history between worktrees |
|
||||
| `worktree:cleanup <name>` | Remove worktree, branch, and instance data |
|
||||
| `worktree init` | Bootstrap instance inside existing worktree |
|
||||
| `worktree env` | Print shell exports for worktree instance |
|
||||
| `worktree reseed` | Refresh worktree DB from another instance |
|
||||
| `worktree repair` | Fix broken/missing worktree instance metadata |
|
||||
|
||||
### Typical Workflow
|
||||
|
||||
```bash
|
||||
# 1. Create a worktree for a feature
|
||||
npx paperclipai worktree:make my-feature --start-point origin/main
|
||||
|
||||
# 2. Move into the worktree (path printed by worktree:make) and source the environment
|
||||
cd <worktree-path>
|
||||
eval "$(npx paperclipai worktree env)"
|
||||
|
||||
# 3. Start the isolated Paperclip server
|
||||
npx paperclipai run
|
||||
|
||||
# 4. Do your work
|
||||
|
||||
# 5. When done, merge history back if needed
|
||||
npx paperclipai worktree:merge-history --from paperclip-my-feature --to current --apply
|
||||
|
||||
# 6. Clean up
|
||||
npx paperclipai worktree:cleanup my-feature
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
> **MANDATORY PRE-FLIGHT:** Before creating ANY pull request, you MUST read the canonical source files listed below. Do NOT run `gh pr create` until you have read these files and verified your PR body matches every required section.
|
||||
|
||||
### Step 1 — Read the canonical files
|
||||
|
||||
You MUST read all three of these files before creating a PR:
|
||||
|
||||
1. **`.github/PULL_REQUEST_TEMPLATE.md`** — the required PR body structure
|
||||
2. **`CONTRIBUTING.md`** — contribution conventions, PR requirements, and thinking-path examples
|
||||
3. **`.github/workflows/pr.yml`** — CI checks that gate merge
|
||||
|
||||
### Step 2 — Validate your PR body against this checklist
|
||||
|
||||
After reading the template, verify your `--body` includes every one of these sections (names must match exactly):
|
||||
|
||||
- [ ] `## Thinking Path` — blockquote style, 5-8 reasoning steps
|
||||
- [ ] `## What Changed` — bullet list of concrete changes
|
||||
- [ ] `## Verification` — how a reviewer confirms this works
|
||||
- [ ] `## Risks` — what could go wrong
|
||||
- [ ] `## Model Used` — provider, model ID, version, capabilities
|
||||
- [ ] `## Checklist` — copied from the template, items checked off
|
||||
|
||||
If any section is missing or empty, do NOT submit the PR. Go back and fill it in.
|
||||
|
||||
### Step 3 — Create the PR
|
||||
|
||||
Only after completing Steps 1 and 2, run `gh pr create`. Use the template contents as the structure for `--body` — do not write a freeform summary.
|
||||
|
||||
## Hard Rules — Do NOT Bypass
|
||||
|
||||
These rules exist because agents have caused real damage by improvising around CLI failures. Follow them exactly.
|
||||
|
||||
1. **CLI is the only interface to worktrees and databases.** All worktree and database operations MUST go through `npx paperclipai` / `pnpm paperclipai` commands. You MUST NOT:
|
||||
- Run `pg_dump`, `pg_restore`, `psql`, `createdb`, `dropdb`, or any raw postgres commands
|
||||
- Manually set `DATABASE_URL` to point a worktree server at another instance's database
|
||||
- Run `rm -rf` on any `.paperclip/`, `.paperclip-worktrees/`, or `db/` directory
|
||||
- Directly manipulate embedded postgres data directories
|
||||
- Kill postgres processes by PID
|
||||
|
||||
2. **If a CLI command fails, stop and report.** Do NOT attempt workarounds. If `worktree:make`, `worktree reseed`, `worktree init`, `worktree:cleanup`, or any other `paperclipai` command fails:
|
||||
- Report the exact error message in your task comment
|
||||
- Set the task to `blocked`
|
||||
- Suggest running `npx paperclipai doctor --repair` or recreating the worktree from scratch
|
||||
- Do NOT try to manually replicate what the CLI does
|
||||
|
||||
3. **Never share databases between instances.** Each worktree instance gets its own isolated database. Never override `DATABASE_URL` to point one instance at another's database. This destroys isolation and can corrupt production data.
|
||||
|
||||
4. **Starting a dev server in a worktree requires setup first.** The correct sequence is:
|
||||
```bash
|
||||
# If the worktree already exists but has no running instance:
|
||||
cd <worktree-path>
|
||||
eval "$(npx paperclipai worktree env)"
|
||||
pnpm install && pnpm build
|
||||
npx paperclipai run # or pnpm dev
|
||||
|
||||
# If the worktree needs a fresh database:
|
||||
npx paperclipai worktree reseed --seed-mode full
|
||||
|
||||
# If the worktree is broken beyond repair:
|
||||
npx paperclipai worktree:cleanup <name>
|
||||
npx paperclipai worktree:make <name> --seed-mode full
|
||||
```
|
||||
If any step fails, follow rule 2 — stop and report.
|
||||
|
||||
5. **Seeding is a CLI operation.** When asked to seed a worktree database from the main instance, use `worktree reseed` or recreate with `worktree:make --seed-mode full`. Read `doc/DEVELOPING.md` for the full option tables. Never attempt manual database copying.
|
||||
|
||||
## Persistent Dev Servers (for Manual Testing)
|
||||
|
||||
When an agent needs to start a dev server that outlives the current heartbeat — for example, so a human or QA agent can manually test against it — the server process **must** be launched in a detached session. A process started directly from a heartbeat shell is killed when the heartbeat exits.
|
||||
|
||||
### Use `tmux` for persistent servers
|
||||
|
||||
```bash
|
||||
# 1. cd into the worktree (or main repo) and source the environment
|
||||
cd <worktree-path>
|
||||
eval "$(npx paperclipai worktree env)" # skip if using the primary instance
|
||||
|
||||
# 2. Start the dev server in a named, detached tmux session
|
||||
tmux new-session -d -s <session-name> 'pnpm dev'
|
||||
|
||||
# Example with a descriptive name:
|
||||
tmux new-session -d -s auth-fix-3102 'pnpm dev'
|
||||
```
|
||||
|
||||
### Managing the session
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Check if the session is alive | `tmux has-session -t <session-name> 2>/dev/null && echo running` |
|
||||
| View server output | `tmux capture-pane -t <session-name> -p` |
|
||||
| Kill the session | `tmux kill-session -t <session-name>` |
|
||||
| List all tmux sessions | `tmux list-sessions` |
|
||||
|
||||
### Verifying the server is reachable
|
||||
|
||||
After launching, confirm the port is listening before reporting success:
|
||||
|
||||
```bash
|
||||
# Wait briefly for startup, then verify
|
||||
sleep 3
|
||||
curl -sf http://127.0.0.1:<port>/api/health && echo "Server is up"
|
||||
lsof -nP -iTCP:<port> -sTCP:LISTEN
|
||||
```
|
||||
|
||||
### Key rules
|
||||
|
||||
1. **Always use `tmux` (or equivalent)** when a dev server needs to stay running after the heartbeat ends. A server started directly from the agent shell will die when the heartbeat exits, even if it appeared healthy moments before.
|
||||
2. **Name the session descriptively** — include the worktree name and port (e.g., `auth-fix-3102`).
|
||||
3. **Verify the server is listening** before reporting the URL to anyone.
|
||||
4. **Do not use `nohup` or `&` alone** — these are unreliable for agent shells that may have their entire process group killed.
|
||||
5. **Clean up when done** — kill the tmux session when the testing is complete.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
|---------|-----|
|
||||
| Server won't start | Run `npx paperclipai doctor --repair` to diagnose and auto-fix |
|
||||
| Forgetting to source worktree env | Run `eval "$(npx paperclipai worktree env)"` after cd-ing into the worktree |
|
||||
| Stale dependencies after pull | Run `pnpm install && pnpm build` after pulling |
|
||||
| Schema out of date after pull | Run `pnpm db:generate && pnpm db:migrate` |
|
||||
| Reseeding while target DB is running | Stop the target server first, or use `--allow-live-target` |
|
||||
| Cleaning up with unmerged commits | Merge or push first, or use `--force` if intentionally discarding |
|
||||
| Running agents against wrong instance | Verify `PAPERCLIP_API_URL` points to the correct port |
|
||||
| CLI command fails | Do NOT work around it — report the error and block (see Hard Rules above) |
|
||||
| Agent tries manual postgres operations | NEVER do this — all DB ops go through the CLI (see Hard Rules above) |
|
||||
| Dev server dies between heartbeats | Launch in a detached `tmux` session — see "Persistent Dev Servers" above |
|
||||
@@ -187,16 +187,17 @@ describe("MarkdownBody", () => {
|
||||
expect(html).not.toContain('aria-label="Issue PAP-1271: PAP-1271"');
|
||||
});
|
||||
|
||||
it("rewrites full issue URLs to internal issue links", () => {
|
||||
const html = renderMarkdown("See http://localhost:3100/PAP/issues/PAP-1179.", [
|
||||
{ identifier: "PAP-1179", status: "blocked" },
|
||||
it("preserves absolute issue URLs as external links", () => {
|
||||
const url = "http://remote.example.test:3103/PAPA/issues/PAPA-115#comment-850083f3-24de-43e7-a8cd-bc01f7cc9f0d";
|
||||
const html = renderMarkdown(`See ${url}.`, [
|
||||
{ identifier: "PAPA-115", status: "blocked" },
|
||||
]);
|
||||
|
||||
expect(html).toContain('href="/issues/PAP-1179"');
|
||||
expect(html).toContain("text-red-600");
|
||||
expect(html).toContain(">http://localhost:3100/PAP/issues/PAP-1179<");
|
||||
expect(html).toContain('data-mention-kind="issue"');
|
||||
expect(html).not.toContain("paperclip-mention-chip--issue");
|
||||
expect(html).toContain(`href="${url}"`);
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain("lucide-external-link");
|
||||
expect(html).not.toContain('href="/issues/PAPA-115"');
|
||||
expect(html).not.toContain("paperclip-markdown-issue-ref");
|
||||
});
|
||||
|
||||
it("linkifies plain internal issue paths in markdown text", () => {
|
||||
|
||||
@@ -9,8 +9,9 @@ describe("issue-reference", () => {
|
||||
expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts issue ids from full issue URLs", () => {
|
||||
expect(parseIssuePathIdFromPath("http://localhost:3100/PAP/issues/PAP-1179")).toBe("PAP-1179");
|
||||
it("does not treat full issue URLs as internal issue paths", () => {
|
||||
expect(parseIssuePathIdFromPath("http://localhost:3100/PAP/issues/PAP-1179")).toBeNull();
|
||||
expect(parseIssuePathIdFromPath("http://remote.example.test:3103/PAPA/issues/PAPA-115#comment-850083f3-24de-43e7-a8cd-bc01f7cc9f0d")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not treat GitHub issue URLs as internal Paperclip issue links", () => {
|
||||
@@ -24,15 +25,11 @@ describe("issue-reference", () => {
|
||||
expect(parseIssueReferenceFromHref("/issues/:id")).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes bare identifiers, issue URLs, and issue scheme links into internal links", () => {
|
||||
it("normalizes bare identifiers, relative issue paths, and issue scheme links into internal links", () => {
|
||||
expect(parseIssueReferenceFromHref("pap-1271")).toEqual({
|
||||
issuePathId: "PAP-1271",
|
||||
href: "/issues/PAP-1271",
|
||||
});
|
||||
expect(parseIssueReferenceFromHref("http://localhost:3100/PAP/issues/PAP-1179")).toEqual({
|
||||
issuePathId: "PAP-1179",
|
||||
href: "/issues/PAP-1179",
|
||||
});
|
||||
expect(parseIssueReferenceFromHref("/PAP/issues/pap-1180")).toEqual({
|
||||
issuePathId: "PAP-1180",
|
||||
href: "/issues/PAP-1180",
|
||||
@@ -54,6 +51,11 @@ describe("issue-reference", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves absolute Paperclip issue URLs so origin, port, and hash are not lost", () => {
|
||||
expect(parseIssueReferenceFromHref("http://localhost:3100/PAP/issues/PAP-1179")).toBeNull();
|
||||
expect(parseIssueReferenceFromHref("http://remote.example.test:3103/PAPA/issues/PAPA-115#comment-850083f3-24de-43e7-a8cd-bc01f7cc9f0d")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores literal route placeholder paths", () => {
|
||||
expect(parseIssueReferenceFromHref("/issues/:id")).toBeNull();
|
||||
expect(parseIssueReferenceFromHref("http://localhost:3100/api/issues/:id")).toBeNull();
|
||||
|
||||
@@ -11,18 +11,9 @@ const ISSUE_REFERENCE_TOKEN_RE = /issue:\/\/:?[^\s<>()]+|https?:\/\/[^\s<>()]+|\
|
||||
|
||||
export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): string | null {
|
||||
if (!pathOrUrl) return null;
|
||||
let pathname = pathOrUrl.trim();
|
||||
const pathname = pathOrUrl.trim();
|
||||
if (!pathname) return null;
|
||||
|
||||
if (/^https?:\/\//i.test(pathname)) {
|
||||
try {
|
||||
const url = new URL(pathname);
|
||||
if (url.hostname === "github.com" || url.hostname === "www.github.com") return null;
|
||||
pathname = url.pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (/^https?:\/\//i.test(pathname)) return null;
|
||||
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const issueIndex = segments.findIndex((segment) => segment === "issues");
|
||||
|
||||
@@ -37,6 +37,10 @@ export type ParsedMentionChip =
|
||||
const iconMaskCache = new Map<string, string>();
|
||||
|
||||
export function parseMentionChipHref(href: string): ParsedMentionChip | null {
|
||||
if (/^https?:\/\//i.test(href.trim())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const issue = parseIssueReferenceHref(href);
|
||||
if (issue) {
|
||||
return {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
matchesIssueRef,
|
||||
mergeIssueComments,
|
||||
removeIssueCommentFromPages,
|
||||
shouldAutoloadOlderIssueComments,
|
||||
takeOptimisticIssueComment,
|
||||
upsertIssueComment,
|
||||
upsertIssueCommentInPages,
|
||||
@@ -233,6 +234,45 @@ describe("optimistic issue comments", () => {
|
||||
).toBe("comment-1");
|
||||
});
|
||||
|
||||
it("autoloads older chat comments while the initial thread is still under the threshold", () => {
|
||||
expect(
|
||||
shouldAutoloadOlderIssueComments({
|
||||
activeDetailTab: "chat",
|
||||
hasOlderComments: true,
|
||||
loadedCommentCount: 50,
|
||||
initialPageLoading: false,
|
||||
olderPageLoading: false,
|
||||
autoLoadLimit: 150,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not autoload older comments outside the chat tab", () => {
|
||||
expect(
|
||||
shouldAutoloadOlderIssueComments({
|
||||
activeDetailTab: "activity",
|
||||
hasOlderComments: true,
|
||||
loadedCommentCount: 50,
|
||||
initialPageLoading: false,
|
||||
olderPageLoading: false,
|
||||
autoLoadLimit: 150,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("stops autoloading once the initial comment window reaches the cap", () => {
|
||||
expect(
|
||||
shouldAutoloadOlderIssueComments({
|
||||
activeDetailTab: "chat",
|
||||
hasOlderComments: true,
|
||||
loadedCommentCount: 150,
|
||||
initialPageLoading: false,
|
||||
olderPageLoading: false,
|
||||
autoLoadLimit: 150,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("upserts paged comments without dropping older pages", () => {
|
||||
const nextPages = upsertIssueCommentInPages(
|
||||
[
|
||||
|
||||
@@ -150,6 +150,21 @@ export function getNextIssueCommentPageParam(
|
||||
return lastPage[lastPage.length - 1]?.id;
|
||||
}
|
||||
|
||||
export function shouldAutoloadOlderIssueComments(params: {
|
||||
activeDetailTab: string;
|
||||
hasOlderComments: boolean;
|
||||
loadedCommentCount: number;
|
||||
initialPageLoading: boolean;
|
||||
olderPageLoading: boolean;
|
||||
autoLoadLimit: number;
|
||||
}) {
|
||||
if (params.activeDetailTab !== "chat") return false;
|
||||
if (!params.hasOlderComments) return false;
|
||||
if (params.initialPageLoading || params.olderPageLoading) return false;
|
||||
if (params.loadedCommentCount === 0) return false;
|
||||
return params.loadedCommentCount < params.autoLoadLimit;
|
||||
}
|
||||
|
||||
export function upsertIssueComment(
|
||||
comments: IssueComment[] | undefined,
|
||||
nextComment: IssueComment,
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
matchesIssueRef,
|
||||
mergeIssueComments,
|
||||
removeIssueCommentFromPages,
|
||||
shouldAutoloadOlderIssueComments,
|
||||
takeOptimisticIssueComment,
|
||||
upsertIssueCommentInPages,
|
||||
type IssueCommentReassignment,
|
||||
@@ -152,6 +153,7 @@ type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
||||
|
||||
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
||||
const ISSUE_COMMENT_PAGE_SIZE = 50;
|
||||
const ISSUE_COMMENT_AUTOLOAD_LIMIT = ISSUE_COMMENT_PAGE_SIZE * 3;
|
||||
const TREE_CONTROL_MODE_LABEL: Record<IssueTreeControlMode, string> = {
|
||||
pause: "Pause subtree",
|
||||
resume: "Resume subtree",
|
||||
@@ -1106,6 +1108,18 @@ export function IssueDetail() {
|
||||
() => flattenIssueCommentPages(commentPages?.pages),
|
||||
[commentPages?.pages],
|
||||
);
|
||||
const shouldPrefetchOlderComments = useMemo(
|
||||
() =>
|
||||
shouldAutoloadOlderIssueComments({
|
||||
activeDetailTab: detailTab,
|
||||
hasOlderComments: hasOlderComments ?? false,
|
||||
loadedCommentCount: comments.length,
|
||||
initialPageLoading: commentsLoading,
|
||||
olderPageLoading: commentsLoadingOlder,
|
||||
autoLoadLimit: ISSUE_COMMENT_AUTOLOAD_LIMIT,
|
||||
}),
|
||||
[comments.length, commentsLoading, commentsLoadingOlder, detailTab, hasOlderComments],
|
||||
);
|
||||
const { data: interactions = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.interactions(issueId!),
|
||||
queryFn: () => issuesApi.listInteractions(issueId!),
|
||||
@@ -2540,6 +2554,10 @@ export function IssueDetail() {
|
||||
const loadOlderComments = useCallback(() => {
|
||||
void fetchOlderComments();
|
||||
}, [fetchOlderComments]);
|
||||
useEffect(() => {
|
||||
if (!shouldPrefetchOlderComments) return;
|
||||
void fetchOlderComments();
|
||||
}, [fetchOlderComments, shouldPrefetchOlderComments]);
|
||||
const handleCommentVote = useCallback(async (commentId: string, vote: "up" | "down", options?: { allowSharing?: boolean; reason?: string }) => {
|
||||
await feedbackVoteMutation.mutateAsync({
|
||||
targetType: "issue_comment",
|
||||
|
||||
Reference in new issue
Block a user