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:
433
server/src/services/documents.ts
Normal file
433
server/src/services/documents.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user