Merge remote-tracking branch 'public-gh/master' into paperclip-subissues

* public-gh/master: (55 commits)
  fix(issue-documents): address greptile review
  Update packages/shared/src/validators/issue.ts
  feat(ui): add issue document copy and download actions
  fix(ui): unify new issue upload action
  feat(ui): stage issue files before create
  feat(ui): handle issue document edit conflicts
  fix(ui): refresh issue documents from live events
  feat(ui): deep link issue documents
  fix(ui): streamline issue document chrome
  fix(ui): collapse empty document and attachment states
  fix(ui): simplify document card body layout
  fix(issues): address document review comments
  feat(issues): add issue documents and inline editing
  docs: add agent evals framework plan
  fix(cli): quote env values with special characters
  Fix worktree seed source selection
  fix: address greptile follow-up
  docs: add paperclip skill tightening plan
  fix: isolate codex home in worktrees
  Add worktree UI branding
  ...

# Conflicts:
#	packages/db/src/migrations/meta/0028_snapshot.json
#	packages/db/src/migrations/meta/_journal.json
#	packages/shared/src/index.ts
#	server/src/routes/issues.ts
#	ui/src/api/issues.ts
#	ui/src/components/NewIssueDialog.tsx
#	ui/src/pages/IssueDetail.tsx
This commit is contained in:
Dotta
2026-03-14 12:24:40 -05:00
136 changed files with 17867 additions and 1511 deletions

View File

