mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Merge origin/master into master
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
f985b38c62
8 files changed
+424
-12
No files matched your search
@@ -6,6 +6,20 @@ import {
|
||||
} from "./approval.js";
|
||||
|
||||
describe("approval validators", () => {
|
||||
it("passes real line breaks through unchanged", () => {
|
||||
expect(addApprovalCommentSchema.parse({ body: "Looks good\n\nApproved." }).body)
|
||||
.toBe("Looks good\n\nApproved.");
|
||||
expect(resolveApprovalSchema.parse({ decisionNote: "Decision\n\nApproved." }).decisionNote)
|
||||
.toBe("Decision\n\nApproved.");
|
||||
});
|
||||
|
||||
it("accepts null and omitted optional decision notes", () => {
|
||||
expect(resolveApprovalSchema.parse({ decisionNote: null }).decisionNote).toBeNull();
|
||||
expect(resolveApprovalSchema.parse({}).decisionNote).toBeUndefined();
|
||||
expect(requestApprovalRevisionSchema.parse({ decisionNote: null }).decisionNote).toBeNull();
|
||||
expect(requestApprovalRevisionSchema.parse({}).decisionNote).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes escaped line breaks in approval comments and decision notes", () => {
|
||||
expect(addApprovalCommentSchema.parse({ body: "Looks good\\n\\nApproved." }).body)
|
||||
.toBe("Looks good\n\nApproved.");
|
||||
|
||||
@@ -9,6 +9,24 @@ import {
|
||||
} from "./issue.js";
|
||||
|
||||
describe("issue validators", () => {
|
||||
it("passes real line breaks through unchanged", () => {
|
||||
const parsed = createIssueSchema.parse({
|
||||
title: "Follow up PR",
|
||||
description: "Line 1\n\nLine 2",
|
||||
});
|
||||
|
||||
expect(parsed.description).toBe("Line 1\n\nLine 2");
|
||||
});
|
||||
|
||||
it("accepts null and omitted optional multiline issue fields", () => {
|
||||
expect(createIssueSchema.parse({ title: "Follow up PR", description: null }).description)
|
||||
.toBeNull();
|
||||
expect(createIssueSchema.parse({ title: "Follow up PR" }).description)
|
||||
.toBeUndefined();
|
||||
expect(updateIssueSchema.parse({ comment: undefined }).comment)
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes JSON-escaped line breaks in issue descriptions", () => {
|
||||
const parsed = createIssueSchema.parse({
|
||||
title: "Follow up PR",
|
||||
|
||||
@@ -91,13 +91,24 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
|
||||
const mockEnvironmentService = vi.hoisted(() => ({}));
|
||||
const mockEnsureOpenCodeModelConfiguredAndAvailable = vi.hoisted(() => vi.fn());
|
||||
const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("@paperclipai/adapter-opencode-local/server", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-opencode-local/server")>("@paperclipai/adapter-opencode-local/server");
|
||||
return {
|
||||
...actual,
|
||||
ensureOpenCodeModelConfiguredAndAvailable: mockEnsureOpenCodeModelConfiguredAndAvailable,
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentCreated: mockTrackAgentCreated,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
@@ -307,6 +318,8 @@ describe.sequential("agent permission routes", () => {
|
||||
mockGetTelemetryClient.mockReset();
|
||||
mockSyncInstructionsBundleConfigFromFilePath.mockReset();
|
||||
mockInstanceSettingsService.getGeneral.mockReset();
|
||||
mockEnvironmentService.getById.mockReset();
|
||||
mockEnsureOpenCodeModelConfiguredAndAvailable.mockReset();
|
||||
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockAgentService.getById.mockResolvedValue(baseAgent);
|
||||
@@ -834,6 +847,232 @@ describe.sequential("agent permission routes", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects creating an agent with an environment from another company", async () => {
|
||||
const environmentId = "33333333-3333-4333-8333-333333333333";
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: environmentId,
|
||||
companyId: "other-company",
|
||||
driver: "local",
|
||||
config: {},
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: "Builder",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
defaultEnvironmentId: environmentId,
|
||||
}));
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toContain("Environment not found");
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects creating an agent with an unsupported default environment driver", async () => {
|
||||
const environmentId = "33333333-3333-4333-8333-333333333333";
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: environmentId,
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
config: {},
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: "Builder",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
defaultEnvironmentId: environmentId,
|
||||
}));
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toContain('Environment driver "ssh" is not allowed here');
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const sshCapableAdapterCases = [
|
||||
{ adapterType: "codex_local", name: "Codex Builder", adapterConfig: {} },
|
||||
{ adapterType: "claude_local", name: "Claude Builder", adapterConfig: {} },
|
||||
{ adapterType: "gemini_local", name: "Gemini Builder", adapterConfig: {} },
|
||||
{ adapterType: "opencode_local", name: "OpenCode Builder", adapterConfig: { model: "opencode/gpt-5-nano" } },
|
||||
{ adapterType: "cursor", name: "Cursor Builder", adapterConfig: {} },
|
||||
{ adapterType: "pi_local", name: "Pi Builder", adapterConfig: { model: "openai/gpt-5.4-mini" } },
|
||||
];
|
||||
|
||||
for (const adapterCase of sshCapableAdapterCases) {
|
||||
it(`allows creating a ${adapterCase.adapterType} agent with an SSH default environment`, async () => {
|
||||
const environmentId = "33333333-3333-4333-8333-333333333333";
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: environmentId,
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
config: {},
|
||||
});
|
||||
mockAgentService.create.mockResolvedValue({
|
||||
...baseAgent,
|
||||
name: adapterCase.name,
|
||||
adapterType: adapterCase.adapterType,
|
||||
defaultEnvironmentId: environmentId,
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: adapterCase.name,
|
||||
role: "engineer",
|
||||
adapterType: adapterCase.adapterType,
|
||||
adapterConfig: adapterCase.adapterConfig,
|
||||
defaultEnvironmentId: environmentId,
|
||||
}));
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
expect.objectContaining({
|
||||
adapterType: adapterCase.adapterType,
|
||||
defaultEnvironmentId: environmentId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("rejects updating an agent with an unsupported default environment driver", async () => {
|
||||
const environmentId = "33333333-3333-4333-8333-333333333333";
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: environmentId,
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
config: {},
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
||||
.patch(`/api/agents/${agentId}`)
|
||||
.send({
|
||||
defaultEnvironmentId: environmentId,
|
||||
}));
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toContain('Environment driver "ssh" is not allowed here');
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const adapterCase of sshCapableAdapterCases) {
|
||||
it(`allows updating a ${adapterCase.adapterType} agent with an SSH default environment`, async () => {
|
||||
const environmentId = "33333333-3333-4333-8333-333333333333";
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: environmentId,
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
config: {},
|
||||
});
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: adapterCase.adapterType,
|
||||
adapterConfig: adapterCase.adapterConfig,
|
||||
defaultEnvironmentId: null,
|
||||
});
|
||||
mockAgentService.update.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: adapterCase.adapterType,
|
||||
adapterConfig: adapterCase.adapterConfig,
|
||||
defaultEnvironmentId: environmentId,
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
||||
.patch(`/api/agents/${agentId}`)
|
||||
.send({
|
||||
defaultEnvironmentId: environmentId,
|
||||
}));
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
agentId,
|
||||
expect.objectContaining({
|
||||
defaultEnvironmentId: environmentId,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("rejects switching an agent away from an SSH-capable runtime without clearing its SSH default", async () => {
|
||||
const environmentId = "33333333-3333-4333-8333-333333333333";
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: environmentId,
|
||||
companyId,
|
||||
driver: "ssh",
|
||||
config: {},
|
||||
});
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...baseAgent,
|
||||
adapterType: "codex_local",
|
||||
defaultEnvironmentId: environmentId,
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
||||
.patch(`/api/agents/${agentId}`)
|
||||
.send({
|
||||
adapterType: "process",
|
||||
}));
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toContain('Environment driver "ssh" is not allowed here');
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exposes explicit task assignment access on agent detail", async () => {
|
||||
mockAccessService.listPrincipalGrants.mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -489,6 +489,105 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
|
||||
expect(issue?.executionRunId).toBeNull();
|
||||
});
|
||||
|
||||
it("does not promote a scheduled retry after the issue is cancelled", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const sourceRunId = randomUUID();
|
||||
const now = new Date("2026-04-20T15:00:00.000Z");
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
wakeOnDemand: true,
|
||||
maxConcurrentRuns: 1,
|
||||
},
|
||||
},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: sourceRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
status: "failed",
|
||||
error: "upstream overload",
|
||||
errorCode: "adapter_failed",
|
||||
finishedAt: now,
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
wakeReason: "issue_assigned",
|
||||
},
|
||||
updatedAt: now,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Retry promotion cancellation",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
executionRunId: sourceRunId,
|
||||
executionAgentNameKey: "codexcoder",
|
||||
executionLockedAt: now,
|
||||
issueNumber: 1,
|
||||
identifier: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}-3`,
|
||||
});
|
||||
|
||||
const scheduled = await heartbeat.scheduleBoundedRetry(sourceRunId, {
|
||||
now,
|
||||
random: () => 0.5,
|
||||
});
|
||||
expect(scheduled.outcome).toBe("scheduled");
|
||||
if (scheduled.outcome !== "scheduled") return;
|
||||
|
||||
await db.update(issues).set({
|
||||
status: "cancelled",
|
||||
updatedAt: now,
|
||||
}).where(eq(issues.id, issueId));
|
||||
|
||||
const promotion = await heartbeat.promoteDueScheduledRetries(scheduled.dueAt);
|
||||
expect(promotion).toEqual({ promoted: 0, runIds: [] });
|
||||
|
||||
const oldRetry = await db
|
||||
.select({
|
||||
status: heartbeatRuns.status,
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, scheduled.run.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(oldRetry).toEqual({
|
||||
status: "cancelled",
|
||||
errorCode: "issue_cancelled",
|
||||
});
|
||||
|
||||
const issue = await db
|
||||
.select({ executionRunId: issues.executionRunId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(issue?.executionRunId).toBeNull();
|
||||
});
|
||||
|
||||
it("exhausts bounded retries after the hard cap", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
||||
@@ -3550,6 +3550,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const issue = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
executionRunId: issues.executionRunId,
|
||||
})
|
||||
@@ -3557,15 +3558,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.where(and(eq(issues.id, dueRunIssueId), eq(issues.companyId, dueRun.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (issue && issue.assigneeAgentId !== dueRun.agentId) {
|
||||
const reason = "Cancelled because the issue was reassigned before the scheduled retry became due";
|
||||
if (issue && (issue.assigneeAgentId !== dueRun.agentId || issue.status === "cancelled")) {
|
||||
const issueCancelled = issue.status === "cancelled";
|
||||
const reason = issueCancelled
|
||||
? "Cancelled because the issue was cancelled before the scheduled retry became due"
|
||||
: "Cancelled because the issue was reassigned before the scheduled retry became due";
|
||||
const cancelled = await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
finishedAt: now,
|
||||
error: reason,
|
||||
errorCode: "issue_reassigned",
|
||||
errorCode: issueCancelled ? "issue_cancelled" : "issue_reassigned",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
@@ -3608,9 +3612,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
eventType: "lifecycle",
|
||||
stream: "system",
|
||||
level: "warn",
|
||||
message: "Scheduled retry cancelled because issue ownership changed before it became due",
|
||||
message: issueCancelled
|
||||
? "Scheduled retry cancelled because issue was cancelled before it became due"
|
||||
: "Scheduled retry cancelled because issue ownership changed before it became due",
|
||||
payload: {
|
||||
issueId: issue.id,
|
||||
issueStatus: issue.status,
|
||||
scheduledRetryAttempt: cancelled.scheduledRetryAttempt,
|
||||
scheduledRetryAt: cancelled.scheduledRetryAt ? new Date(cancelled.scheduledRetryAt).toISOString() : null,
|
||||
scheduledRetryReason: cancelled.scheduledRetryReason,
|
||||
@@ -6305,6 +6312,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
executionRunId: issues.executionRunId,
|
||||
executionAgentNameKey: issues.executionAgentNameKey,
|
||||
@@ -6331,22 +6339,25 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
}
|
||||
|
||||
const cancelStaleScheduledRetry = async (scheduledRun: typeof heartbeatRuns.$inferSelect) => {
|
||||
const issueCancelled = issue.status === "cancelled";
|
||||
if (
|
||||
scheduledRun.status !== "scheduled_retry" ||
|
||||
scheduledRun.agentId === issue.assigneeAgentId
|
||||
(scheduledRun.agentId === issue.assigneeAgentId && !issueCancelled)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const reason = "Cancelled because the issue was reassigned before the scheduled retry became due";
|
||||
const reason = issueCancelled
|
||||
? "Cancelled because the issue was cancelled before the scheduled retry became due"
|
||||
: "Cancelled because the issue was reassigned before the scheduled retry became due";
|
||||
const cancelled = await tx
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
finishedAt: now,
|
||||
error: reason,
|
||||
errorCode: "issue_reassigned",
|
||||
errorCode: issueCancelled ? "issue_cancelled" : "issue_reassigned",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(heartbeatRuns.id, scheduledRun.id), eq(heartbeatRuns.status, "scheduled_retry")))
|
||||
@@ -6379,6 +6390,33 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.where(and(eq(issues.id, issue.id), eq(issues.executionRunId, scheduledRun.id)));
|
||||
}
|
||||
|
||||
const [eventSeq] = await tx
|
||||
.select({ maxSeq: sql<number | null>`max(${heartbeatRunEvents.seq})` })
|
||||
.from(heartbeatRunEvents)
|
||||
.where(eq(heartbeatRunEvents.runId, cancelled.id));
|
||||
|
||||
await tx.insert(heartbeatRunEvents).values({
|
||||
companyId: cancelled.companyId,
|
||||
runId: cancelled.id,
|
||||
agentId: cancelled.agentId,
|
||||
seq: Number(eventSeq?.maxSeq ?? 0) + 1,
|
||||
eventType: "lifecycle",
|
||||
stream: "system",
|
||||
level: "warn",
|
||||
message: issueCancelled
|
||||
? "Scheduled retry cancelled because issue was cancelled before it became due"
|
||||
: "Scheduled retry cancelled because issue ownership changed before it became due",
|
||||
payload: {
|
||||
issueId: issue.id,
|
||||
issueStatus: issue.status,
|
||||
scheduledRetryAttempt: cancelled.scheduledRetryAttempt,
|
||||
scheduledRetryAt: cancelled.scheduledRetryAt ? new Date(cancelled.scheduledRetryAt).toISOString() : null,
|
||||
scheduledRetryReason: cancelled.scheduledRetryReason,
|
||||
previousRetryAgentId: cancelled.agentId,
|
||||
currentAssigneeAgentId: issue.assigneeAgentId,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -319,14 +319,14 @@ function canBoardRecordWatchdogDecision(
|
||||
companyId: string,
|
||||
boardAccess: CurrentBoardAccess | undefined,
|
||||
) {
|
||||
if (!boardAccess) return true;
|
||||
if (!boardAccess) return false;
|
||||
if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true;
|
||||
|
||||
const membership = boardAccess.memberships?.find(
|
||||
(item) => item.companyId === companyId && item.status === "active",
|
||||
);
|
||||
if (!membership) return boardAccess.companyIds.includes(companyId) && !boardAccess.memberships;
|
||||
return membership.membershipRole !== "viewer";
|
||||
return membership.membershipRole !== "viewer" && membership.membershipRole !== null;
|
||||
}
|
||||
|
||||
function watchdogDecisionErrorMessage(error: unknown) {
|
||||
@@ -386,12 +386,13 @@ export function IssueRunLedger({
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = watchdogDecisionErrorMessage(error);
|
||||
const dedupeSuffix = error instanceof ApiError ? String(error.status) : "error";
|
||||
setWatchdogDecisionError(message);
|
||||
pushToast({
|
||||
title: "Watchdog decision not recorded",
|
||||
body: message,
|
||||
tone: "error",
|
||||
dedupeKey: `watchdog-decision:${issueId}:forbidden`,
|
||||
dedupeKey: `watchdog-decision:${issueId}:${dedupeSuffix}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -325,6 +325,7 @@ describe("MarkdownBody", () => {
|
||||
// icon can never be orphaned on the previous line from the URL text.
|
||||
expect(html).toMatch(/<span style="white-space:nowrap">.*lucide-github.*?<\/svg>h<\/span>/);
|
||||
expect(html).toContain("ttps://github.com/paperclipai/paperclip/pull/4099");
|
||||
expect(html).not.toContain("lucide-external-link");
|
||||
});
|
||||
|
||||
it("prefixes GitHub autolinks with the GitHub icon", () => {
|
||||
|
||||
@@ -140,6 +140,8 @@ function renderLinkBody(
|
||||
): ReactNode {
|
||||
if (!leadingIcon && !trailingIcon) return children;
|
||||
|
||||
// React-markdown can pass arrays/elements for styled link text; the nowrap
|
||||
// splitting below is intentionally limited to plain text links.
|
||||
if (typeof children === "string" && children.length > 0) {
|
||||
if (children.length === 1) {
|
||||
return (
|
||||
@@ -332,7 +334,7 @@ export function MarkdownBody({
|
||||
const leadingIcon = isGitHubLink ? (
|
||||
<Github aria-hidden="true" className="mr-1 inline h-3.5 w-3.5 align-[-0.125em]" />
|
||||
) : null;
|
||||
const trailingIcon = isExternal ? (
|
||||
const trailingIcon = isExternal && !isGitHubLink ? (
|
||||
<ExternalLink aria-hidden="true" className="ml-1 inline h-3 w-3 align-[-0.125em]" />
|
||||
) : null;
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user