@@ -0,0 +1,433 @@
import { and, asc, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { documentRevisions, documents, issueDocuments, issues } from "@paperclipai/db";
import { issueDocumentKeySchema } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
function normalizeDocumentKey(key: string) {
const normalized = key.trim().toLowerCase();
const parsed = issueDocumentKeySchema.safeParse(normalized);
if (!parsed.success) {
throw unprocessable("Invalid document key", parsed.error.issues);
}
return parsed.data;
}
function isUniqueViolation(error: unknown): boolean {
return !!error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "23505";
}
export function extractLegacyPlanBody(description: string | null | undefined) {
if (!description) return null;
const match = /<plan>\s*([\s\S]*?)\s*<\/plan>/i.exec(description);
if (!match) return null;
const body = match[1]?.trim();
return body ? body : null;
}
function mapIssueDocumentRow(
row: {
id: string;
companyId: string;
issueId: string;
key: string;
title: string | null;
format: string;
latestBody: string;
latestRevisionId: string | null;
latestRevisionNumber: number;
createdByAgentId: string | null;
createdByUserId: string | null;
updatedByAgentId: string | null;
updatedByUserId: string | null;
createdAt: Date;
updatedAt: Date;
},
includeBody: boolean,
) {
return {
id: row.id,
companyId: row.companyId,
issueId: row.issueId,
key: row.key,
title: row.title,
format: row.format,
...(includeBody ? { body: row.latestBody } : {}),
latestRevisionId: row.latestRevisionId ?? null,
latestRevisionNumber: row.latestRevisionNumber,
createdByAgentId: row.createdByAgentId,
createdByUserId: row.createdByUserId,
updatedByAgentId: row.updatedByAgentId,
updatedByUserId: row.updatedByUserId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export function documentService(db: Db) {
return {
getIssueDocumentPayload: async (issue: { id: string; description: string | null }) => {
const [planDocument, documentSummaries] = await Promise.all([
db
.select({
id: documents.id,
companyId: documents.companyId,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
title: documents.title,
format: documents.format,
latestBody: documents.latestBody,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
createdByAgentId: documents.createdByAgentId,
createdByUserId: documents.createdByUserId,
updatedByAgentId: documents.updatedByAgentId,
updatedByUserId: documents.updatedByUserId,
createdAt: documents.createdAt,
updatedAt: documents.updatedAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, "plan")))
.then((rows) => rows[0] ?? null),
db
.select({
id: documents.id,
companyId: documents.companyId,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
title: documents.title,
format: documents.format,
latestBody: documents.latestBody,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
createdByAgentId: documents.createdByAgentId,
createdByUserId: documents.createdByUserId,
updatedByAgentId: documents.updatedByAgentId,
updatedByUserId: documents.updatedByUserId,
createdAt: documents.createdAt,
updatedAt: documents.updatedAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(eq(issueDocuments.issueId, issue.id))
.orderBy(asc(issueDocuments.key), desc(documents.updatedAt)),
]);
const legacyPlanBody = planDocument ? null : extractLegacyPlanBody(issue.description);
return {
planDocument: planDocument ? mapIssueDocumentRow(planDocument, true) : null,
documentSummaries: documentSummaries.map((row) => mapIssueDocumentRow(row, false)),
legacyPlanDocument: legacyPlanBody
? {
key: "plan" as const,
body: legacyPlanBody,
source: "issue_description" as const,
}
: null,
};
},
listIssueDocuments: async (issueId: string) => {
const rows = await db
.select({
id: documents.id,
companyId: documents.companyId,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
title: documents.title,
format: documents.format,
latestBody: documents.latestBody,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
createdByAgentId: documents.createdByAgentId,
createdByUserId: documents.createdByUserId,
updatedByAgentId: documents.updatedByAgentId,
updatedByUserId: documents.updatedByUserId,
createdAt: documents.createdAt,
updatedAt: documents.updatedAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(eq(issueDocuments.issueId, issueId))
.orderBy(asc(issueDocuments.key), desc(documents.updatedAt));
return rows.map((row) => mapIssueDocumentRow(row, true));
},
getIssueDocumentByKey: async (issueId: string, rawKey: string) => {
const key = normalizeDocumentKey(rawKey);
const row = await db
.select({
id: documents.id,
companyId: documents.companyId,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
title: documents.title,
format: documents.format,
latestBody: documents.latestBody,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
createdByAgentId: documents.createdByAgentId,
createdByUserId: documents.createdByUserId,
updatedByAgentId: documents.updatedByAgentId,
updatedByUserId: documents.updatedByUserId,
createdAt: documents.createdAt,
updatedAt: documents.updatedAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
.then((rows) => rows[0] ?? null);
return row ? mapIssueDocumentRow(row, true) : null;
},
listIssueDocumentRevisions: async (issueId: string, rawKey: string) => {
const key = normalizeDocumentKey(rawKey);
return db
.select({
id: documentRevisions.id,
companyId: documentRevisions.companyId,
documentId: documentRevisions.documentId,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
revisionNumber: documentRevisions.revisionNumber,
body: documentRevisions.body,
changeSummary: documentRevisions.changeSummary,
createdByAgentId: documentRevisions.createdByAgentId,
createdByUserId: documentRevisions.createdByUserId,
createdAt: documentRevisions.createdAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.innerJoin(documentRevisions, eq(documentRevisions.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
.orderBy(desc(documentRevisions.revisionNumber));
},
upsertIssueDocument: async (input: {
issueId: string;
key: string;
title?: string | null;
format: string;
body: string;
changeSummary?: string | null;
baseRevisionId?: string | null;
createdByAgentId?: string | null;
createdByUserId?: string | null;
}) => {
const key = normalizeDocumentKey(input.key);
const issue = await db
.select({ id: issues.id, companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, input.issueId))
.then((rows) => rows[0] ?? null);
if (!issue) throw notFound("Issue not found");
try {
return await db.transaction(async (tx) => {
const now = new Date();
const existing = await tx
.select({
id: documents.id,
companyId: documents.companyId,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
title: documents.title,
format: documents.format,
latestBody: documents.latestBody,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
createdByAgentId: documents.createdByAgentId,
createdByUserId: documents.createdByUserId,
updatedByAgentId: documents.updatedByAgentId,
updatedByUserId: documents.updatedByUserId,
createdAt: documents.createdAt,
updatedAt: documents.updatedAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, key)))
.then((rows) => rows[0] ?? null);
if (existing) {
if (!input.baseRevisionId) {
throw conflict("Document update requires baseRevisionId", {
currentRevisionId: existing.latestRevisionId,
});
}
if (input.baseRevisionId !== existing.latestRevisionId) {
throw conflict("Document was updated by someone else", {
currentRevisionId: existing.latestRevisionId,
});
}
const nextRevisionNumber = existing.latestRevisionNumber + 1;
const [revision] = await tx
.insert(documentRevisions)
.values({
companyId: issue.companyId,
documentId: existing.id,
revisionNumber: nextRevisionNumber,
body: input.body,
changeSummary: input.changeSummary ?? null,
createdByAgentId: input.createdByAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
createdAt: now,
})
.returning();
await tx
.update(documents)
.set({
title: input.title ?? null,
format: input.format,
latestBody: input.body,
latestRevisionId: revision.id,
latestRevisionNumber: nextRevisionNumber,
updatedByAgentId: input.createdByAgentId ?? null,
updatedByUserId: input.createdByUserId ?? null,
updatedAt: now,
})
.where(eq(documents.id, existing.id));
await tx
.update(issueDocuments)
.set({ updatedAt: now })
.where(eq(issueDocuments.documentId, existing.id));
return {
created: false as const,
document: {
...existing,
title: input.title ?? null,
format: input.format,
body: input.body,
latestRevisionId: revision.id,
latestRevisionNumber: nextRevisionNumber,
updatedByAgentId: input.createdByAgentId ?? null,
updatedByUserId: input.createdByUserId ?? null,
updatedAt: now,
},
};
}
if (input.baseRevisionId) {
throw conflict("Document does not exist yet", { key });
}
const [document] = await tx
.insert(documents)
.values({
companyId: issue.companyId,
title: input.title ?? null,
format: input.format,
latestBody: input.body,
latestRevisionId: null,
latestRevisionNumber: 1,
createdByAgentId: input.createdByAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
updatedByAgentId: input.createdByAgentId ?? null,
updatedByUserId: input.createdByUserId ?? null,
createdAt: now,
updatedAt: now,
})
.returning();
const [revision] = await tx
.insert(documentRevisions)
.values({
companyId: issue.companyId,
documentId: document.id,
revisionNumber: 1,
body: input.body,
changeSummary: input.changeSummary ?? null,
createdByAgentId: input.createdByAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
createdAt: now,
})
.returning();
await tx
.update(documents)
.set({ latestRevisionId: revision.id })
.where(eq(documents.id, document.id));
await tx.insert(issueDocuments).values({
companyId: issue.companyId,
issueId: issue.id,
documentId: document.id,
key,
createdAt: now,
updatedAt: now,
});
return {
created: true as const,
document: {
id: document.id,
companyId: issue.companyId,
issueId: issue.id,
key,
title: document.title,
format: document.format,
body: document.latestBody,
latestRevisionId: revision.id,
latestRevisionNumber: 1,
createdByAgentId: document.createdByAgentId,
createdByUserId: document.createdByUserId,
updatedByAgentId: document.updatedByAgentId,
updatedByUserId: document.updatedByUserId,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
},
};
});
} catch (error) {
if (isUniqueViolation(error)) {
throw conflict("Document key already exists on this issue", { key });
}
throw error;
}
},
deleteIssueDocument: async (issueId: string, rawKey: string) => {
const key = normalizeDocumentKey(rawKey);
return db.transaction(async (tx) => {
const existing = await tx
.select({
id: documents.id,
companyId: documents.companyId,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
title: documents.title,
format: documents.format,
latestBody: documents.latestBody,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
createdByAgentId: documents.createdByAgentId,
createdByUserId: documents.createdByUserId,
updatedByAgentId: documents.updatedByAgentId,
updatedByUserId: documents.updatedByUserId,
createdAt: documents.createdAt,
updatedAt: documents.updatedAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
await tx.delete(issueDocuments).where(eq(issueDocuments.documentId, existing.id));
await tx.delete(documents).where(eq(documents.id, existing.id));
return {
...existing,
body: existing.latestBody,
latestRevisionId: existing.latestRevisionId ?? null,
};
});
},
};
}

View File

@@ -18,7 +18,7 @@ import { logger } from "../middleware/logger.js";
import { publishLiveEvent } from "./live-events.js";
import { getRunLogStore, type RunLogHandle } from "./run-log-store.js";
import { getServerAdapter, runningProcesses } from "../adapters/index.js";
import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec } from "../adapters/index.js";
import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec, UsageSummary } from "../adapters/index.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
import { costService } from "./costs.js";
@@ -48,6 +48,14 @@ const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10;
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
const startLocksByAgent = new Map<string, Promise<void>>();
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
const SESSIONED_LOCAL_ADAPTERS = new Set([
"claude_local",
"codex_local",
"cursor",
"gemini_local",
"opencode_local",
"pi_local",
]);
const heartbeatRunListColumns = {
id: heartbeatRuns.id,
@@ -118,6 +126,26 @@ interface WakeupOptions {
contextSnapshot?: Record<string, unknown>;
}
type UsageTotals = {
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
};
type SessionCompactionPolicy = {
enabled: boolean;
maxSessionRuns: number;
maxRawInputTokens: number;
maxSessionAgeHours: number;
};
type SessionCompactionDecision = {
rotate: boolean;
reason: string | null;
handoffMarkdown: string | null;
previousRunId: string | null;
};
interface ParsedIssueAssigneeAdapterOverrides {
adapterConfig: Record<string, unknown> | null;
useProjectWorkspace: boolean | null;
@@ -157,6 +185,88 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function normalizeUsageTotals(usage: UsageSummary | null | undefined): UsageTotals | null {
if (!usage) return null;
return {
inputTokens: Math.max(0, Math.floor(asNumber(usage.inputTokens, 0))),
cachedInputTokens: Math.max(0, Math.floor(asNumber(usage.cachedInputTokens, 0))),
outputTokens: Math.max(0, Math.floor(asNumber(usage.outputTokens, 0))),
};
}
function readRawUsageTotals(usageJson: unknown): UsageTotals | null {
const parsed = parseObject(usageJson);
if (Object.keys(parsed).length === 0) return null;
const inputTokens = Math.max(
0,
Math.floor(asNumber(parsed.rawInputTokens, asNumber(parsed.inputTokens, 0))),
);
const cachedInputTokens = Math.max(
0,
Math.floor(asNumber(parsed.rawCachedInputTokens, asNumber(parsed.cachedInputTokens, 0))),
);
const outputTokens = Math.max(
0,
Math.floor(asNumber(parsed.rawOutputTokens, asNumber(parsed.outputTokens, 0))),
);
if (inputTokens <= 0 && cachedInputTokens <= 0 && outputTokens <= 0) {
return null;
}
return {
inputTokens,
cachedInputTokens,
outputTokens,
};
}
function deriveNormalizedUsageDelta(current: UsageTotals | null, previous: UsageTotals | null): UsageTotals | null {
if (!current) return null;
if (!previous) return { ...current };
const inputTokens = current.inputTokens >= previous.inputTokens
? current.inputTokens - previous.inputTokens
: current.inputTokens;
const cachedInputTokens = current.cachedInputTokens >= previous.cachedInputTokens
? current.cachedInputTokens - previous.cachedInputTokens
: current.cachedInputTokens;
const outputTokens = current.outputTokens >= previous.outputTokens
? current.outputTokens - previous.outputTokens
: current.outputTokens;
return {
inputTokens: Math.max(0, inputTokens),
cachedInputTokens: Math.max(0, cachedInputTokens),
outputTokens: Math.max(0, outputTokens),
};
}
function formatCount(value: number | null | undefined) {
if (typeof value !== "number" || !Number.isFinite(value)) return "0";
return value.toLocaleString("en-US");
}
function parseSessionCompactionPolicy(agent: typeof agents.$inferSelect): SessionCompactionPolicy {
const runtimeConfig = parseObject(agent.runtimeConfig);
const heartbeat = parseObject(runtimeConfig.heartbeat);
const compaction = parseObject(
heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtimeConfig.sessionCompaction,
);
const supportsSessions = SESSIONED_LOCAL_ADAPTERS.has(agent.adapterType);
const enabled = compaction.enabled === undefined
? supportsSessions
: asBoolean(compaction.enabled, supportsSessions);
return {
enabled,
maxSessionRuns: Math.max(0, Math.floor(asNumber(compaction.maxSessionRuns, 200))),
maxRawInputTokens: Math.max(0, Math.floor(asNumber(compaction.maxRawInputTokens, 2_000_000))),
maxSessionAgeHours: Math.max(0, Math.floor(asNumber(compaction.maxSessionAgeHours, 72))),
};
}
export function resolveRuntimeSessionParamsForWorkspace(input: {
agentId: string;
previousSessionParams: Record<string, unknown> | null;
@@ -261,29 +371,20 @@ function deriveTaskKey(
export function shouldResetTaskSessionForWake(
contextSnapshot: Record<string, unknown> | null | undefined,
) {
if (contextSnapshot?.forceFreshSession === true) return true;
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
if (wakeReason === "issue_assigned") return true;
const wakeSource = readNonEmptyString(contextSnapshot?.wakeSource);
if (wakeSource === "timer") return true;
const wakeTriggerDetail = readNonEmptyString(contextSnapshot?.wakeTriggerDetail);
return wakeSource === "on_demand" && wakeTriggerDetail === "manual";
return false;
}
function describeSessionResetReason(
contextSnapshot: Record<string, unknown> | null | undefined,
) {
if (contextSnapshot?.forceFreshSession === true) return "forceFreshSession was requested";
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
if (wakeReason === "issue_assigned") return "wake reason is issue_assigned";
const wakeSource = readNonEmptyString(contextSnapshot?.wakeSource);
if (wakeSource === "timer") return "wake source is timer";
const wakeTriggerDetail = readNonEmptyString(contextSnapshot?.wakeTriggerDetail);
if (wakeSource === "on_demand" && wakeTriggerDetail === "manual") {
return "this is a manual invoke";
}
return null;
}
@@ -517,6 +618,176 @@ export function heartbeatService(db: Db) {
.then((rows) => rows[0] ?? null);
}
async function getLatestRunForSession(
agentId: string,
sessionId: string,
opts?: { excludeRunId?: string | null },
) {
const conditions = [
eq(heartbeatRuns.agentId, agentId),
eq(heartbeatRuns.sessionIdAfter, sessionId),
];
if (opts?.excludeRunId) {
conditions.push(sql`${heartbeatRuns.id} <> ${opts.excludeRunId}`);
}
return db
.select()
.from(heartbeatRuns)
.where(and(...conditions))
.orderBy(desc(heartbeatRuns.createdAt))
.limit(1)
.then((rows) => rows[0] ?? null);
}
async function getOldestRunForSession(agentId: string, sessionId: string) {
return db
.select({
id: heartbeatRuns.id,
createdAt: heartbeatRuns.createdAt,
})
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.agentId, agentId), eq(heartbeatRuns.sessionIdAfter, sessionId)))
.orderBy(asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id))
.limit(1)
.then((rows) => rows[0] ?? null);
}
async function resolveNormalizedUsageForSession(input: {
agentId: string;
runId: string;
sessionId: string | null;
rawUsage: UsageTotals | null;
}) {
const { agentId, runId, sessionId, rawUsage } = input;
if (!sessionId || !rawUsage) {
return {
normalizedUsage: rawUsage,
previousRawUsage: null as UsageTotals | null,
derivedFromSessionTotals: false,
};
}
const previousRun = await getLatestRunForSession(agentId, sessionId, { excludeRunId: runId });
const previousRawUsage = readRawUsageTotals(previousRun?.usageJson);
return {
normalizedUsage: deriveNormalizedUsageDelta(rawUsage, previousRawUsage),
previousRawUsage,
derivedFromSessionTotals: previousRawUsage !== null,
};
}
async function evaluateSessionCompaction(input: {
agent: typeof agents.$inferSelect;
sessionId: string | null;
issueId: string | null;
}): Promise<SessionCompactionDecision> {
const { agent, sessionId, issueId } = input;
if (!sessionId) {
return {
rotate: false,
reason: null,
handoffMarkdown: null,
previousRunId: null,
};
}
const policy = parseSessionCompactionPolicy(agent);
if (!policy.enabled) {
return {
rotate: false,
reason: null,
handoffMarkdown: null,
previousRunId: null,
};
}
const fetchLimit = Math.max(policy.maxSessionRuns > 0 ? policy.maxSessionRuns + 1 : 0, 4);
const runs = await db
.select({
id: heartbeatRuns.id,
createdAt: heartbeatRuns.createdAt,
usageJson: heartbeatRuns.usageJson,
resultJson: heartbeatRuns.resultJson,
error: heartbeatRuns.error,
})
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.agentId, agent.id), eq(heartbeatRuns.sessionIdAfter, sessionId)))
.orderBy(desc(heartbeatRuns.createdAt))
.limit(fetchLimit);
if (runs.length === 0) {
return {
rotate: false,
reason: null,
handoffMarkdown: null,
previousRunId: null,
};
}
const latestRun = runs[0] ?? null;
const oldestRun =
policy.maxSessionAgeHours > 0
? await getOldestRunForSession(agent.id, sessionId)
: runs[runs.length - 1] ?? latestRun;
const latestRawUsage = readRawUsageTotals(latestRun?.usageJson);
const sessionAgeHours =
latestRun && oldestRun
? Math.max(
0,
(new Date(latestRun.createdAt).getTime() - new Date(oldestRun.createdAt).getTime()) / (1000 * 60 * 60),
)
: 0;
let reason: string | null = null;
if (policy.maxSessionRuns > 0 && runs.length > policy.maxSessionRuns) {
reason = `session exceeded ${policy.maxSessionRuns} runs`;
} else if (
policy.maxRawInputTokens > 0 &&
latestRawUsage &&
latestRawUsage.inputTokens >= policy.maxRawInputTokens
) {
reason =
`session raw input reached ${formatCount(latestRawUsage.inputTokens)} tokens ` +
`(threshold ${formatCount(policy.maxRawInputTokens)})`;
} else if (policy.maxSessionAgeHours > 0 && sessionAgeHours >= policy.maxSessionAgeHours) {
reason = `session age reached ${Math.floor(sessionAgeHours)} hours`;
}
if (!reason || !latestRun) {
return {
rotate: false,
reason: null,
handoffMarkdown: null,
previousRunId: latestRun?.id ?? null,
};
}
const latestSummary = summarizeHeartbeatRunResultJson(latestRun.resultJson);
const latestTextSummary =
readNonEmptyString(latestSummary?.summary) ??
readNonEmptyString(latestSummary?.result) ??
readNonEmptyString(latestSummary?.message) ??
readNonEmptyString(latestRun.error);
const handoffMarkdown = [
"Paperclip session handoff:",
`- Previous session: ${sessionId}`,
issueId ? `- Issue: ${issueId}` : "",
`- Rotation reason: ${reason}`,
latestTextSummary ? `- Last run summary: ${latestTextSummary}` : "",
"Continue from the current task state. Rebuild only the minimum context you need.",
]
.filter(Boolean)
.join("\n");
return {
rotate: true,
reason,
handoffMarkdown,
previousRunId: latestRun.id,
};
}
async function resolveSessionBeforeForWakeup(
agent: typeof agents.$inferSelect,
taskKey: string | null,
@@ -1061,9 +1332,10 @@ export function heartbeatService(db: Db) {
run: typeof heartbeatRuns.$inferSelect,
result: AdapterExecutionResult,
session: { legacySessionId: string | null },
normalizedUsage?: UsageTotals | null,
) {
await ensureRuntimeState(agent);
const usage = result.usage;
const usage = normalizedUsage ?? normalizeUsageTotals(result.usage);
const inputTokens = usage?.inputTokens ?? 0;
const outputTokens = usage?.outputTokens ?? 0;
const cachedInputTokens = usage?.cachedInputTokens ?? 0;
@@ -1383,15 +1655,42 @@ export function heartbeatService(db: Db) {
context.projectId = executionWorkspace.projectId;
}
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
const previousSessionDisplayId = truncateDisplayId(
let previousSessionDisplayId = truncateDisplayId(
taskSessionForRun?.sessionDisplayId ??
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ??
readNonEmptyString(runtimeSessionParams?.sessionId) ??
runtimeSessionFallback,
);
let runtimeSessionIdForAdapter =
readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback;
let runtimeSessionParamsForAdapter = runtimeSessionParams;
const sessionCompaction = await evaluateSessionCompaction({
agent,
sessionId: previousSessionDisplayId ?? runtimeSessionIdForAdapter,
issueId,
});
if (sessionCompaction.rotate) {
context.paperclipSessionHandoffMarkdown = sessionCompaction.handoffMarkdown;
context.paperclipSessionRotationReason = sessionCompaction.reason;
context.paperclipPreviousSessionId = previousSessionDisplayId ?? runtimeSessionIdForAdapter;
runtimeSessionIdForAdapter = null;
runtimeSessionParamsForAdapter = null;
previousSessionDisplayId = null;
if (sessionCompaction.reason) {
runtimeWorkspaceWarnings.push(
`Starting a fresh session because ${sessionCompaction.reason}.`,
);
}
} else {
delete context.paperclipSessionHandoffMarkdown;
delete context.paperclipSessionRotationReason;
delete context.paperclipPreviousSessionId;
}
const runtimeForAdapter = {
sessionId: readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback,
sessionParams: runtimeSessionParams,
sessionId: runtimeSessionIdForAdapter,
sessionParams: runtimeSessionParamsForAdapter,
sessionDisplayId: previousSessionDisplayId,
taskKey,
};
@@ -1636,6 +1935,14 @@ export function heartbeatService(db: Db) {
previousDisplayId: runtimeForAdapter.sessionDisplayId,
previousLegacySessionId: runtimeForAdapter.sessionId,
});
const rawUsage = normalizeUsageTotals(adapterResult.usage);
const sessionUsageResolution = await resolveNormalizedUsageForSession({
agentId: agent.id,
runId: run.id,
sessionId: nextSessionState.displayId ?? nextSessionState.legacySessionId,
rawUsage,
});
const normalizedUsage = sessionUsageResolution.normalizedUsage;
let outcome: "succeeded" | "failed" | "cancelled" | "timed_out";
const latestRun = await getRun(run.id);
@@ -1664,9 +1971,23 @@ export function heartbeatService(db: Db) {
: "failed";
const usageJson =
adapterResult.usage || adapterResult.costUsd != null
normalizedUsage || adapterResult.costUsd != null
? ({
...(adapterResult.usage ?? {}),
...(normalizedUsage ?? {}),
...(rawUsage ? {
rawInputTokens: rawUsage.inputTokens,
rawCachedInputTokens: rawUsage.cachedInputTokens,
rawOutputTokens: rawUsage.outputTokens,
} : {}),
...(sessionUsageResolution.derivedFromSessionTotals ? { usageSource: "session_delta" } : {}),
...((nextSessionState.displayId ?? nextSessionState.legacySessionId)
? { persistedSessionId: nextSessionState.displayId ?? nextSessionState.legacySessionId }
: {}),
sessionReused: runtimeForAdapter.sessionId != null || runtimeForAdapter.sessionDisplayId != null,
taskSessionReused: taskSessionForRun != null,
freshSession: runtimeForAdapter.sessionId == null && runtimeForAdapter.sessionDisplayId == null,
sessionRotated: sessionCompaction.rotate,
sessionRotationReason: sessionCompaction.reason,
...(adapterResult.costUsd != null ? { costUsd: adapterResult.costUsd } : {}),
...(adapterResult.billingType ? { billingType: adapterResult.billingType } : {}),
} as Record<string, unknown>)
@@ -1723,7 +2044,7 @@ export function heartbeatService(db: Db) {
if (finalizedRun) {
await updateRuntimeState(agent, finalizedRun, adapterResult, {
legacySessionId: nextSessionState.legacySessionId,
});
}, normalizedUsage);
if (taskKey) {
if (adapterResult.clearSession || (!nextSessionState.params && !nextSessionState.displayId)) {
await clearTaskSessions(agent.companyId, agent.id, {

View File

@@ -1,6 +1,7 @@
export { companyService } from "./companies.js";
export { agentService, deduplicateAgentName } from "./agents.js";
export { assetService } from "./assets.js";
export { documentService, extractLegacyPlanBody } from "./documents.js";
export { projectService } from "./projects.js";
export { issueService, type IssueFilters } from "./issues.js";
export { issueApprovalService } from "./issue-approvals.js";

View File

@@ -5,12 +5,14 @@ import {
assets,
companies,
companyMemberships,
documents,
goals,
heartbeatRuns,
executionWorkspaces,
issueAttachments,
issueLabels,
issueComments,
issueDocuments,
issueReadStates,
issues,
labels,
@@ -28,6 +30,7 @@ import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallbac
import { getDefaultCompanyGoal } from "./goals.js";
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500;
function assertTransition(from: string, to: string) {
if (from === to) return;
@@ -862,6 +865,10 @@ export function issueService(db: Db) {
.select({ assetId: issueAttachments.assetId })
.from(issueAttachments)
.where(eq(issueAttachments.issueId, id));
const issueDocumentIds = await tx
.select({ documentId: issueDocuments.documentId })
.from(issueDocuments)
.where(eq(issueDocuments.issueId, id));
const removedIssue = await tx
.delete(issues)
@@ -875,6 +882,12 @@ export function issueService(db: Db) {
.where(inArray(assets.id, attachmentAssetIds.map((row) => row.assetId)));
}
if (removedIssue && issueDocumentIds.length > 0) {
await tx
.delete(documents)
.where(inArray(documents.id, issueDocumentIds.map((row) => row.documentId)));
}
if (!removedIssue) return null;
const [enriched] = await withIssueLabels(tx, [removedIssue]);
return enriched;
@@ -1133,13 +1146,86 @@ export function issueService(db: Db) {
.returning()
.then((rows) => rows[0] ?? null),
listComments: (issueId: string) =>
db
listComments: async (
issueId: string,
opts?: {
afterCommentId?: string | null;
order?: "asc" | "desc";
limit?: number | null;
},
) => {
const order = opts?.order === "asc" ? "asc" : "desc";
const afterCommentId = opts?.afterCommentId?.trim() || null;
const limit =
opts?.limit && opts.limit > 0
? Math.min(Math.floor(opts.limit), MAX_ISSUE_COMMENT_PAGE_LIMIT)
: null;
const conditions = [eq(issueComments.issueId, issueId)];
if (afterCommentId) {
const anchor = await db
.select({
id: issueComments.id,
createdAt: issueComments.createdAt,
})
.from(issueComments)
.where(and(eq(issueComments.issueId, issueId), eq(issueComments.id, afterCommentId)))
.then((rows) => rows[0] ?? null);
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})
)`,
);
}
const query = db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId))
.orderBy(desc(issueComments.createdAt))
.then((comments) => comments.map(redactIssueComment)),
.where(and(...conditions))
.orderBy(
order === "asc" ? asc(issueComments.createdAt) : desc(issueComments.createdAt),
order === "asc" ? asc(issueComments.id) : desc(issueComments.id),
);
const comments = limit ? await query.limit(limit) : await query;
return comments.map(redactIssueComment);
},
getCommentCursor: async (issueId: string) => {
const [latest, countRow] = await Promise.all([
db
.select({
latestCommentId: issueComments.id,
latestCommentAt: issueComments.createdAt,
})
.from(issueComments)
.where(eq(issueComments.issueId, issueId))
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
.limit(1)
.then((rows) => rows[0] ?? null),
db
.select({
totalComments: sql<number>`count(*)::int`,
})
.from(issueComments)
.where(eq(issueComments.issueId, issueId))
.then((rows) => rows[0] ?? null),
]);
return {
totalComments: Number(countRow?.totalComments ?? 0),
latestCommentId: latest?.latestCommentId ?? null,
latestCommentAt: latest?.latestCommentAt ?? null,
};
},
getComment: (commentId: string) =>
db